[object Object]

← back to Lawyer Directory Builder

snapshot: 26 file(s) changed, +6 new, ~20 modified

94f106761c7f100f0d069ac94bbf13f2d0f1eae1 · 2026-05-13 08:57:56 -0700 · Steve

Files touched

Diff

commit 94f106761c7f100f0d069ac94bbf13f2d0f1eae1
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:57:56 2026 -0700

    snapshot: 26 file(s) changed, +6 new, ~20 modified
---
 REVIEW-2026-05-04.md                 |  63 ++++
 data/last-verified.json              |   8 +
 migrations/019_site_audit_html.sql   |  37 +++
 package-lock.json                    |  38 +++
 package.json                         |   7 +-
 public/audit.html                    |  87 +++++-
 public/index.html                    | 197 +++++++++++-
 public/methodology.html              |  18 +-
 public/privacy.html                  |  16 +-
 public/sitemap.xml                   |   6 +
 public/style.html                    | 577 +++++++++++++++++++++++++++++++++++
 public/terms.html                    |  16 +-
 scripts/build-agent/nightly-email.sh |   6 +-
 scripts/build-agent/watchdog.sh      |   2 +-
 scripts/build-pitch-html.py          | 287 +++++++++++++++++
 src/db/pool.ts                       |  45 +--
 src/enrich/ad_signals.ts             | 150 +++++++++
 src/enrich/site_audit.ts             |  47 ++-
 src/lib/match_firms.ts               | 136 ++++-----
 src/scripts/run_overnight.ts         |   8 +-
 src/server/auth.ts                   |  24 ++
 src/server/data_market.ts            |  76 +++++
 src/server/index.ts                  |  56 ++++
 src/server/leads.ts                  |  97 ++++--
 src/server/portal.ts                 |  39 ++-
 src/server/upgrade.ts                |  53 +++-
 26 files changed, 1928 insertions(+), 168 deletions(-)

diff --git a/REVIEW-2026-05-04.md b/REVIEW-2026-05-04.md
new file mode 100644
index 0000000..aed2ed3
--- /dev/null
+++ b/REVIEW-2026-05-04.md
@@ -0,0 +1,63 @@
+# lawyer-directory-builder — overnight debate-team review (2026-05-04)
+
+Code-reviewer + architect-reviewer parallel run. **5 patches applied** (4 P0 security + 1 launchd-fix). **5 P0 COMPLIANCE issues surfaced — Steve product calls required, not patched.**
+
+## Patches APPLIED (local — NOT deployed)
+
+| # | File:line | Severity | Issue | Fix |
+|---|---|---|---|---|
+| 1 | `src/server/index.ts:29-32` | **P0** | Wildcard CORS `Access-Control-Allow-Origin: *` on every route — credentialed reads from any origin against authenticated APIs (/api/billing/*, /api/me/*) | Allow-list gated on `PUBLIC_BASE_URL` env + localhost dev; sets `Vary: Origin` + `Allow-Credentials: true` only for matched origin |
+| 2 | `src/server/index.ts:179` | **P0** | `/api/firms/:id` did `SELECT * FROM organizations` — leaked future internal columns (`contact_crawled_at`, `site_intel_body`, etc.) to any unauth caller | Enumerated 21-column public allow-list |
+| 3 | `src/server/upgrade.ts:306` | **P1** | `/upgrade/thanks?_demo=paid` localhost backdoor used `req.hostname` check (spoofable via `Host:` header with `trust proxy: 1`) | Switched to `process.env.NODE_ENV !== 'production'` |
+| 4 | `src/scripts/run_overnight.ts:44` | **launchd-fix** | `spawn('npx', ['tsx', ...])` failed under launchd because launchd's minimal PATH doesn't include npm/nvm/homebrew bin dirs (this is the `com.steve.lawyer-build-nightly` exit=1 with empty stderr). Manual run worked because login shell PATH inherits them. | `spawn(process.execPath, [require.resolve('tsx/cli'), ...])` — uses absolute paths, no PATH dependency |
+
+`tsc --noEmit` exits 0 (pre-existing TS errors in community.ts/leads.ts/portal.ts unrelated to these patches).
+
+## P0 COMPLIANCE — Steve product decisions (NOT patched)
+
+These need your judgment, not a code edit. **All 5 are open action items in `COMPLIANCE.md` and currently violated by live code.**
+
+| # | Where | What's wrong (per Cal §6155 / Rule 7.x / non-lawyer operator standard) |
+|---|---|---|
+| C1 | `src/server/leads.ts:441-451` | `matched_firm_ids` is still being populated after lead submission. Status enum still includes `'matched'` / `'contacted'` / `'won'` (line 47). This is the active referral workflow that classifies the platform as an LRS under §6155. **You as a non-lawyer cannot operate an LRS.** COMPLIANCE.md §1 says stop. |
+| C2 | `src/server/portal.ts:630` | Lawyer Pro pitch reads "Get notified the moment a matched lead is submitted in your practice area + ZIP" — exact LRS-classification language. Replace with self-serve shared-inbox framing. |
+| C3 | Multiple | Brand "Counsel & Bar" still rendered publicly. COMPLIANCE.md §3 says rename without "Bar" before public launch. State Bar of California has trademark-policed similar names. |
+| C4 | All 11 server files | NO `/privacy-delete`, `/opt-out`, `/data-deletion-request` endpoint. CCPA/CPRA requires one. COMPLIANCE.md §4 promises it. |
+| C5 | `src/server/leads.ts:399,680` | Trust block links to `/privacy.html` + `/terms.html` — neither file exists in `/public/`. Dead links on production pages = consumer-protection violation in CA. |
+
+## Other P1 / P2 deferred (track in REVIEW)
+
+- `index.ts:147-149` `/api/attorneys` and `/api/firms` return `phone` + `website` unauth'd. Phone is public State Bar data, but bulk unauth exposure is a CCPA vector for the data marketplace.
+- `index.ts:231` `/api/professionals/search` returns `firm_phone` unauth — same issue.
+- `data_market.ts:504` `/data/download/:token` no rate limit — buyer can re-download indefinitely. Add cap on `downloads_count` for `one_time` orders.
+- `billing.ts:130-133` Stripe webhook missing-secret returns 503 silently — should alert (already in reviewer's own comment, not wired).
+- No CSRF protection on any state-changing POST. `sameSite: 'lax'` blocks cross-site links but NOT cross-origin `<form method=POST>`. Add `csurf` or double-submit cookie pattern, especially on `/admin/users/:id/{plan,suspend,delete}` forms in `portal.ts`.
+- 6 copies of `esc()` HTML-escape function (drift risk — one in `data_market.ts` omits `'` → `&#39;`). Extract to `src/lib/esc.ts`.
+- No `helmet` / CSP anywhere. `npm install helmet` + `app.use(helmet())`.
+- `directory.ts:468` `/directory/inquire` hardcodes `consent_to_contact = true` — for CCPA should be explicit opt-in checkbox.
+
+## Architecture roadmap
+
+**Architectural Impact: HIGH.** Project has crossed threshold where ad-hoc per-router conventions are leaking compliance, presentation, and operational risk.
+
+### 1. Codify compliance in code, not COMPLIANCE.md
+New `src/lib/compliance_mode.ts` exports `getMode()` (env: `COMPLIANCE_MODE`, default `directory` — fail-closed). Middleware `requireMode('hybrid' | 'lrs_certified')` applied to `matched_firm_ids` write path. Boot-time assert. Surface in `/api/health`. **This is the single change that makes "non-lawyer operator, strictest reading" enforceable.**
+
+### 2. Extract `directory-core` shared TypeScript package — host it here
+This project is the only TS-native sibling (doctor is JS, animals/restaurant likely JS). TS gives shared compliance types real teeth. Carve out: `mockup_templates.ts`, `render_mockups.ts`, `site_audit.ts`, `compliance.ts` (rename → `fetch-policy.ts`), `match_firms.ts`, `html-helpers.ts` (shared esc/layout/pagination).
+
+### 3. Split `leads.ts` (802) and `portal.ts` (773) along trust boundaries
+- `leads.ts` → `leads_public.ts` (form + results, no DB writes routing to firms) + `leads_admin.ts` (queue, status, notes). The `matched_firm_ids` write at line 446 is the single most legally-loaded line in the codebase and sits buried in the middle of an 800-line render-heavy module.
+- `portal.ts` → `auth_routes.ts` (signup/login/logout) + `dashboard.ts` (user dashboard) + `admin_users.ts`.
+
+### 4. Tests
+Zero `*.test.ts`. Compliance code has zero regression protection. Add `vitest` + a smoke test asserting `matched_firm_ids` write path is unreachable when `COMPLIANCE_MODE=directory`.
+
+### 5. Ship `scripts/deploy-kamatera.sh` + audit-viewer export
+Audit viewer is described as `/audit.html` in MEMORY but no checked-in deploy script. Pattern: rsync `public/audit.html` + static API snapshot, pm2 reload, verify.
+
+## Files touched
+
+- `/Users/stevestudio2/Projects/lawyer-directory-builder/src/server/index.ts`
+- `/Users/stevestudio2/Projects/lawyer-directory-builder/src/server/upgrade.ts`
+- `/Users/stevestudio2/Projects/lawyer-directory-builder/src/scripts/run_overnight.ts`
diff --git a/data/last-verified.json b/data/last-verified.json
new file mode 100644
index 0000000..3d510a1
--- /dev/null
+++ b/data/last-verified.json
@@ -0,0 +1,8 @@
+{
+  "_comment": "Bumped after each State Bar public-roll re-verification run. Surfaced in the landing colophon as 'Last verified'. Don't bump unless an actual verification completed — stale or false dates here are a directory-wide trust liability.",
+  "verified_at_iso": "2026-05-04T00:00:00Z",
+  "verified_at_display": "2026 · 05 · 04",
+  "source": "calbar.ca.gov public licensee roll",
+  "method": "full-roll diff against organizations.bar_number",
+  "count_active_at_verification": 89000
+}
diff --git a/migrations/019_site_audit_html.sql b/migrations/019_site_audit_html.sql
new file mode 100644
index 0000000..a688f2e
--- /dev/null
+++ b/migrations/019_site_audit_html.sql
@@ -0,0 +1,37 @@
+-- 019_site_audit_html.sql
+-- Persist raw HTML snapshots from site_audit Playwright pass so a downstream
+-- regex-only ad-signal detector can mine known tracking-pixel fingerprints.
+--
+-- Why a separate firm_ad_signals table (and not a column on organizations)?
+--   - This is INTERNAL sales-targeting data only. Per
+--     feedback_lawyer_directory_compliance.md, internal CRM signals stay isolated
+--     from the public profile schema so they can never accidentally render
+--     into a public-facing artifact.
+--   - Cal §6155 / Rule 7.x: directory-only, no fabricated marketing claims —
+--     these signals are inputs for our outreach, not facts about the firm.
+--
+-- Schema:
+--   site_audits.raw_html_path  — relpath under raw_html/ to the persisted HTML
+--   firm_ad_signals            — one row per firm, JSONB blob of pixel-detect booleans
+
+ALTER TABLE public.site_audits
+  ADD COLUMN IF NOT EXISTS raw_html_path TEXT;
+
+CREATE INDEX IF NOT EXISTS site_audits_raw_html_path_idx
+  ON public.site_audits(raw_html_path)
+  WHERE raw_html_path IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS public.firm_ad_signals (
+  firm_id            BIGINT PRIMARY KEY REFERENCES public.organizations(id) ON DELETE CASCADE,
+  ad_signals         JSONB NOT NULL DEFAULT '{}'::jsonb,
+  signals_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS firm_ad_signals_paid_count_idx
+  ON public.firm_ad_signals (((ad_signals->>'paid_ads_count')::int) DESC NULLS LAST);
+
+CREATE INDEX IF NOT EXISTS firm_ad_signals_updated_idx
+  ON public.firm_ad_signals (signals_updated_at DESC);
+
+COMMENT ON TABLE public.firm_ad_signals IS
+  'INTERNAL sales-targeting only. Tracking-pixel detect output mined from raw_html_path. Admin-gated. Never expose publicly.';
diff --git a/package-lock.json b/package-lock.json
index 1e4d8d6..76ffd0f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,8 +13,10 @@
         "cheerio": "^1.0.0",
         "cookie": "^1.1.1",
         "csv-stringify": "^6.5.2",
+        "directory-core": "file:../directory-core",
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
+        "helmet": "^8.1.0",
         "pg": "^8.13.1",
         "playwright": "^1.59.1",
         "robots-parser": "^3.0.1",
@@ -30,6 +32,29 @@
         "typescript": "^5.7.3"
       }
     },
+    "../directory-core": {
+      "version": "0.1.0",
+      "dependencies": {
+        "bcryptjs": "^2.4.3",
+        "cookie": "^0.6.0",
+        "dotenv": "^16.4.0",
+        "robots-parser": "^3.0.1",
+        "undici": "^6.19.0"
+      },
+      "devDependencies": {
+        "@types/bcryptjs": "^2.4.6",
+        "@types/cookie": "^0.6.0",
+        "@types/express": "^4.0.0",
+        "@types/node": "^22.0.0",
+        "@types/pg": "^8.0.0",
+        "tsx": "^4.0.0",
+        "typescript": "^5.0.0"
+      },
+      "peerDependencies": {
+        "express": "^4.0.0",
+        "pg": "^8.0.0"
+      }
+    },
     "node_modules/@emnapi/runtime": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -1333,6 +1358,10 @@
         "node": ">=8"
       }
     },
+    "node_modules/directory-core": {
+      "resolved": "../directory-core",
+      "link": true
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -1742,6 +1771,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/htmlparser2": {
       "version": "10.1.0",
       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
diff --git a/package.json b/package.json
index 21e4ec5..fe0d8ec 100644
--- a/package.json
+++ b/package.json
@@ -11,13 +11,14 @@
     "ingest:state-bar": "tsx src/scripts/run_state_bar.ts",
     "ingest:state-bar:smoke": "tsx src/scripts/run_state_bar.ts --smoke",
     "export:csv": "tsx src/scripts/export_csv.ts",
-    "server": "tsx src/server/index.ts",
-    "dev": "tsx watch src/server/index.ts",
+    "server": "node --import tsx src/server/index.ts",
+    "dev": "node --import tsx --watch src/server/index.ts",
     "stats": "tsx src/scripts/stats.ts",
     "overnight": "tsx src/scripts/run_overnight.ts",
     "geocode": "tsx src/enrich/geocode_nominatim.ts",
     "discover": "tsx src/enrich/discover_websites.ts",
     "enrich:contacts": "tsx src/enrich/firm_website_contacts.ts",
+    "enrich:adsignals": "tsx src/enrich/ad_signals.ts",
     "create-admin": "tsx src/scripts/create_admin.ts"
   },
   "dependencies": {
@@ -25,8 +26,10 @@
     "cheerio": "^1.0.0",
     "cookie": "^1.1.1",
     "csv-stringify": "^6.5.2",
+    "directory-core": "file:../directory-core",
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
+    "helmet": "^8.1.0",
     "pg": "^8.13.1",
     "playwright": "^1.59.1",
     "robots-parser": "^3.0.1",
diff --git a/public/audit.html b/public/audit.html
index 9b30fff..f8a710d 100644
--- a/public/audit.html
+++ b/public/audit.html
@@ -84,6 +84,23 @@
   .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-lede { font-family: var(--serif); font-style: italic; font-weight: 300; color: var(--ink-soft); font-size: clamp(15px, 1.4vw, 18px); line-height: 1.55; max-width: 64ch; margin: 18px 0 0; letter-spacing: .005em; }
+  .audit-distribution { max-width: 1280px; margin: 0 auto 32px; padding: 22px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); display: grid; grid-template-columns: 200px 1fr; gap: 28px; align-items: center; }
+  .ad-eyebrow { font-size: 10px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 6px; }
+  .ad-meta { font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; line-height: 1.45; margin: 0; letter-spacing: .005em; }
+  .ad-bars { display: grid; grid-template-columns: repeat(4, 1fr); gap: 22px; align-items: end; }
+  .ad-bar { display: flex; flex-direction: column; gap: 8px; }
+  .ad-bar-tier { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 13px; color: var(--metal); letter-spacing: .01em; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
+  .ad-bar-tier b { font-style: normal; font-family: var(--sans); color: var(--ink); font-weight: 500; font-size: 18px; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
+  .ad-bar-track { height: 4px; background: var(--rule); position: relative; }
+  .ad-bar-fill { position: absolute; inset: 0 auto 0 0; background: var(--metal); width: 0%; transition: width 700ms cubic-bezier(.16,.84,.3,1); }
+  .ad-bars[data-painted] .ad-bar-fill { transition: width 200ms linear; }
+  .ad-bar.ad-tier-a .ad-bar-fill { background: linear-gradient(90deg, var(--metal-glow), var(--metal)); }
+  .ad-bar.ad-tier-b .ad-bar-fill { background: var(--metal); }
+  .ad-bar.ad-tier-c .ad-bar-fill { background: #a07a40; }
+  .ad-bar.ad-tier-d .ad-bar-fill { background: #5a4220; }
+  .ad-bar-meta { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); font-weight: 500; }
+  @media (max-width: 780px) { .audit-distribution { grid-template-columns: 1fr; gap: 18px; } .ad-bars { gap: 14px; } }
   .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); }
@@ -158,17 +175,39 @@
         <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>
+      <p class="audit-lede">Independent audit of every California-licensed firm with a published website. Twelve technical and presentation signals, weighted, refresh cadence published. Firms do not pay for inclusion or score adjustment.</p>
+    </div>
+  </section>
+
+  <section class="audit-distribution" aria-label="Tier distribution across audited sites">
+    <div>
+      <p class="ad-eyebrow">Distribution at a glance</p>
+      <p class="ad-meta"><span id="dist-denominator">— audited firms</span> · <a href="/methodology.html" style="color:var(--ink-mute);border-bottom:1px dotted var(--rule);text-decoration:none;font-style:normal;font-family:var(--sans);font-size:11px;letter-spacing:.04em">methodology v1.0 →</a></p>
+    </div>
+    <div class="ad-bars">
+      <div class="ad-bar ad-tier-a">
+        <div class="ad-bar-tier"><span>High · 90+</span><b id="dist-a-pct">—</b></div>
+        <div class="ad-bar-track"><div class="ad-bar-fill" id="dist-a-fill"></div></div>
+        <div class="ad-bar-meta"><span id="dist-a-n">— sites</span></div>
+      </div>
+      <div class="ad-bar ad-tier-b">
+        <div class="ad-bar-tier"><span>Mid · 70–89</span><b id="dist-b-pct">—</b></div>
+        <div class="ad-bar-track"><div class="ad-bar-fill" id="dist-b-fill"></div></div>
+        <div class="ad-bar-meta"><span id="dist-b-n">— sites</span></div>
+      </div>
+      <div class="ad-bar ad-tier-c">
+        <div class="ad-bar-tier"><span>Low · 50–69</span><b id="dist-c-pct">—</b></div>
+        <div class="ad-bar-track"><div class="ad-bar-fill" id="dist-c-fill"></div></div>
+        <div class="ad-bar-meta"><span id="dist-c-n">— sites</span></div>
+      </div>
+      <div class="ad-bar ad-tier-d">
+        <div class="ad-bar-tier"><span>Weak · &lt;50</span><b id="dist-d-pct">—</b></div>
+        <div class="ad-bar-track"><div class="ad-bar-fill" id="dist-d-fill"></div></div>
+        <div class="ad-bar-meta"><span id="dist-d-n">— sites</span></div>
+      </div>
     </div>
   </section>
 
-  <div class="audit-keystrip" aria-label="Score legend">
-    <span class="ks-label">Site signals</span>
-    <span class="ks-pair">90+ <span>High</span></span>
-    <span class="ks-pair">70–89 <span>Mid</span></span>
-    <span class="ks-pair">50–69 <span>Low</span></span>
-    <span class="ks-pair">&lt;50 <span>Weak</span></span>
-    <a href="/methodology.html" class="ks-label" style="margin-left:auto;color:var(--ink-mute);text-decoration:none;border-bottom:1px dotted var(--rule);padding-bottom:1px">Methodology v1.0 · 12 signals · how we score →</a>
-  </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>
@@ -226,6 +265,38 @@ function render() {
   document.getElementById('stat-count').textContent = `${filtered.length} sites`;
   document.getElementById('stat-avg').textContent = `· avg score ${avg}`;
 
+  // Tier distribution
+  const tiers = { a: 0, b: 0, c: 0, d: 0 };
+  for (const r of filtered) {
+    const s = r.marketing_score || 0;
+    if (s >= 90) tiers.a++;
+    else if (s >= 70) tiers.b++;
+    else if (s >= 50) tiers.c++;
+    else tiers.d++;
+  }
+  const total = filtered.length;
+  // Denominator line — exposes the implicit "filtered ≠ population" so the chart can't be misread as the population.
+  const denom = document.getElementById('dist-denominator');
+  if (denom) {
+    if (min > 0) {
+      denom.textContent = `${total.toLocaleString()} of ${DATA.length.toLocaleString()} audited firms shown`;
+    } else {
+      denom.textContent = `${total.toLocaleString()} audited firm${total === 1 ? '' : 's'}`;
+    }
+  }
+  for (const k of ['a','b','c','d']) {
+    const n = tiers[k];
+    const pct = total ? Math.round((n / total) * 100) : 0;
+    document.getElementById(`dist-${k}-pct`).textContent = `${pct}%`;
+    document.getElementById(`dist-${k}-n`).textContent = `${n.toLocaleString()} site${n === 1 ? '' : 's'}`;
+    document.getElementById(`dist-${k}-fill`).style.width = `${pct}%`;
+  }
+  // After first paint shorten the bar transition so slider drags don't replay 700ms each tick.
+  const bars = document.querySelector('.ad-bars');
+  if (bars && !bars.dataset.painted) {
+    requestAnimationFrame(() => { bars.dataset.painted = '1'; });
+  }
+
   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('');
diff --git a/public/index.html b/public/index.html
index 7f13f61..236464c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -289,21 +289,65 @@
     .feat:first-child { border-top: 0; }
   }
 
-  /* ─── Closer ───────────────────────────────────────────────────── */
+  /* ─── Colophon (closer) ───────────────────────────────────────── */
   .closer {
-    padding: 120px 48px; background: var(--noir-deep);
+    padding: 120px 48px 96px; background: var(--noir-deep);
     border-top: var(--hairline);
-    text-align: center;
   }
-  .closer-inner { max-width: 720px; margin: 0 auto; }
-  .closer .quote {
+  .colophon {
+    max-width: 1280px; margin: 0 auto;
+    display: grid; grid-template-columns: 1.6fr 1fr; gap: 96px;
+    align-items: end;
+  }
+  .colophon-statement {
     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;
+    font-size: clamp(28px, 3.6vw, 46px); line-height: 1.18;
+    color: var(--ink); margin: 0; letter-spacing: -0.012em;
+    max-width: 22ch;
+  }
+  .colophon-statement em { font-style: normal; color: var(--metal); font-weight: 400; }
+  .colophon-stack { display: flex; flex-direction: column; gap: 22px; align-items: flex-end; text-align: right; }
+  .colophon-stack-row {
+    display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
+    padding-right: 0;
+  }
+  .colophon-stack-lbl {
+    font-size: 9px; letter-spacing: 0.32em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+  .colophon-stack-val {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 18px; color: var(--metal); letter-spacing: 0.005em;
+    line-height: 1; font-variant-numeric: tabular-nums;
+  }
+  .colophon-stack-val.sans {
+    font-family: var(--sans); font-style: normal; font-weight: 500;
+    font-size: 12px; color: var(--ink); letter-spacing: 0.18em;
+    text-transform: uppercase;
+  }
+  .colophon-rule {
+    max-width: 1280px; margin: 0 auto 56px;
+    height: 1px; background: var(--metal-rule-gradient);
+    opacity: 0.5;
+  }
+  .colophon-signature {
+    max-width: 1280px; margin: 56px auto 0;
+    padding-top: 28px;
+    border-top: 1px solid var(--rule);
+    display: flex; justify-content: space-between; align-items: center;
+    font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500; gap: 24px; flex-wrap: wrap;
+  }
+  .colophon-signature .glyph {
+    font-family: var(--serif); font-style: italic; font-size: 22px;
+    color: var(--metal); letter-spacing: 0; text-transform: none;
+    line-height: 1;
+  }
+  @media (max-width: 880px) {
+    .colophon { grid-template-columns: 1fr; gap: 48px; }
+    .colophon-stack { align-items: flex-start; text-align: left; }
+    .closer { padding: 64px 24px 48px; }
   }
-  .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; } }
 
   /* ─── Footer ───────────────────────────────────────────────────── */
   footer {
@@ -410,6 +454,40 @@
   .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; }
 
+  /* ─── Editorial engravings (decorative SVG art) ────────────────── */
+  .engraving {
+    display: flex; flex-direction: column; align-items: center; gap: 14px;
+    padding: 56px 24px;
+    background: var(--noir);
+    position: relative;
+  }
+  .engraving::before, .engraving::after {
+    content: ''; position: absolute; top: 50%; height: 1px;
+    width: clamp(80px, 18vw, 220px);
+    background: linear-gradient(90deg, transparent 0%, var(--rule) 100%);
+  }
+  .engraving::before { left: 0; }
+  .engraving::after { right: 0; transform: scaleX(-1); }
+  .engraving svg { display: block; max-width: 100%; height: auto; }
+  .engraving .e-label {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    color: var(--metal-deep); font-size: 12px; letter-spacing: 0.18em;
+    text-transform: uppercase; opacity: 0.7;
+  }
+  .engraving--frieze { padding: 36px 0; overflow: hidden; }
+  .engraving--frieze::before, .engraving--frieze::after { display: none; }
+  .closer .rosette { width: 64px; height: 64px; margin: 0 auto 28px; display: block; opacity: 0.72; }
+  .closer .fleuron {
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: 56px; line-height: 1; color: var(--metal);
+    display: block; margin: 0 auto 28px; opacity: 0.7;
+    text-align: center; letter-spacing: 0;
+  }
+  @media (max-width: 720px) {
+    .engraving { padding: 40px 18px; }
+    .engraving::before, .engraving::after { width: 24vw; }
+  }
+
   /* ─── Subtle entrance ──────────────────────────────────────────── */
   @media (prefers-reduced-motion: no-preference) {
     .reveal { opacity: 0; transform: translateY(14px); animation: rise 800ms cubic-bezier(.16,.84,.3,1) forwards; }
@@ -497,6 +575,48 @@
   </div>
 </section>
 
+<!-- Editorial engraving I · classical capital -->
+<div class="engraving scroll-reveal" role="presentation" aria-label="Classical column capital — decorative section divider">
+  <svg viewBox="0 0 320 110" width="320" height="110" aria-hidden="true">
+    <defs>
+      <linearGradient id="eng-metal-1" 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>
+    <g fill="none" stroke="url(#eng-metal-1)" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round">
+      <!-- Abacus (top slab) -->
+      <rect x="60" y="14" width="200" height="6"/>
+      <line x1="62" y1="22" x2="258" y2="22"/>
+      <!-- Echinus / volute band -->
+      <path d="M70 28 Q 80 24 96 28"/>
+      <path d="M96 28 Q 112 32 128 28"/>
+      <path d="M128 28 Q 144 24 160 28"/>
+      <path d="M160 28 Q 176 32 192 28"/>
+      <path d="M192 28 Q 208 24 224 28"/>
+      <path d="M224 28 Q 240 32 250 28"/>
+      <!-- Twin volutes -->
+      <path d="M70 28 Q 56 36 60 50 Q 64 60 76 56 Q 86 52 86 44 Q 86 38 80 38 Q 75 38 75 44"/>
+      <path d="M250 28 Q 264 36 260 50 Q 256 60 244 56 Q 234 52 234 44 Q 234 38 240 38 Q 245 38 245 44"/>
+      <!-- Astragal -->
+      <line x1="84" y1="62" x2="236" y2="62"/>
+      <line x1="84" y1="66" x2="236" y2="66"/>
+      <!-- Fluted shaft hint -->
+      <line x1="100" y1="72" x2="100" y2="100"/>
+      <line x1="120" y1="72" x2="120" y2="100"/>
+      <line x1="140" y1="72" x2="140" y2="100"/>
+      <line x1="160" y1="72" x2="160" y2="100"/>
+      <line x1="180" y1="72" x2="180" y2="100"/>
+      <line x1="200" y1="72" x2="200" y2="100"/>
+      <line x1="220" y1="72" x2="220" y2="100"/>
+      <!-- Center diamond -->
+      <path d="M155 38 L 160 33 L 165 38 L 160 43 Z" fill="url(#eng-metal-1)" stroke="none" opacity="0.55"/>
+    </g>
+  </svg>
+  <span class="e-label">Volume I · The Registry</span>
+</div>
+
 <section class="ledger scroll-reveal">
   <div class="ledger-inner">
     <div class="ledger-grid">
@@ -567,9 +687,61 @@
   </div>
 </section>
 
+<!-- Editorial engraving II · banking-engraving guilloché frieze -->
+<div class="engraving engraving--frieze scroll-reveal" role="presentation" aria-label="Engraved guilloché frieze — section ornament">
+  <svg viewBox="0 0 1200 36" width="100%" height="36" preserveAspectRatio="none" aria-hidden="true">
+    <defs>
+      <linearGradient id="eng-metal-2" x1="0" y1="0" x2="1" y2="0">
+        <stop offset="0%" stop-color="rgba(184,153,104,0)"/>
+        <stop offset="20%" stop-color="rgba(184,153,104,0.85)"/>
+        <stop offset="50%" stop-color="rgba(212,182,131,1)"/>
+        <stop offset="80%" stop-color="rgba(184,153,104,0.85)"/>
+        <stop offset="100%" stop-color="rgba(184,153,104,0)"/>
+      </linearGradient>
+      <pattern id="guilloche" x="0" y="0" width="48" height="36" patternUnits="userSpaceOnUse">
+        <path d="M0 18 Q 12 4 24 18 T 48 18" fill="none" stroke="url(#eng-metal-2)" stroke-width="0.6"/>
+        <path d="M0 18 Q 12 32 24 18 T 48 18" fill="none" stroke="url(#eng-metal-2)" stroke-width="0.6"/>
+        <circle cx="12" cy="11" r="0.7" fill="url(#eng-metal-2)" opacity="0.6"/>
+        <circle cx="36" cy="25" r="0.7" fill="url(#eng-metal-2)" opacity="0.6"/>
+      </pattern>
+    </defs>
+    <rect x="0" y="0" width="1200" height="36" fill="url(#guilloche)" opacity="0.85"/>
+    <line x1="0" y1="0.5" x2="1200" y2="0.5" stroke="url(#eng-metal-2)" stroke-width="0.5" opacity="0.5"/>
+    <line x1="0" y1="35.5" x2="1200" y2="35.5" stroke="url(#eng-metal-2)" stroke-width="0.5" opacity="0.5"/>
+  </svg>
+</div>
+
 <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 class="colophon-rule" aria-hidden="true"></div>
+  <div class="colophon">
+    <p class="colophon-statement">A directory <em>is what its sources are.</em> Ours is the State Bar of California's public roll — indexed in full.</p>
+    <div class="colophon-stack">
+      <div class="colophon-stack-row">
+        <span class="colophon-stack-lbl">Edition</span>
+        <span class="colophon-stack-val">Volume I · MMXXVI</span>
+      </div>
+      <div class="colophon-stack-row">
+        <span class="colophon-stack-lbl">Source</span>
+        <span class="colophon-stack-val">CalBar public roll</span>
+      </div>
+      <!-- COLOPHON-DATE: hand-edited; bump on every deploy that re-verifies the State Bar roll. Stale value here is a trust liability. -->
+      <div class="colophon-stack-row">
+        <span class="colophon-stack-lbl">Last verified</span>
+        <span class="colophon-stack-val sans">2026 · 05 · 04</span>
+      </div>
+      <div class="colophon-stack-row">
+        <span class="colophon-stack-lbl">Audit</span>
+        <span class="colophon-stack-val">No charge to any firm</span>
+      </div>
+      <div class="colophon-stack-row">
+        <span class="colophon-stack-lbl">Operator</span>
+        <span class="colophon-stack-val sans">Independent · non-attorney</span>
+      </div>
+    </div>
+  </div>
+  <div class="colophon-signature">
+    <span>Counsel <span class="glyph">&amp;</span> Bar</span>
+    <span>An indexed registry — not a referral service · §6155</span>
   </div>
 </section>
 
@@ -583,6 +755,7 @@
     <a href="/find-a-lawyer">Find counsel</a>
     <a href="/audit.html">Site audit</a>
     <a href="/methodology.html">Audit methodology</a>
+    <a href="/style.html">Visual standard</a>
   </div>
   <div class="col">
     <h4>Attorneys</h4>
diff --git a/public/methodology.html b/public/methodology.html
index f747111..9a7a2ff 100644
--- a/public/methodology.html
+++ b/public/methodology.html
@@ -303,9 +303,25 @@ footer a{color:var(--metal)}
     <p>The audit is published <strong>independently of any commercial offering</strong>. Purchasing the EZ Upgrade — or any other Counsel &amp; Bar service — is not required to receive, view, or correct your score, and does not adjust the score. Signals are checked against the same criteria for every audited firm, paying or not.</p>
   </div>
 
+  <h2 class="section">Acceptable <em>exceptions.</em></h2>
+  <div class="disclosure">
+    <p class="eyebrow">— Where a signal does not apply —</p>
+    <p>A small number of sites legitimately omit certain signals because their business model does not call for them. The audit treats these omissions as <strong>not-applicable</strong> rather than as failures, provided the site discloses the reason. Examples:</p>
+    <p>· <strong>Phone number.</strong> Sites whose stated purpose is research, indexing, or data publishing (and which do not solicit client matters) may legitimately omit a published phone number, where doing so would mislead consumers into expecting case intake or legal advice. The site must clearly disclaim phone-contact intent.<br>
+       · <strong>Practice-area schema.</strong> Non-firm directory and reference sites are not penalized for omitting <code>LegalService</code>-style schema, since they are not legal-service providers.<br>
+       · <strong>Contact form.</strong> Read-only reference sites that publish no inbound channel beyond a moderated email address are scored on the email signal alone.</p>
+  </div>
+
   <h2 class="section">Refresh <em>cadence.</em></h2>
   <p class="body">Audited firms are re-checked on a rolling basis. We aim for a refresh interval of no longer than ninety days per firm, and we re-audit on demand when a firm reports a site change. The most recent audit date for a given firm appears on its audit card under "Audited [date] · methodology v1.0".</p>
 
+  <h2 class="section">Self-audit <em>disclosure.</em></h2>
+  <div class="disclosure">
+    <p class="eyebrow">— Counsel &amp; Bar, audited by its own rule —</p>
+    <p>To make the methodology testable in public, we run it against this site too. Counsel &amp; Bar is a directory and software platform, not a law firm; under the rule above, its phone signal is recorded as <strong>not-applicable</strong> rather than missing. We publish the resulting score as we would any audited firm, and we do not adjust the score upward to account for our role as the auditor.</p>
+    <p>The self-audit is rerun on the same ninety-day cadence as audited firms, and any change to its tier appears here.</p>
+  </div>
+
   <h2 class="section">Corrections &amp; <em>removal.</em></h2>
   <p class="body">If your firm is shown with an incorrect score, an outdated screenshot, or you believe a signal was misread, send a note to <a href="mailto:audit@agentabrams.com">audit@agentabrams.com</a> and we will re-audit and update within five business days. Listed firms may also request removal of their audit card; the directory listing itself draws on the State Bar's public roll and is not removable here — request removal from the State Bar registry directly for that.</p>
 
@@ -321,7 +337,7 @@ footer a{color:var(--metal)}
 
 <footer>
   Counsel &amp; Bar is a directory of California-licensed attorneys, not a lawyer referral service under California Business &amp; Professions Code §6155.
-  · <a href="/privacy.html">Privacy</a> · <a href="/terms.html">Terms</a> · <a href="/audit.html">Audit</a>
+  · <a href="/audit.html">Audit</a> · <a href="/style.html">Visual standard</a> · <a href="/privacy.html">Privacy</a> · <a href="/terms.html">Terms</a>
 </footer>
 
 </body>
diff --git a/public/privacy.html b/public/privacy.html
index d96e75f..4c11aec 100644
--- a/public/privacy.html
+++ b/public/privacy.html
@@ -2,6 +2,10 @@
 <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="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
+<link rel="apple-touch-icon" href="/favicon-32.png">
+<meta name="theme-color" content="#0a0a0c">
 <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">
@@ -177,5 +181,15 @@ footer{padding:36px 48px;border-top:1px solid var(--rule);text-align:center;colo
 <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>
+<footer style="padding:32px 28px;border-top:1px solid var(--rule);background:var(--noir);text-align:center;font-size:11px;color:var(--ink-mute);line-height:1.7">
+  <div style="margin-bottom:10px">Counsel &amp; Bar · A directory and software platform · Not a law firm · Not a lawyer referral service · Not legal advice</div>
+  <div style="font-size:11px">
+    <a href="/" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Home</a> ·
+    <a href="/find-a-lawyer" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Find counsel</a> ·
+    <a href="/methodology.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Methodology</a> ·
+    <a href="/audit.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Audit</a> ·
+    <a href="/style.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Visual standard</a> ·
+    <a href="/terms.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Terms</a>
+  </div>
+</footer>
 </body></html>
diff --git a/public/sitemap.xml b/public/sitemap.xml
index c27505d..74d0f1a 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -35,6 +35,12 @@
     <changefreq>monthly</changefreq>
     <priority>0.8</priority>
   </url>
+  <url>
+    <loc>https://lawyers.agentabrams.com/style.html</loc>
+    <lastmod>2026-05-04</lastmod>
+    <changefreq>monthly</changefreq>
+    <priority>0.6</priority>
+  </url>
   <url>
     <loc>https://lawyers.agentabrams.com/upgrade</loc>
     <lastmod>2026-05-04</lastmod>
diff --git a/public/style.html b/public/style.html
new file mode 100644
index 0000000..41cbf1d
--- /dev/null
+++ b/public/style.html
@@ -0,0 +1,577 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The visual standard — Counsel &amp; Bar</title>
+<meta name="description" content="The look of a tier-A directory listing. Palette, typography, ornament, and voice for Counsel & Bar — independent California attorney directory.">
+<link rel="canonical" href="https://lawyers.agentabrams.com/style.html">
+<meta name="robots" content="index, follow">
+<link rel="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
+<meta name="theme-color" content="#0a0a0c">
+
+<meta property="og:type" content="article">
+<meta property="og:site_name" content="Counsel &amp; Bar">
+<meta property="og:title" content="The visual standard — Counsel &amp; Bar">
+<meta property="og:description" content="Palette, typography, ornament, and voice for the directory.">
+<meta property="og:url" content="https://lawyers.agentabrams.com/style.html">
+<meta property="og:image" content="https://lawyers.agentabrams.com/og.png">
+<meta name="twitter:card" content="summary_large_image">
+
+<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;0,600;1,300;1,400;1,500&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;
+    --serif:  "Cormorant Garamond", "Georgia", serif;
+    --sans:   "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+    --hairline: 1px solid var(--rule);
+  }
+  *, *::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: 1.55;
+    overflow-x: hidden;
+  }
+  a { color: var(--metal); text-decoration: none; border-bottom: 1px solid var(--rule); padding-bottom: 1px; }
+  a:hover { color: var(--metal-glow); border-color: var(--metal); }
+  ::selection { background: var(--metal); color: var(--noir); }
+
+  /* ─── 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-row { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; border: 0; }
+  .brand { font-family: var(--serif); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.01em; }
+  .brand .ampersand { color: var(--metal); font-style: italic; padding: 0 4px; }
+  .topbar nav { font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); }
+  .topbar nav a { color: var(--ink-mute); border: 0; margin-left: 28px; transition: color 180ms ease; padding: 0; }
+  .topbar nav a:hover { color: var(--metal); }
+  @media (max-width: 720px) { .topbar { padding: 16px 20px; } .topbar nav a { margin-left: 16px; } }
+
+  /* ─── Layout ────────────────────────────────────────────────────── */
+  .wrap { max-width: 1100px; margin: 0 auto; padding: 140px 48px 80px; }
+  @media (max-width: 720px) { .wrap { padding: 110px 20px 56px; } }
+
+  /* ─── Folio mark ────────────────────────────────────────────────── */
+  .folio {
+    display: flex; align-items: center; gap: 18px; margin: 0 0 32px;
+    color: var(--metal); font-family: var(--serif); font-style: italic;
+    font-size: 14px; letter-spacing: 0.02em;
+  }
+  .folio .f-rule { flex: 0 0 56px; height: 1px; background: linear-gradient(90deg, transparent 0%, var(--metal) 50%, transparent 100%); }
+  .folio .f-meta { font-family: var(--sans); font-style: normal; color: var(--ink-mute); font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; }
+
+  /* ─── Display title ─────────────────────────────────────────────── */
+  h1.display {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(48px, 8vw, 96px); line-height: 0.98;
+    letter-spacing: -0.025em; margin: 0 0 28px; color: var(--ink);
+  }
+  h1.display em { font-style: italic; color: var(--metal); font-weight: 400; }
+  .lede {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(18px, 1.6vw, 22px); line-height: 1.55;
+    color: var(--ink-soft); max-width: 60ch; margin: 0 0 80px;
+    font-style: italic;
+  }
+
+  /* ─── Section ───────────────────────────────────────────────────── */
+  section { margin: 96px 0; }
+  .section-eyebrow {
+    font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin: 0 0 18px;
+  }
+  h2.section-title {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(32px, 4vw, 48px); line-height: 1.1;
+    letter-spacing: -0.015em; margin: 0 0 8px; color: var(--ink);
+  }
+  h2.section-title em { font-style: italic; color: var(--metal); font-weight: 400; }
+  .section-sub {
+    font-family: var(--serif); font-style: italic; color: var(--ink-mute);
+    font-size: 16px; margin: 0 0 36px;
+  }
+
+  /* ─── Palette ───────────────────────────────────────────────────── */
+  .palette {
+    display: grid; grid-template-columns: repeat(5, 1fr); gap: 0;
+    border: var(--hairline);
+  }
+  .swatch {
+    padding: 28px 22px 22px;
+    border-right: var(--hairline);
+    min-height: 200px;
+    display: flex; flex-direction: column; justify-content: flex-end; gap: 6px;
+    position: relative;
+  }
+  .swatch:last-child { border-right: 0; }
+  .swatch::before {
+    content: ''; position: absolute; top: 16px; left: 22px; right: 22px; height: 1px; background: currentColor; opacity: 0.35;
+  }
+  .swatch .s-name {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 18px; letter-spacing: 0.01em; opacity: 0.95;
+  }
+  .swatch .s-hex {
+    font-family: var(--sans); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
+    font-weight: 500; opacity: 0.7; font-variant-numeric: tabular-nums;
+  }
+  .swatch .s-role {
+    font-family: var(--sans); font-size: 9px; letter-spacing: 0.24em; text-transform: uppercase;
+    color: var(--ink-mute); margin-top: 8px; padding-top: 8px;
+    border-top: 1px solid currentColor;
+  }
+  .sw-noir       { background: #0a0a0c; color: var(--ink); }
+  .sw-noir-rise  { background: #131316; color: var(--ink); }
+  .sw-rule       { background: #2a2724; color: var(--ink-soft); }
+  .sw-ink        { background: #f4f1ea; color: var(--noir); }
+  .sw-metal      { background: linear-gradient(180deg, #d4b683 0%, #b89968 50%, #8a7044 100%); color: var(--noir); }
+  .sw-metal .s-role { border-top-color: rgba(10,10,12,0.35); }
+  .sw-ink .s-role   { border-top-color: rgba(10,10,12,0.18); }
+  @media (max-width: 720px) {
+    .palette { grid-template-columns: repeat(2, 1fr); }
+    .swatch { border-right: 0; border-bottom: var(--hairline); }
+    .swatch:nth-child(odd) { border-right: var(--hairline); }
+    .swatch:nth-last-child(-n+1) { border-bottom: 0; }
+  }
+
+  /* ─── Typography ────────────────────────────────────────────────── */
+  .type-grid {
+    display: grid; grid-template-columns: 1fr 1fr; gap: 0;
+    border-top: var(--hairline); border-bottom: var(--hairline);
+  }
+  .type-cell {
+    padding: 36px 0;
+    border-right: var(--hairline);
+  }
+  .type-cell:nth-child(2n) { padding-left: 36px; border-right: 0; }
+  .type-cell:nth-child(2n-1) { padding-right: 36px; }
+  .type-cell .t-eyebrow {
+    font-family: var(--sans); font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin: 0 0 18px;
+  }
+  .specimen-serif {
+    font-family: var(--serif); font-weight: 300; font-size: clamp(48px, 6vw, 84px);
+    line-height: 0.95; letter-spacing: -0.025em; color: var(--ink); margin: 0 0 16px;
+  }
+  .specimen-serif em { font-style: italic; color: var(--metal); font-weight: 400; }
+  .specimen-sans {
+    font-family: var(--sans); font-weight: 300; font-size: 18px; line-height: 1.55;
+    color: var(--ink-soft); margin: 0 0 16px;
+  }
+  .specimen-sans strong { font-weight: 500; color: var(--ink); }
+  .t-meta {
+    font-family: var(--sans); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+  .t-meta span { color: var(--metal); padding: 0 6px; }
+  @media (max-width: 720px) {
+    .type-grid { grid-template-columns: 1fr; }
+    .type-cell { padding: 28px 0; border-right: 0; border-bottom: var(--hairline); }
+    .type-cell:last-child { border-bottom: 0; }
+    .type-cell:nth-child(2n) { padding-left: 0; }
+    .type-cell:nth-child(2n-1) { padding-right: 0; }
+  }
+
+  /* ─── Scale ─────────────────────────────────────────────────────── */
+  .scale {
+    display: grid; grid-template-columns: 80px 1fr 90px; gap: 24px;
+    padding: 24px 0; border-bottom: var(--hairline);
+    align-items: baseline;
+  }
+  .scale:last-child { border-bottom: 0; }
+  .scale .s-num {
+    font-family: var(--sans); font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; font-variant-numeric: tabular-nums;
+  }
+  .scale .s-text { font-family: var(--serif); color: var(--ink); line-height: 1; }
+  .scale .s-meta {
+    font-family: var(--sans); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500; text-align: right; font-variant-numeric: tabular-nums;
+  }
+  .s-d1 { font-size: 64px; font-weight: 300; letter-spacing: -0.025em; }
+  .s-d1 em { font-style: italic; color: var(--metal); }
+  .s-d2 { font-size: 40px; font-weight: 400; letter-spacing: -0.015em; }
+  .s-d3 { font-size: 24px; font-weight: 400; }
+  .s-body { font-family: var(--sans); font-size: 16px; font-weight: 300; line-height: 1.55; color: var(--ink-soft); }
+  .s-eyebrow { font-family: var(--sans); font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--metal); font-weight: 500; }
+  @media (max-width: 720px) {
+    .scale { grid-template-columns: 1fr; gap: 6px; }
+    .scale .s-meta { text-align: left; }
+    .s-d1 { font-size: 44px; }
+    .s-d2 { font-size: 30px; }
+  }
+
+  /* ─── Ornament gallery ──────────────────────────────────────────── */
+  .ornament-gallery {
+    display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
+    border-top: var(--hairline); border-bottom: var(--hairline);
+  }
+  .orn-cell {
+    padding: 56px 28px;
+    border-right: var(--hairline);
+    display: flex; flex-direction: column; align-items: center; gap: 18px;
+    text-align: center;
+  }
+  .orn-cell:last-child { border-right: 0; }
+  .orn-cell svg { display: block; max-width: 100%; height: auto; }
+  .orn-cell .orn-name {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 16px; color: var(--ink); margin-top: 8px;
+  }
+  .orn-cell .orn-meta {
+    font-family: var(--sans); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+  @media (max-width: 720px) {
+    .ornament-gallery { grid-template-columns: 1fr; }
+    .orn-cell { border-right: 0; border-bottom: var(--hairline); padding: 40px 18px; }
+    .orn-cell:last-child { border-bottom: 0; }
+  }
+
+  /* ─── Voice ─────────────────────────────────────────────────────── */
+  .voice-grid {
+    display: grid; grid-template-columns: 1fr 1fr; gap: 0;
+    border-top: var(--hairline);
+  }
+  .voice-cell { padding: 36px 0; border-bottom: var(--hairline); }
+  .voice-cell:nth-child(2n-1) { border-right: var(--hairline); padding-right: 36px; }
+  .voice-cell:nth-child(2n) { padding-left: 36px; }
+  .voice-cell .v-eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 16px; }
+  .voice-cell .v-quote {
+    font-family: var(--serif); font-weight: 300; font-style: italic;
+    font-size: 22px; line-height: 1.4; color: var(--ink-soft); margin: 0 0 12px;
+  }
+  .voice-cell .v-quote::before { content: '"'; color: var(--metal); margin-right: 4px; }
+  .voice-cell .v-quote::after  { content: '"'; color: var(--metal); margin-left: 4px; }
+  .voice-cell .v-rule {
+    font-family: var(--sans); font-size: 12px; line-height: 1.6; color: var(--ink-mute);
+    font-weight: 300;
+  }
+  .voice-cell .v-rule strong { color: var(--ink); font-weight: 500; }
+  .voice-cell.do  .v-eyebrow::before { content: '✓ '; color: var(--metal); }
+  .voice-cell.dont .v-eyebrow::before { content: '✗ '; color: var(--metal-deep); }
+  @media (max-width: 720px) {
+    .voice-grid { grid-template-columns: 1fr; }
+    .voice-cell, .voice-cell:nth-child(2n-1), .voice-cell:nth-child(2n) {
+      padding: 28px 0; border-right: 0; border-bottom: var(--hairline);
+    }
+    .voice-cell:last-child { border-bottom: 0; }
+  }
+
+  /* ─── Closer ────────────────────────────────────────────────────── */
+  .closer {
+    margin: 120px 0 0; padding: 80px 0;
+    border-top: var(--hairline); text-align: center;
+  }
+  .closer .glyph {
+    font-family: var(--serif); font-style: italic; font-size: 56px;
+    color: var(--metal); opacity: 0.7; display: block; margin: 0 auto 28px;
+  }
+  .closer .quote {
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: clamp(22px, 2.4vw, 30px); line-height: 1.4;
+    color: var(--ink); margin: 0 auto 20px; max-width: 60ch;
+  }
+  .closer .credit {
+    font-family: var(--sans); font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+
+  /* ─── Footer ────────────────────────────────────────────────────── */
+  footer {
+    padding: 56px 48px 48px;
+    border-top: var(--hairline);
+    background: var(--noir);
+    font-size: 12px; color: var(--ink-mute);
+    text-align: center;
+  }
+  footer a { color: var(--ink-mute); border: 0; }
+  footer a:hover { color: var(--metal); }
+  footer .legal { font-size: 11px; opacity: 0.65; max-width: 600px; margin: 0 auto 18px; line-height: 1.6; }
+  @media (max-width: 720px) { footer { padding: 40px 20px 32px; } }
+
+  /* ─── Reveals ───────────────────────────────────────────────────── */
+  @media (prefers-reduced-motion: no-preference) {
+    .reveal { opacity: 0; transform: translateY(18px); transition: opacity 800ms cubic-bezier(.16,.84,.3,1), transform 800ms cubic-bezier(.16,.84,.3,1); }
+    .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; }
+</style>
+</head>
+<body>
+
+<header class="topbar">
+  <a class="brand-row" href="/">
+    <svg width="28" height="28" 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" fill="none" stroke="url(#mg-stroke)" stroke-width="1"/>
+      <path fill="url(#mg-stroke)" 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>
+    <a href="/find-a-lawyer">Find counsel</a>
+    <a href="/methodology.html">Methodology</a>
+    <a href="/audit.html">Audit</a>
+  </nav>
+</header>
+
+<main class="wrap">
+
+  <p class="folio">
+    <span class="f-rule"></span>
+    <span>The visual standard</span>
+    <span class="f-meta">Volume I · Edition 2026 · v1.0</span>
+  </p>
+
+  <h1 class="display reveal in-view">The look of a <em>tier-A</em> directory listing.</h1>
+
+  <p class="lede reveal in-view">
+    A field guide to the typography, palette, ornament, and voice that the directory
+    holds itself to — and against which every audited firm site is measured.
+  </p>
+
+  <!-- ─── Palette ─────────────────────────────────────────────────── -->
+  <section>
+    <p class="section-eyebrow">Palette</p>
+    <h2 class="section-title">A restrained <em>five.</em></h2>
+    <p class="section-sub">No blue. No tech-stack accent. The page is a noir field with one metallic.</p>
+
+    <div class="palette">
+      <div class="swatch sw-noir">
+        <span class="s-name">Noir</span>
+        <span class="s-hex">#0a0a0c</span>
+        <span class="s-role">Page · Field</span>
+      </div>
+      <div class="swatch sw-noir-rise">
+        <span class="s-name">Noir Rise</span>
+        <span class="s-hex">#131316</span>
+        <span class="s-role">Card · Inset</span>
+      </div>
+      <div class="swatch sw-rule">
+        <span class="s-name">Rule</span>
+        <span class="s-hex">#2a2724</span>
+        <span class="s-role">Hairline · Divider</span>
+      </div>
+      <div class="swatch sw-ink">
+        <span class="s-name">Ink</span>
+        <span class="s-hex">#f4f1ea</span>
+        <span class="s-role">Type · Display</span>
+      </div>
+      <div class="swatch sw-metal">
+        <span class="s-name">Metal</span>
+        <span class="s-hex">#b89968</span>
+        <span class="s-role">Accent · Italic</span>
+      </div>
+    </div>
+  </section>
+
+  <!-- ─── Typography ──────────────────────────────────────────────── -->
+  <section>
+    <p class="section-eyebrow">Typography</p>
+    <h2 class="section-title">Two faces. <em>One job each.</em></h2>
+    <p class="section-sub">A serif for voice, a sans for instrumentation. Never reverse the two.</p>
+
+    <div class="type-grid">
+      <div class="type-cell">
+        <p class="t-eyebrow">Display · Editorial</p>
+        <p class="specimen-serif">Counsel <em>&amp;</em> Bar</p>
+        <p class="t-meta">Cormorant Garamond <span>·</span> 300 / 400 italic</p>
+      </div>
+      <div class="type-cell">
+        <p class="t-eyebrow">Body · Instrumentation</p>
+        <p class="specimen-sans">An indexed directory of every California-licensed attorney, drawn from the State Bar public roll. <strong>Browse, verify, contact directly.</strong></p>
+        <p class="t-meta">Inter <span>·</span> 300 / 500 weights</p>
+      </div>
+    </div>
+
+    <div style="margin-top: 56px;">
+      <p class="t-eyebrow" style="font-family:var(--sans);font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px;">The scale</p>
+      <div>
+        <div class="scale">
+          <span class="s-num">D · 01</span>
+          <span class="s-text s-d1">Every <em>licensed</em> attorney.</span>
+          <span class="s-meta">64–96 px · 0.95 lh</span>
+        </div>
+        <div class="scale">
+          <span class="s-num">D · 02</span>
+          <span class="s-text s-d2">A directory worth its name.</span>
+          <span class="s-meta">32–48 px · 1.10 lh</span>
+        </div>
+        <div class="scale">
+          <span class="s-num">D · 03</span>
+          <span class="s-text s-d3">Verified, not listed.</span>
+          <span class="s-meta">24 px · 1.20 lh</span>
+        </div>
+        <div class="scale">
+          <span class="s-num">B · body</span>
+          <span class="s-text s-body">Filter by ZIP, practice area, firm size. Contact the attorney through their listed channels.</span>
+          <span class="s-meta">15–17 px · 1.55 lh</span>
+        </div>
+        <div class="scale">
+          <span class="s-num">E · eyebrow</span>
+          <span class="s-text s-eyebrow">Volume I · Edition 2026 · v1.0</span>
+          <span class="s-meta">10 px · 0.30 ls</span>
+        </div>
+      </div>
+    </div>
+  </section>
+
+  <!-- ─── Ornament ────────────────────────────────────────────────── -->
+  <section>
+    <p class="section-eyebrow">Ornament</p>
+    <h2 class="section-title">Three engravings, <em>used sparingly.</em></h2>
+    <p class="section-sub">Architectural restraint. No flourish for its own sake.</p>
+
+    <div class="ornament-gallery">
+      <div class="orn-cell">
+        <svg viewBox="0 0 240 90" width="240" height="90" aria-hidden="true">
+          <defs>
+            <linearGradient id="og1" 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>
+          <g fill="none" stroke="url(#og1)" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round">
+            <rect x="40" y="10" width="160" height="5"/>
+            <line x1="42" y1="17" x2="198" y2="17"/>
+            <path d="M50 22 Q 62 18 80 22 Q 98 26 116 22 Q 134 18 152 22 Q 170 26 188 22"/>
+            <path d="M50 22 Q 38 30 42 42 Q 46 50 56 47 Q 64 44 64 38 Q 64 33 60 33 Q 56 33 56 38"/>
+            <path d="M188 22 Q 200 30 196 42 Q 192 50 182 47 Q 174 44 174 38 Q 174 33 178 33 Q 182 33 182 38"/>
+            <line x1="64" y1="52" x2="174" y2="52"/>
+            <line x1="64" y1="56" x2="174" y2="56"/>
+            <line x1="80" y1="62" x2="80" y2="84"/>
+            <line x1="100" y1="62" x2="100" y2="84"/>
+            <line x1="120" y1="62" x2="120" y2="84"/>
+            <line x1="140" y1="62" x2="140" y2="84"/>
+            <line x1="160" y1="62" x2="160" y2="84"/>
+            <path d="M115 30 L 120 25 L 125 30 L 120 35 Z" fill="url(#og1)" stroke="none" opacity="0.55"/>
+          </g>
+        </svg>
+        <span class="orn-name">Capital</span>
+        <span class="orn-meta">Section opener · 320 × 110</span>
+      </div>
+      <div class="orn-cell">
+        <svg viewBox="0 0 240 36" width="240" height="36" aria-hidden="true">
+          <defs>
+            <linearGradient id="og2" x1="0" y1="0" x2="1" y2="0">
+              <stop offset="0%" stop-color="rgba(184,153,104,0)"/>
+              <stop offset="20%" stop-color="rgba(184,153,104,0.85)"/>
+              <stop offset="50%" stop-color="rgba(212,182,131,1)"/>
+              <stop offset="80%" stop-color="rgba(184,153,104,0.85)"/>
+              <stop offset="100%" stop-color="rgba(184,153,104,0)"/>
+            </linearGradient>
+            <pattern id="og2-pat" x="0" y="0" width="48" height="36" patternUnits="userSpaceOnUse">
+              <path d="M0 18 Q 12 4 24 18 T 48 18" fill="none" stroke="url(#og2)" stroke-width="0.6"/>
+              <path d="M0 18 Q 12 32 24 18 T 48 18" fill="none" stroke="url(#og2)" stroke-width="0.6"/>
+              <circle cx="12" cy="11" r="0.7" fill="url(#og2)" opacity="0.6"/>
+              <circle cx="36" cy="25" r="0.7" fill="url(#og2)" opacity="0.6"/>
+            </pattern>
+          </defs>
+          <rect x="0" y="0" width="240" height="36" fill="url(#og2-pat)"/>
+        </svg>
+        <span class="orn-name">Frieze</span>
+        <span class="orn-meta">Section break · banking-engraving</span>
+      </div>
+      <div class="orn-cell">
+        <span style="font-family:var(--serif);font-style:italic;font-weight:300;font-size:96px;line-height:1;color:var(--metal);opacity:0.78;">&amp;</span>
+        <span class="orn-name">Ampersand</span>
+        <span class="orn-meta">Closer glyph · italic 84 px</span>
+      </div>
+    </div>
+  </section>
+
+  <!-- ─── Voice ───────────────────────────────────────────────────── -->
+  <section>
+    <p class="section-eyebrow">Voice</p>
+    <h2 class="section-title">Speak as the <em>indexer,</em> not the marketer.</h2>
+    <p class="section-sub">Editorial, exacting, third-person. We are a directory, not a service desk.</p>
+
+    <div class="voice-grid">
+      <div class="voice-cell do">
+        <p class="v-eyebrow">Do</p>
+        <p class="v-quote">Every licensed attorney in California, indexed.</p>
+        <p class="v-rule"><strong>Why:</strong> states the scope and the verb. Indexed = drawn from a public roll, not curated for advertising.</p>
+      </div>
+      <div class="voice-cell dont">
+        <p class="v-eyebrow">Don't</p>
+        <p class="v-quote">Find your perfect lawyer fast — get matched in 60 seconds!</p>
+        <p class="v-rule"><strong>Why:</strong> "matched" implies referral; "perfect lawyer" is unsubstantiated; the urgency is theatrical. All three fail Rule 7.1 and §6155.</p>
+      </div>
+      <div class="voice-cell do">
+        <p class="v-eyebrow">Do</p>
+        <p class="v-quote">Each firm publishes its own consultation fee, schedule, and booking flow.</p>
+        <p class="v-rule"><strong>Why:</strong> attributes terms to the firm, not the directory. Money and engagement letters never touch us.</p>
+      </div>
+      <div class="voice-cell dont">
+        <p class="v-eyebrow">Don't</p>
+        <p class="v-quote">We'll find you a top-rated attorney near you — no fee.</p>
+        <p class="v-rule"><strong>Why:</strong> "we'll find" is referral language; "top-rated" is unsubstantiated; "no fee" implies a paid alternative we're undercutting.</p>
+      </div>
+    </div>
+  </section>
+
+  <!-- ─── Closer ──────────────────────────────────────────────────── -->
+  <div class="closer">
+    <p class="quote">A directory is a piece of typography before it is a piece of software.</p>
+  </div>
+
+</main>
+
+<footer>
+  <p class="legal">
+    Counsel &amp; Bar is an independent directory of California-licensed attorneys, not a lawyer referral service under California Business &amp; Professions Code §6155.
+    Visual standard v1.0 — last reviewed 2026-05-04.
+  </p>
+  <p>
+    <a href="/">Home</a> ·
+    <a href="/find-a-lawyer">Find counsel</a> ·
+    <a href="/methodology.html">Methodology</a> ·
+    <a href="/audit.html">Audit</a> ·
+    <a href="/privacy.html">Privacy</a> ·
+    <a href="/terms.html">Terms</a>
+  </p>
+</footer>
+
+<script>
+  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 });
+    document.querySelectorAll('.reveal:not(.in-view)').forEach(el => io.observe(el));
+  }
+</script>
+
+</body>
+</html>
diff --git a/public/terms.html b/public/terms.html
index 9fa208b..1ae9650 100644
--- a/public/terms.html
+++ b/public/terms.html
@@ -2,6 +2,10 @@
 <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="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
+<link rel="apple-touch-icon" href="/favicon-32.png">
+<meta name="theme-color" content="#0a0a0c">
 <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">
@@ -139,5 +143,15 @@ Counsel &amp; Bar is a directory and software platform. <strong>We are not a law
 <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>
+<footer style="padding:32px 28px;border-top:1px solid var(--rule);background:var(--noir);text-align:center;font-size:11px;color:var(--ink-mute);line-height:1.7">
+  <div style="margin-bottom:10px">Counsel &amp; Bar · A directory and software platform · Not a law firm · Not a lawyer referral service · Not legal advice</div>
+  <div style="font-size:11px">
+    <a href="/" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Home</a> ·
+    <a href="/find-a-lawyer" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Find counsel</a> ·
+    <a href="/methodology.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Methodology</a> ·
+    <a href="/audit.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Audit</a> ·
+    <a href="/style.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Visual standard</a> ·
+    <a href="/privacy.html" style="color:var(--ink-mute);border-bottom:1px solid var(--rule);text-decoration:none;padding-bottom:1px">Privacy</a>
+  </div>
+</footer>
 </body></html>
diff --git a/scripts/build-agent/nightly-email.sh b/scripts/build-agent/nightly-email.sh
index 2887db4..c571001 100755
--- a/scripts/build-agent/nightly-email.sh
+++ b/scripts/build-agent/nightly-email.sh
@@ -58,8 +58,10 @@ EOF
 # Subject (RFC 2047 encoded)
 SUBJECT="=?UTF-8?B?$(printf '%s' "Lawyer Directory · $LAST24 mockups today · $COMPLETE firms complete" | base64)?="
 
-# Send via George
-RESP=$(curl -s -u "admin:DWSecure2024!" \
+# Send via George — credentials from env (audit 2026-05-04: removed inline literal)
+: "${GEORGE_AUTH_USERNAME:=admin}"
+: "${GEORGE_AUTH_PASSWORD:?GEORGE_AUTH_PASSWORD env var required}"
+RESP=$(curl -s -u "${GEORGE_AUTH_USERNAME}:${GEORGE_AUTH_PASSWORD}" \
   -X POST "http://localhost:9850/api/send?account=info" \
   -H "Content-Type: application/json" \
   -d "$(jq -n --arg t "steve@designerwallcoverings.com" --arg s "$SUBJECT" --arg b "$BODY" \
diff --git a/scripts/build-agent/watchdog.sh b/scripts/build-agent/watchdog.sh
index f057310..4bf04a8 100755
--- a/scripts/build-agent/watchdog.sh
+++ b/scripts/build-agent/watchdog.sh
@@ -39,7 +39,7 @@ start_mac1_worker() {
      nohup env PATH=\"\$HOME/local/bin:\$PATH\" \
        DATABASE_URL='postgres://stevestudio2@127.0.0.1:15432/lawyer_professional_directory' \
        OLLAMA_URL='http://localhost:11434' \
-       LLM_MOCKUP_MODEL=gemma3:12b \
+       LLM_MOCKUP_MODEL=qwen3:14b \
        LLM_TIMEOUT_MS=360000 \
        SITE_INTEL_TIMEOUT_MS=12000 \
        \"\$HOME/local/bin/node\" \"\$HOME/Projects/lawyer-directory-builder/node_modules/.bin/tsx\" \
diff --git a/scripts/build-pitch-html.py b/scripts/build-pitch-html.py
new file mode 100644
index 0000000..7f83013
--- /dev/null
+++ b/scripts/build-pitch-html.py
@@ -0,0 +1,287 @@
+#!/usr/bin/env python3
+"""Build admin-gated, §6155-compliant pitch HTML from lawyer ad-signals CSV.
+Pure data transform — no LLM. Re-runnable: re-pulls fresh CSV from DB.
+"""
+import csv, html, json, sys, os, subprocess
+from datetime import datetime
+from collections import Counter
+
+ROOT = os.path.expanduser('~/Projects/lawyer-directory-builder')
+CSV_PATH = f'{ROOT}/exports/lawyer-paid-ads-targets-2026-05-05.csv'
+OUT_PATH = f'{ROOT}/exports/lawyer-sales-pitch-2026-05-05.html'
+
+# Refresh CSV from DB (so overnight jobs pick up new firms as they crawl in)
+REFRESH_SQL = """
+SELECT
+  o.id AS firm_id,
+  o.name,
+  COALESCE(o.city, '') AS city,
+  COALESCE(o.state, '') AS state,
+  COALESCE(o.website, '') AS website,
+  COALESCE((fas.ad_signals->>'paid_ads_count')::int, 0) AS paid_pixels,
+  (fas.ad_signals->>'google_ads')::boolean AS google_ads,
+  (fas.ad_signals->>'meta_pixel')::boolean AS meta,
+  (fas.ad_signals->>'tiktok_pixel')::boolean AS tiktok,
+  (fas.ad_signals->>'linkedin_insight')::boolean AS linkedin,
+  (fas.ad_signals->>'bing_uet')::boolean AS bing,
+  (fas.ad_signals->>'twitter_pixel')::boolean AS twitter,
+  CASE
+    WHEN (fas.ad_signals->>'paid_ads_count')::int >= 3 THEN 'TIER1_HEAVY'
+    WHEN (fas.ad_signals->>'paid_ads_count')::int = 2 THEN 'TIER2_MODERATE'
+    ELSE 'TIER3_SINGLE'
+  END AS tier
+FROM organizations o
+JOIN firm_ad_signals fas ON fas.firm_id = o.id
+WHERE (fas.ad_signals->>'paid_ads_count')::int > 0
+ORDER BY (fas.ad_signals->>'paid_ads_count')::int DESC, o.name
+"""
+
+if '--refresh-csv' in sys.argv or not os.path.exists(CSV_PATH):
+    with open(CSV_PATH, 'w') as f:
+        subprocess.run(
+            ['psql', '-d', 'lawyer_professional_directory', '-A', '-F,', '--csv', '-c', REFRESH_SQL],
+            stdout=f, check=True
+        )
+
+with open(CSV_PATH) as f:
+    rows = list(csv.DictReader(f))
+
+tier_counts = Counter(r['tier'] for r in rows)
+city_counts = Counter(r['city'] for r in rows if r['city']).most_common(5)
+plat_counts = {p: sum(1 for r in rows if r[p] == 't')
+               for p in ['google_ads','meta','tiktok','linkedin','bing','twitter']}
+total = len(rows)
+
+PLATFORM_LABELS = {
+    'google_ads': ('G', 'Google Ads', '#4285f4'),
+    'meta': ('M', 'Meta', '#1877f2'),
+    'tiktok': ('TT', 'TikTok', '#ff0050'),
+    'linkedin': ('LI', 'LinkedIn', '#0a66c2'),
+    'bing': ('B', 'Bing', '#008373'),
+    'twitter': ('TW', 'Twitter/X', '#1da1f2'),
+}
+
+def chips(r):
+    out = []
+    for p, (lbl, full, col) in PLATFORM_LABELS.items():
+        if r[p] == 't':
+            out.append(f'<span class="chip" style="background:{col}" title="{full}">{lbl}</span>')
+    return ''.join(out)
+
+def plats_for_pitch(r):
+    return [PLATFORM_LABELS[p][1] for p in PLATFORM_LABELS if r[p] == 't']
+
+data = []
+for r in rows:
+    data.append({
+        'firm_id': r['firm_id'],
+        'name': r['name'],
+        'city': r['city'],
+        'state': r['state'],
+        'website': r['website'],
+        'paid_pixels': int(r['paid_pixels']),
+        'tier': r['tier'],
+        'platforms': plats_for_pitch(r),
+        'platforms_html': chips(r),
+    })
+
+generated = datetime.now().strftime('%Y-%m-%d %H:%M PT')
+
+HTML_OUT = f"""<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>CA Law Firms — Paid-Ad Pixel Detection — INTERNAL</title>
+<script>
+(function(){{const t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t);}})();
+</script>
+<style>
+:root[data-theme="dark"]{{--bg:#0a0a0a;--fg:#e8e8e8;--muted:#888;--accent:#f5b800;--card:#161616;--border:#2a2a2a;--banner:#3a1a00;--banner-fg:#ffaa44;}}
+:root[data-theme="light"]{{--bg:#fafafa;--fg:#1a1a1a;--muted:#666;--accent:#b08000;--card:#fff;--border:#ddd;--banner:#fff4d6;--banner-fg:#7a4f00;}}
+*{{box-sizing:border-box}}
+body{{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--fg);font-size:13px;line-height:1.4}}
+.banner{{background:var(--banner);color:var(--banner-fg);padding:10px 20px;font-weight:600;font-size:13px;border-bottom:2px solid var(--banner-fg);display:flex;justify-content:space-between;align-items:center}}
+.banner .lock{{font-size:18px}}
+.hd{{padding:18px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:flex-start;gap:20px;flex-wrap:wrap}}
+h1{{margin:0 0 4px;font-size:20px}}
+.sub{{color:var(--muted);font-size:12px}}
+.theme-tog{{background:none;border:1px solid var(--border);color:var(--fg);padding:6px 12px;border-radius:4px;cursor:pointer;font-size:14px}}
+.theme-tog:hover{{border-color:var(--accent)}}
+.stats{{padding:14px 20px;display:flex;gap:24px;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--card)}}
+.stat{{display:flex;flex-direction:column}}
+.stat .v{{font-size:22px;font-weight:600;color:var(--accent)}}
+.stat .l{{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px}}
+.controls{{padding:12px 20px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border)}}
+.controls input[type=text]{{flex:1;min-width:200px;padding:8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font-size:13px}}
+.controls label{{font-size:12px;cursor:pointer;display:flex;align-items:center;gap:4px}}
+.main{{display:grid;grid-template-columns:1fr;gap:0}}
+.main.with-panel{{grid-template-columns:1fr 380px}}
+table{{width:100%;border-collapse:collapse;font-size:12px}}
+thead th{{text-align:left;padding:8px 10px;background:var(--card);border-bottom:1px solid var(--border);cursor:pointer;user-select:none;position:sticky;top:0}}
+thead th:hover{{color:var(--accent)}}
+tbody td{{padding:8px 10px;border-bottom:1px solid var(--border)}}
+tbody tr:hover{{background:var(--card)}}
+.tier-TIER1_HEAVY{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#5a1a1a;color:#ffaaaa}}
+.tier-TIER2_MODERATE{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#5a3d00;color:#ffd180}}
+.tier-TIER3_SINGLE{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#333;color:#aaa}}
+:root[data-theme="light"] .tier-TIER1_HEAVY{{background:#ffd6d6;color:#8b0000}}
+:root[data-theme="light"] .tier-TIER2_MODERATE{{background:#fff0c2;color:#7a4f00}}
+:root[data-theme="light"] .tier-TIER3_SINGLE{{background:#eee;color:#444}}
+.chip{{display:inline-block;padding:2px 5px;border-radius:3px;font-size:10px;font-weight:600;color:#fff;margin-right:3px}}
+.btn-pitch{{background:var(--accent);color:#000;border:none;padding:5px 10px;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600}}
+.btn-pitch:hover{{opacity:0.85}}
+a{{color:var(--accent);text-decoration:none}}
+a:hover{{text-decoration:underline}}
+.panel{{background:var(--card);border-left:1px solid var(--border);padding:18px;height:calc(100vh - 200px);overflow-y:auto}}
+.panel h2{{margin:0 0 8px;font-size:16px}}
+.panel .firm-meta{{font-size:11px;color:var(--muted);margin-bottom:12px}}
+.panel .pitch-script{{background:var(--bg);padding:12px;border-radius:4px;border:1px solid var(--border);font-size:12px;line-height:1.5;white-space:pre-wrap}}
+.panel .close{{float:right;background:none;border:none;color:var(--muted);cursor:pointer;font-size:18px}}
+.compliance{{padding:12px 20px;background:var(--card);border-top:1px solid var(--border);font-size:11px;color:var(--muted);line-height:1.6}}
+.compliance strong{{color:var(--fg)}}
+</style>
+</head>
+<body>
+<div class="banner">
+  <span><span class="lock">🔒</span> INTERNAL — Sales Targeting Data — Not For Public Distribution — §6155 Compliance Sensitive</span>
+  <span style="font-size:11px;opacity:0.8">Admin-only · Generated {generated}</span>
+</div>
+<div class="hd">
+  <div>
+    <h1>California Law Firms — Paid-Ad Pixel Detection</h1>
+    <div class="sub">{total} firms with at least one tracking pixel detected · directory enrichment data</div>
+  </div>
+  <button class="theme-tog" onclick="toggleTheme()" id="themeBtn">☀ light</button>
+</div>
+<div class="stats">
+  <div class="stat"><div class="v">{total}</div><div class="l">Total firms</div></div>
+  <div class="stat"><div class="v" style="color:#ff7070">{tier_counts.get('TIER1_HEAVY', 0)}</div><div class="l">TIER1 — 3+ platforms</div></div>
+  <div class="stat"><div class="v" style="color:#ffaa44">{tier_counts.get('TIER2_MODERATE', 0)}</div><div class="l">TIER2 — 2 platforms</div></div>
+  <div class="stat"><div class="v">{tier_counts.get('TIER3_SINGLE', 0)}</div><div class="l">TIER3 — 1 platform</div></div>
+  <div class="stat"><div class="v">{plat_counts['google_ads']}</div><div class="l">Google Ads</div></div>
+  <div class="stat"><div class="v">{plat_counts['meta']}</div><div class="l">Meta</div></div>
+  <div class="stat"><div class="v">{plat_counts['linkedin']}</div><div class="l">LinkedIn</div></div>
+</div>
+<div class="controls">
+  <input type="text" id="filter" placeholder="Filter by name or city…" oninput="render()">
+  <label><input type="checkbox" data-tier="TIER1_HEAVY" checked onchange="render()"> TIER1</label>
+  <label><input type="checkbox" data-tier="TIER2_MODERATE" checked onchange="render()"> TIER2</label>
+  <label><input type="checkbox" data-tier="TIER3_SINGLE" checked onchange="render()"> TIER3</label>
+  <span id="visible-count" style="margin-left:auto;color:var(--muted);font-size:11px"></span>
+</div>
+<div class="main" id="main">
+  <div style="overflow-x:auto">
+    <table>
+      <thead><tr>
+        <th onclick="sortBy('name')">Firm</th>
+        <th onclick="sortBy('city')">City</th>
+        <th onclick="sortBy('tier')">Tier</th>
+        <th onclick="sortBy('paid_pixels')">Pixels</th>
+        <th>Platforms</th>
+        <th>Website</th>
+        <th></th>
+      </tr></thead>
+      <tbody id="tbody"></tbody>
+    </table>
+  </div>
+  <div class="panel" id="panel" style="display:none"></div>
+</div>
+<div class="compliance">
+  <strong>Compliance:</strong> Source — tracking-pixel detection on public firm websites, ad-signals enrichment Pass 1, generated {generated}.
+  Data is for internal directory-enrichment use only and is not a public attribution of advertising activity. Not affiliated with the State Bar of California.
+  This page does not contain spend figures, ad budgets, or paid-claim data — only the presence or absence of tracking pixels on a publicly-accessible homepage.
+  Pixel detection is heuristic; "TIER1_HEAVY" indicates 3+ tracking platforms detected, not a verified spend level.
+</div>
+<script>
+const DATA = {json.dumps(data)};
+let sortKey = 'paid_pixels';
+let sortAsc = false;
+function toggleTheme(){{
+  const cur = document.documentElement.getAttribute('data-theme');
+  const next = cur === 'dark' ? 'light' : 'dark';
+  document.documentElement.setAttribute('data-theme', next);
+  localStorage.setItem('theme', next);
+  document.getElementById('themeBtn').textContent = next === 'dark' ? '☀ light' : '☾ dark';
+}}
+document.getElementById('themeBtn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀ light' : '☾ dark';
+function sortBy(k){{
+  if (sortKey === k) sortAsc = !sortAsc;
+  else {{ sortKey = k; sortAsc = false; }}
+  render();
+}}
+function escapeHtml(s){{ return String(s).replace(/[&<>\"']/g, c => ({{'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}})[c]); }}
+function render(){{
+  const q = document.getElementById('filter').value.toLowerCase();
+  const tiers = new Set([...document.querySelectorAll('input[data-tier]:checked')].map(i => i.dataset.tier));
+  const filtered = DATA.filter(r =>
+    tiers.has(r.tier) &&
+    (!q || r.name.toLowerCase().includes(q) || r.city.toLowerCase().includes(q))
+  );
+  filtered.sort((a, b) => {{
+    const va = a[sortKey], vb = b[sortKey];
+    let r = (typeof va === 'number') ? (va - vb) : String(va).localeCompare(String(vb));
+    return sortAsc ? r : -r;
+  }});
+  const tbody = document.getElementById('tbody');
+  tbody.innerHTML = filtered.map(r => `
+    <tr>
+      <td><strong>${{escapeHtml(r.name)}}</strong></td>
+      <td>${{escapeHtml(r.city)}}, ${{escapeHtml(r.state)}}</td>
+      <td><span class="tier-${{r.tier}}">${{r.tier.replace(/_/g,' ')}}</span></td>
+      <td>${{r.paid_pixels}}</td>
+      <td>${{r.platforms_html}}</td>
+      <td>${{r.website ? `<a href="${{escapeHtml(r.website)}}" target="_blank" rel="noreferrer">${{escapeHtml(r.website.replace(/^https?:\\/\\//,'').replace(/\\/$/,'').slice(0,40))}}</a>` : ''}}</td>
+      <td><button class="btn-pitch" onclick='openPitch(${{r.firm_id}})'>📋 Pitch</button></td>
+    </tr>`).join('');
+  document.getElementById('visible-count').textContent = `${{filtered.length}} of ${{DATA.length}} firms shown`;
+}}
+function openPitch(firmId){{
+  const r = DATA.find(x => x.firm_id == firmId);
+  if (!r) return;
+  const main = document.getElementById('main');
+  const panel = document.getElementById('panel');
+  main.classList.add('with-panel');
+  panel.style.display = 'block';
+  const platList = r.platforms.length ? r.platforms.join(', ') : '(no platforms detected)';
+  const intro = r.platforms.length > 1
+    ? `we noticed your firm is running tracking pixels for ${{r.platforms.length}} platforms — including ${{r.platforms[0]}} and ${{r.platforms[1]}}`
+    : `we noticed your firm has a tracking pixel detected for ${{r.platforms[0] || 'one platform'}}`;
+  panel.innerHTML = `
+    <button class="close" onclick="closePitch()">×</button>
+    <h2>${{escapeHtml(r.name)}}</h2>
+    <div class="firm-meta">${{escapeHtml(r.city)}}, ${{escapeHtml(r.state)}} · <span class="tier-${{r.tier}}">${{r.tier.replace(/_/g,' ')}}</span> · ${{r.paid_pixels}} pixels</div>
+    <div style="margin-bottom:8px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px">Platforms detected</div>
+    <div style="margin-bottom:14px">${{r.platforms_html}}</div>
+    <div style="margin-bottom:8px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px">Outreach script</div>
+    <div class="pitch-script">Hi — I'm reaching out from a CA-firm directory project that maps law-firm digital presence across the state.
+
+${{intro.charAt(0).toUpperCase() + intro.slice(1)}} on your firm's website (${{escapeHtml((r.website || '').replace(/^https?:\\/\\//,''))}}).
+
+We're talking with firms that are already investing in their digital channels to compare notes on what's working — what kind of conversion they're seeing on intake forms, click-to-call response, and channel mix across ${{platList}}.
+
+Would you have 10 minutes this week or next? Happy to share what we're seeing across the cohort.
+
+— [Steve / your name]
+
+[INTERNAL CALL CRIB]
+- Active platforms: ${{platList}}
+- Tier: ${{r.tier.replace(/_/g,' ')}}
+- Website: ${{escapeHtml(r.website || 'N/A')}}
+- Do NOT make spend, budget, or dollar-amount claims.
+- Frame as peer-cohort conversation, not pitch-down.</div>
+  `;
+}}
+function closePitch(){{
+  document.getElementById('main').classList.remove('with-panel');
+  document.getElementById('panel').style.display = 'none';
+}}
+render();
+</script>
+</body>
+</html>
+"""
+
+with open(OUT_PATH, 'w') as f:
+    f.write(HTML_OUT)
+
+print(f'Wrote {OUT_PATH} · {len(HTML_OUT):,} bytes · {total} firms')
diff --git a/src/db/pool.ts b/src/db/pool.ts
index a1ce127..ff14227 100644
--- a/src/db/pool.ts
+++ b/src/db/pool.ts
@@ -1,30 +1,17 @@
-import 'dotenv/config';
-import pg from 'pg';
+// Thin re-export from directory-core/db.
+//
+// Migrated 2026-05-04 (tick 21 of overnight YOLO loop) — 30 LOC reduced to a
+// single re-export. All 15+ consumers continue to import from this file via
+// their existing relative paths (`../db/pool.ts`, `../../db/pool.ts`); they
+// don't need to change.
+//
+// The original implementation was the source-of-truth that became
+// directory-core/db.ts in Phase 1. Re-exporting closes the loop: lawyer-directory
+// is now a normal consumer of its own donated module, and any future fix to
+// pool/query/withTx lands once in directory-core and benefits all 4 verticals.
+//
+// DATABASE_URL is set in lawyer-directory's .env; directory-core/db reads it
+// via its `import 'dotenv/config'` (which only loads from the *consumer*'s
+// CWD, not directory-core's own dir).
 
-const { Pool } = pg;
-
-const url = process.env.DATABASE_URL;
-if (!url) {
-  throw new Error('DATABASE_URL is required (see .env)');
-}
-
-export const pool = new Pool({ connectionString: url, max: 10 });
-
-export async function query<T = unknown>(text: string, params: unknown[] = []) {
-  return pool.query<T>(text, params as never[]);
-}
-
-export async function withTx<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
-  const client = await pool.connect();
-  try {
-    await client.query('BEGIN');
-    const out = await fn(client);
-    await client.query('COMMIT');
-    return out;
-  } catch (e) {
-    await client.query('ROLLBACK');
-    throw e;
-  } finally {
-    client.release();
-  }
-}
+export { pool, query, withTx, closePool } from 'directory-core/db';
diff --git a/src/enrich/ad_signals.ts b/src/enrich/ad_signals.ts
new file mode 100644
index 0000000..b34fe11
--- /dev/null
+++ b/src/enrich/ad_signals.ts
@@ -0,0 +1,150 @@
+/**
+ * Ad / social-tracking signal detector — lawyer-directory port.
+ *
+ * Mines the persisted raw HTML files (raw_html/firm-NNN-TS.html, captured by
+ * site_audit.ts) for known tracking-pixel / tag fingerprints. Result is a JSONB
+ * blob stored in firm_ad_signals — one binary "this firm is paying to advertise
+ * on platform X" signal per firm.
+ *
+ * No HTTP calls. No LLM. Pure regex + UPSERT.
+ *
+ * INTERNAL SALES-TARGETING ONLY.
+ *   - Output is admin-gated; never surface in public profile pages, JSON-LD,
+ *     SEO copy, or any directory listing.
+ *   - Compliance: Cal §6155 (no LRS framing), Rule 7.x (no fabricated stats
+ *     attached to real firm names). These regex hits are crude binary signals
+ *     for outreach prioritization, not facts about the firm's spend.
+ *
+ * Detected signals (regexes verbatim from ventura-corridor reference impl):
+ *   google_ads, google_analytics, meta_pixel, tiktok_pixel, pinterest_tag,
+ *   linkedin_insight, twitter_pixel, reddit_pixel, bing_uet, snap_pixel,
+ *   gtm_container (precision caveat — pixels may load indirectly via GTM)
+ */
+import 'dotenv/config';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { query, pool } from '../db/pool.ts';
+
+interface AuditRow {
+  firm_id: number;
+  raw_html_path: string;
+}
+
+interface Signals {
+  google_ads: boolean;
+  google_analytics: boolean;
+  meta_pixel: boolean;
+  tiktok_pixel: boolean;
+  pinterest_tag: boolean;
+  linkedin_insight: boolean;
+  twitter_pixel: boolean;
+  reddit_pixel: boolean;
+  bing_uet: boolean;
+  snap_pixel: boolean;
+  gtm_container: boolean;
+  paid_ads_count: number;
+}
+
+function detect(html: string): Signals {
+  const s: Signals = {
+    google_ads: /googleadservices\.com|gtag\([^)]*['"]AW-|google_conversion_id|googletagmanager\.com\/gtag\/js\?id=AW-/i.test(html),
+    google_analytics: /gtag\([^)]*['"]G-|googletagmanager\.com\/gtm\.js|google-analytics\.com\/analytics\.js|googletagmanager\.com\/gtag\/js\?id=G-|gtag\([^)]*['"]UA-/i.test(html),
+    meta_pixel: /connect\.facebook\.net\/[^"']*\/fbevents\.js|fbq\(['"]init['"]|fbq\(['"]track['"]|facebook\.com\/tr\?id=/i.test(html),
+    tiktok_pixel: /analytics\.tiktok\.com|business-api\.tiktok\.com|ttq\.load|TiktokAnalyticsObject/i.test(html),
+    pinterest_tag: /s\.pinimg\.com\/ct\.js|pintrk\(['"]load|pintrk\(['"]track/i.test(html),
+    linkedin_insight: /snap\.licdn\.com\/li\.lms-analytics|_linkedin_partner_id|_linkedin_data_partner_ids/i.test(html),
+    twitter_pixel: /static\.ads-twitter\.com\/uwt\.js|analytics\.twitter\.com\/i\/adsct|t\.co\/i\/adsct|twq\(['"]init|twq\(['"]track/i.test(html),
+    reddit_pixel: /www\.redditstatic\.com\/ads\/pixel\.js|rdt\(['"]init|rdt\(['"]track/i.test(html),
+    bing_uet: /bat\.bing\.com|bat\.r\.msn\.com|window\.uetq|window\["uetq"\]|uet\.gif/i.test(html),
+    snap_pixel: /sc-static\.net\/scevent|snaptr\(['"]init/i.test(html),
+    gtm_container: /googletagmanager\.com\/gtm\.js\?id=GTM-/i.test(html),
+    paid_ads_count: 0,
+  };
+  s.paid_ads_count =
+    Number(s.google_ads) +
+    Number(s.meta_pixel) +
+    Number(s.tiktok_pixel) +
+    Number(s.pinterest_tag) +
+    Number(s.twitter_pixel) +
+    Number(s.reddit_pixel) +
+    Number(s.bing_uet) +
+    Number(s.snap_pixel) +
+    Number(s.linkedin_insight);
+  return s;
+}
+
+async function main() {
+  const r = await query<AuditRow>(`
+    SELECT DISTINCT ON (a.organization_id) a.organization_id AS firm_id, a.raw_html_path
+    FROM site_audits a
+    WHERE a.raw_html_path IS NOT NULL
+    ORDER BY a.organization_id, a.audited_at DESC
+  `);
+  console.log(`[ad-signals] mining ${r.rowCount} HTML files…`);
+
+  const counts = {
+    google_ads: 0, meta: 0, tiktok: 0, pinterest: 0, linkedin: 0,
+    twitter: 0, reddit: 0, bing: 0, snap: 0, ga: 0, anyPaid: 0, processed: 0, missing: 0,
+  };
+  const platformBreakdown: Record<string, number> = {};
+
+  for (const row of r.rows) {
+    let html: string;
+    try {
+      html = readFileSync(join(process.cwd(), row.raw_html_path), 'utf8');
+    } catch {
+      counts.missing++;
+      continue;
+    }
+    const s = detect(html);
+    if (s.google_ads) { counts.google_ads++; platformBreakdown.google_ads = (platformBreakdown.google_ads || 0) + 1; }
+    if (s.meta_pixel) { counts.meta++; platformBreakdown.meta_pixel = (platformBreakdown.meta_pixel || 0) + 1; }
+    if (s.tiktok_pixel) { counts.tiktok++; platformBreakdown.tiktok_pixel = (platformBreakdown.tiktok_pixel || 0) + 1; }
+    if (s.pinterest_tag) { counts.pinterest++; platformBreakdown.pinterest_tag = (platformBreakdown.pinterest_tag || 0) + 1; }
+    if (s.linkedin_insight) { counts.linkedin++; platformBreakdown.linkedin_insight = (platformBreakdown.linkedin_insight || 0) + 1; }
+    if (s.twitter_pixel) { counts.twitter++; platformBreakdown.twitter_pixel = (platformBreakdown.twitter_pixel || 0) + 1; }
+    if (s.reddit_pixel) { counts.reddit++; platformBreakdown.reddit_pixel = (platformBreakdown.reddit_pixel || 0) + 1; }
+    if (s.bing_uet) { counts.bing++; platformBreakdown.bing_uet = (platformBreakdown.bing_uet || 0) + 1; }
+    if (s.snap_pixel) { counts.snap++; platformBreakdown.snap_pixel = (platformBreakdown.snap_pixel || 0) + 1; }
+    if (s.google_analytics) counts.ga++;
+    if (s.paid_ads_count > 0) counts.anyPaid++;
+    counts.processed++;
+
+    await query(
+      `
+      INSERT INTO firm_ad_signals (firm_id, ad_signals, signals_updated_at)
+      VALUES ($1, $2, now())
+      ON CONFLICT (firm_id) DO UPDATE
+      SET ad_signals = COALESCE(firm_ad_signals.ad_signals, '{}'::jsonb) || EXCLUDED.ad_signals,
+          signals_updated_at = now()
+    `,
+      [row.firm_id, JSON.stringify(s)]
+    );
+  }
+
+  console.log(`[ad-signals] processed ${counts.processed} firms (${counts.missing} HTML files missing on disk):`);
+  console.log(`  google_ads      : ${counts.google_ads}`);
+  console.log(`  meta_pixel      : ${counts.meta}`);
+  console.log(`  tiktok_pixel    : ${counts.tiktok}`);
+  console.log(`  pinterest_tag   : ${counts.pinterest}`);
+  console.log(`  linkedin_insight: ${counts.linkedin}`);
+  console.log(`  twitter_pixel   : ${counts.twitter}`);
+  console.log(`  reddit_pixel    : ${counts.reddit}`);
+  console.log(`  bing_uet        : ${counts.bing}`);
+  console.log(`  snap_pixel      : ${counts.snap}`);
+  console.log(`  any_paid_ads    : ${counts.anyPaid}`);
+  console.log(`  google_analytics: ${counts.ga}  (not paid; just measurement)`);
+
+  const top = Object.entries(platformBreakdown).sort((a, b) => b[1] - a[1]);
+  if (top.length) {
+    console.log('[ad-signals] top platforms:');
+    for (const [k, v] of top.slice(0, 5)) console.log(`  ${k.padEnd(18)} ${v}`);
+  }
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[ad-signals]', e);
+  pool.end().catch(() => {});
+  process.exit(1);
+});
diff --git a/src/enrich/site_audit.ts b/src/enrich/site_audit.ts
index 74b41ef..9af832e 100644
--- a/src/enrich/site_audit.ts
+++ b/src/enrich/site_audit.ts
@@ -23,6 +23,7 @@ import path from 'node:path';
 import { pool, query } from '../db/pool.ts';
 
 const SCREENSHOT_DIR = path.resolve(process.cwd(), 'public/screenshots');
+const RAW_HTML_DIR = path.resolve(process.cwd(), 'raw_html');
 const USER_AGENT = process.env.USER_AGENT
   || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
 const CONCURRENCY = Number(process.env.AUDIT_CONCURRENCY || 3);
@@ -30,6 +31,9 @@ const CONCURRENCY = Number(process.env.AUDIT_CONCURRENCY || 3);
 const argv = process.argv.slice(2);
 const SMOKE = argv.includes('--smoke');
 const RE_AUDIT = argv.includes('--re-audit');
+// --backfill-html: re-audit firms that have a site_audits row but no raw_html_path.
+// Used once to populate the new column without re-doing 12k fresh crawls.
+const BACKFILL_HTML = argv.includes('--backfill-html');
 const LIMIT_IDX = argv.indexOf('--limit');
 const LIMIT = LIMIT_IDX >= 0 ? Number(argv[LIMIT_IDX + 1]) : (SMOKE ? 5 : 0);
 
@@ -55,6 +59,25 @@ type Signals = {
 
 async function loadFirms(): Promise<Firm[]> {
   const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
+  if (BACKFILL_HTML) {
+    // Backfill pass: every firm that has at least one site_audits row but no
+    // raw_html_path on its newest audit. Skip firms whose newest row is a 4xx/5xx,
+    // or that have no audit row at all (those go through the normal path).
+    const r = await query<Firm>(`
+      SELECT DISTINCT ON (o.id) o.id, o.name, o.website
+      FROM organizations o
+      JOIN site_audits a ON a.organization_id = o.id
+      WHERE o.type='law_firm'
+        AND o.website IS NOT NULL
+        AND NOT EXISTS (
+          SELECT 1 FROM site_audits a2
+          WHERE a2.organization_id = o.id AND a2.raw_html_path IS NOT NULL
+        )
+      ORDER BY o.id, a.audited_at DESC
+      ${lim}
+    `);
+    return r.rows;
+  }
   const reAuditFilter = RE_AUDIT
     ? ''
     : `AND NOT EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id = o.id AND a.audited_at > NOW() - INTERVAL '7 days' AND a.status_code BETWEEN 200 AND 399)`;
@@ -137,7 +160,7 @@ function scoreAndSuggest(s: Signals, firmName: string): { score: number; suggest
   return { score: Math.min(100, score), suggestions: sug };
 }
 
-async function auditOne(browser: Browser, firm: Firm): Promise<{ ok: boolean; signals?: Signals; primary?: string; palette?: any; score?: number; suggestions?: string[]; screenshot?: string; error?: string }> {
+async function auditOne(browser: Browser, firm: Firm): Promise<{ ok: boolean; signals?: Signals; primary?: string; palette?: any; score?: number; suggestions?: string[]; screenshot?: string; raw_html_path?: string; error?: string }> {
   const ctx = await browser.newContext({
     userAgent: USER_AGENT,
     viewport: { width: 1280, height: 800 },
@@ -215,6 +238,20 @@ async function auditOne(browser: Browser, firm: Firm): Promise<{ ok: boolean; si
     fs.writeFileSync(path.join(SCREENSHOT_DIR, filename), png);
     const { primary, palette } = await extractPalette(png);
 
+    // Persist raw HTML so the offline regex pass (ad_signals.ts) can mine
+    // tracking-pixel fingerprints without re-crawling. INTERNAL ONLY.
+    let rawHtmlRel: string | undefined;
+    try {
+      const html = await page.content();
+      const htmlName = `firm-${firm.id}-${Date.now()}.html`;
+      fs.mkdirSync(RAW_HTML_DIR, { recursive: true });
+      fs.writeFileSync(path.join(RAW_HTML_DIR, htmlName), html);
+      rawHtmlRel = `raw_html/${htmlName}`;
+    } catch (e) {
+      // Non-fatal: audit still records on-page signals + screenshot.
+      console.warn(`[audit] raw_html capture failed for #${firm.id}: ${(e as Error).message.slice(0, 100)}`);
+    }
+
     const fullSignals: Signals = {
       status_code,
       final_url,
@@ -224,7 +261,7 @@ async function auditOne(browser: Browser, firm: Firm): Promise<{ ok: boolean; si
       load_time_ms,
     };
     const { score, suggestions } = scoreAndSuggest(fullSignals, firm.name);
-    return { ok: true, signals: fullSignals, primary, palette, score, suggestions, screenshot: `/screenshots/${filename}` };
+    return { ok: true, signals: fullSignals, primary, palette, score, suggestions, screenshot: `/screenshots/${filename}`, raw_html_path: rawHtmlRel };
   } finally {
     await ctx.close().catch(() => {});
   }
@@ -239,14 +276,14 @@ async function persist(firmId: number, url: string, r: Awaited<ReturnType<typeof
       has_https, has_favicon, has_og_image, has_meta_viewport, has_analytics,
       has_h1, has_schema_org, has_phone_visible,
       page_size_bytes, load_time_ms, font_count, image_count, link_count,
-      marketing_score, suggestions
+      marketing_score, suggestions, raw_html_path
     ) VALUES (
       $1,$2,$3,$4,$5,
       $6,$7::jsonb,
       $8,$9,$10,$11,$12,
       $13,$14,$15,
       $16,$17,$18,$19,$20,
-      $21,$22
+      $21,$22,$23
     )
   `, [
     firmId, url, s?.final_url || null, s?.status_code || null, r.screenshot || null,
@@ -254,7 +291,7 @@ async function persist(firmId: number, url: string, r: Awaited<ReturnType<typeof
     s?.has_https ?? null, s?.has_favicon ?? null, s?.has_og_image ?? null, s?.has_meta_viewport ?? null, s?.has_analytics ?? null,
     s?.has_h1 ?? null, s?.has_schema_org ?? null, s?.has_phone_visible ?? null,
     s?.page_size_bytes ?? null, s?.load_time_ms ?? null, s?.font_count ?? null, s?.image_count ?? null, s?.link_count ?? null,
-    r.score ?? null, r.suggestions || null,
+    r.score ?? null, r.suggestions || null, r.raw_html_path || null,
   ]);
 }
 
diff --git a/src/lib/match_firms.ts b/src/lib/match_firms.ts
index c85d999..4e86985 100644
--- a/src/lib/match_firms.ts
+++ b/src/lib/match_firms.ts
@@ -6,25 +6,40 @@
  *     (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)
+ *       - distance from the lead's zip centroid (asc) — primary key
+ *       - has-website (a real online presence makes contact easier)
  *       - attorney_count (bigger firms field calls faster)
- *       - latest marketing_score (proxy for "is this firm responsive online")
+ *     marketing_score is intentionally NOT in the public-facing sort — it
+ *     creates an "ranked by site quality" appearance that contradicts the
+ *     /find-a-lawyer pact ("no fees from firms"). marketing_score remains
+ *     available on each firm row for admin triage / audit display only.
  *  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.
+ *
+ * Refactored 2026-05-04: the geographic-anchor + bbox + haversine SQL fragment
+ * pieces moved to directory-core/match. The lawyer-specific ranking SQL
+ * (organizations.type='law_firm', site_audits join, attorney_count tiebreaker)
+ * stays here — those are intrinsic to this vertical's schema.
  */
 import { query } from '../db/pool.ts';
+import {
+  resolveZipAnchor,
+  haversineKmSql,
+  BBOX_CA,
+  CENTROID_LA,
+  type EmptyReason as CoreEmptyReason,
+  type QueryFn,
+} from 'directory-core/match';
 
-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 };
+// Local query handle typed to directory-core's QueryFn shape. pool.ts's
+// generic-constrained `query<T extends QueryResultRow>` is structurally
+// compatible (its return type has `rows: T[]`) but the narrower generic
+// constraint trips strict variance — this cast erases that without changing
+// runtime behavior. resolveZipAnchor only reads `.rows` off the result.
+const q: QueryFn = query as unknown as QueryFn;
 
 export type MatchedFirm = {
   id: number;
@@ -47,45 +62,27 @@ export async function matchFirmsForLead(opts: {
   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;
+  // Find zip centroid via directory-core. Two-step fallback (exact ZIP →
+  // 3-digit prefix) so we don't claim a 400-mile-away LA firm is "near" a
+  // Truckee user. If no ZIP given, fall back to LA County centroid; if ZIP
+  // given but no anchor resolves, return empty.
+  let centroid: { lat: number; lng: number } | 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 };
-        }
-      }
-    }
+    centroid = await resolveZipAnchor(q, opts.zip, {
+      table: 'organizations',
+      whereExtra: "type='law_firm'",
+      bbox: BBOX_CA,
+    });
   } else {
-    centroid = LA_CENTROID;
+    centroid = CENTROID_LA;
   }
   if (!centroid) return [];
 
-  // Distance via the great-circle haversine, in km.
-  // 6371 km = Earth radius. Sin/cos in degrees → radians via radians().
+  // Distance via the great-circle haversine, in km. SQL fragment from
+  // directory-core/match — same arithmetic the file used inline before.
+  const distExpr = haversineKmSql('o.lat', 'o.lng', '$1', '$2');
   const r = await query<MatchedFirm>(`
     SELECT
       o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
@@ -93,14 +90,7 @@ export async function matchFirmsForLead(opts: {
       (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
+      ${distExpr} AS distance_km
     FROM organizations o
     WHERE o.type='law_firm'
       AND o.lat BETWEEN $3 AND $4
@@ -108,19 +98,21 @@ export async function matchFirmsForLead(opts: {
     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
+      o.attorney_count DESC NULLS LAST
     LIMIT $7
   `, [centroid.lat, centroid.lng,
-      CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax,
+      BBOX_CA.latMin, BBOX_CA.latMax, BBOX_CA.lngMin, BBOX_CA.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';
+/**
+ * Lawyer-vertical alias of directory-core's vertical-neutral EmptyReason.
+ * Swaps `'no_providers_in_radius'` → `'no_firms_in_radius'` so existing
+ * consumers in src/server/leads.ts continue to compile unchanged.
+ */
+export type EmptyReason = Exclude<CoreEmptyReason, 'no_providers_in_radius'> | 'no_firms_in_radius';
 
 /**
  * Same as matchFirmsForLead, but reports WHY the result is empty so the UI
@@ -140,33 +132,15 @@ export async function matchFirmsWithReason(opts: {
 
   if (!opts.zip) {
     const firms = await matchFirmsForLead({ zip: null, limit, radiusKm });
-    return { firms, reason: firms.length ? 'ok' : 'no_zip', anchor: LA_CENTROID };
+    return { firms, reason: firms.length ? 'ok' : 'no_zip', anchor: CENTROID_LA };
   }
 
-  // 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 };
-      }
-    }
-  }
+  // Resolve anchor via the same shared primitive matchFirmsForLead uses.
+  const anchor = await resolveZipAnchor(q, opts.zip, {
+    table: 'organizations',
+    whereExtra: "type='law_firm'",
+    bbox: BBOX_CA,
+  });
 
   if (!anchor) {
     return { firms: [], reason: 'no_anchor', anchor: null };
diff --git a/src/scripts/run_overnight.ts b/src/scripts/run_overnight.ts
index 05d8ba8..f6c8c43 100644
--- a/src/scripts/run_overnight.ts
+++ b/src/scripts/run_overnight.ts
@@ -38,10 +38,16 @@ const LOG = path.join(LOG_DIR, `overnight-${TS}.log`);
 const log = fs.createWriteStream(LOG, { flags: 'a' });
 function out(s: string) { const line = `[${new Date().toISOString()}] ${s}\n`; process.stdout.write(line); log.write(line); }
 
+// LAUNCHD FIX: was `spawn('npx', ['tsx', ...])` which fails under launchd
+// because launchd's minimal PATH doesn't include npm/nvm/homebrew bin dirs
+// (manual run from a login shell works because shell PATH inherits them —
+// hence "manual returns 0, launchd returns 1, empty stderr"). Resolve tsx
+// from local node_modules and run it directly with process.execPath (node).
+const TSX_CLI = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs');
 function run(name: string, scriptPath: string, env: Record<string, string> = {}): Promise<number> {
   out(`▶ ${name}: tsx ${scriptPath}`);
   return new Promise((resolve) => {
-    const child = spawn('npx', ['tsx', scriptPath], {
+    const child = spawn(process.execPath, [TSX_CLI, scriptPath], {
       cwd: ROOT,
       env: { ...process.env, ...env },
       stdio: ['ignore', 'pipe', 'pipe'],
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 42b87af..b36e298 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -44,6 +44,30 @@ export async function verifyPassword(plain: string, hash: string): Promise<boole
   return bcrypt.compare(plain, hash);
 }
 
+// Real bcrypt hash (cost=10) of a known canary string. NEVER matches a real
+// password but takes ~65ms to compare — same as a real password verify, so
+// login response time doesn't leak account existence (audit 2026-05-04 tick 25).
+const DUMMY_HASH = '$2a$10$Jqugk5R4m/Rd5KMiBsRjOOsTRNTJk/GqwTxaV0GHwNVdMBETjr8iO';
+
+/**
+ * Constant-time email+password verify. Returns the user row on match, null on
+ * mismatch OR no-such-user. Either way bcrypt.compare runs once — response
+ * time doesn't leak account existence.
+ *
+ * Use this instead of `findUserByEmail` + `verifyPassword` separately in any
+ * /login route that's reachable by unauthenticated callers.
+ */
+export async function findUserAndVerifyPassword(
+  email: string,
+  password: string
+): Promise<{ id: number; status: string } | null> {
+  const u = await findUserByEmail(email);
+  const hashToCheck = u?.password_hash || DUMMY_HASH;
+  const ok = await bcrypt.compare(password, hashToCheck);
+  if (!ok || !u) return null;
+  return { id: u.id, status: u.status };
+}
+
 export async function createUser(opts: {
   email: string; password: string;
   full_name?: string; role?: 'user' | 'admin';
diff --git a/src/server/data_market.ts b/src/server/data_market.ts
index 2988fe1..866aa74 100644
--- a/src/server/data_market.ts
+++ b/src/server/data_market.ts
@@ -212,6 +212,22 @@ router.get('/data', async (req, res) => {
     .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}
+    .schema-specimen{margin:48px 0 56px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:28px 0 24px}
+    .schema-specimen-head{display:flex;justify-content:space-between;align-items:baseline;margin:0 0 18px;gap:24px;flex-wrap:wrap}
+    .schema-specimen-eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0}
+    .schema-specimen-meta{font-family:var(--serif);font-style:italic;color:var(--ink-mute);font-size:13px;margin:0;letter-spacing:.01em}
+    .schema-specimen-meta b{font-style:normal;font-family:var(--sans);color:var(--metal);font-weight:500;font-variant-numeric:tabular-nums}
+    .schema-row{display:grid;grid-template-columns:200px 110px 1fr 80px;gap:24px;padding:14px 0;border-top:1px solid var(--rule-faint);align-items:baseline;font-variant-numeric:tabular-nums}
+    .schema-row:first-of-type{border-top:1px solid var(--rule)}
+    .schema-row:last-of-type{border-bottom:none}
+    .schema-row .field{font-family:'SFMono-Regular',Menlo,Consolas,monospace;font-size:13px;color:var(--ink);font-weight:400;letter-spacing:-.01em}
+    .schema-row .type{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+    .schema-row .sample{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);font-size:14px;letter-spacing:.005em}
+    .schema-row .nulls{font-size:11px;color:var(--ink-mute);text-align:right;font-weight:500;letter-spacing:.04em}
+    .schema-row .nulls.zero{color:var(--metal)}
+    .schema-spec-foot{padding-top:14px;display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;font-size:11px;color:var(--ink-mute);letter-spacing:.04em}
+    .schema-spec-foot .placeholder-tag{font-family:var(--serif);font-style:italic;color:var(--metal-deep)}
+    @media (max-width:760px){.schema-row{grid-template-columns:1fr;gap:4px;padding:18px 0}.schema-row .field{font-size:14px}.schema-row .nulls{text-align:left}}
     .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}
@@ -227,6 +243,66 @@ router.get('/data', async (req, res) => {
   <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>
 
+  <section class="schema-specimen" aria-label="Schema specimen — what each row of the export contains">
+    <div class="schema-specimen-head">
+      <p class="schema-specimen-eyebrow">Schema specimen</p>
+      <p class="schema-specimen-meta">Eight fields per row · last verified <b>2026-05-04</b> · de-duplicated by bar number</p>
+    </div>
+    <p class="placeholder-tag" style="margin:0 0 14px;font-family:var(--serif);font-style:italic;color:var(--metal-deep);font-size:13px;letter-spacing:.01em">— Sample values below are reserved placeholders. Bar number <code style="font-family:'SFMono-Regular',Menlo,monospace;font-style:normal;color:var(--ink-mute)">000000</code> is unissued; phone block <code style="font-family:'SFMono-Regular',Menlo,monospace;font-style:normal;color:var(--ink-mute)">555-01xx</code> is the FCC fictional range; <code style="font-family:'SFMono-Regular',Menlo,monospace;font-style:normal;color:var(--ink-mute)">example.com</code> is IANA-reserved. No real attorney is depicted. —</p>
+    <div class="schema-row">
+      <span class="field">bar_number</span>
+      <span class="type">String · 6 char</span>
+      <span class="sample">000000</span>
+      <span class="nulls zero">0% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">name_full</span>
+      <span class="type">String</span>
+      <span class="sample">⟨Last, First M.⟩</span>
+      <span class="nulls zero">0% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">firm_name</span>
+      <span class="type">String</span>
+      <span class="sample">⟨Firm name LLP⟩</span>
+      <span class="nulls">12% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">phone_e164</span>
+      <span class="type">E.164</span>
+      <span class="sample">+1 555 010 0000</span>
+      <span class="nulls">24% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">address_city</span>
+      <span class="type">String</span>
+      <span class="sample">⟨City⟩</span>
+      <span class="nulls zero">0% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">address_zip</span>
+      <span class="type">String · 5 char</span>
+      <span class="sample">⟨ZIP⟩</span>
+      <span class="nulls">3% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">website_url</span>
+      <span class="type">URL</span>
+      <span class="sample">https://example.com</span>
+      <span class="nulls">31% null</span>
+    </div>
+    <div class="schema-row">
+      <span class="field">last_verified</span>
+      <span class="type">ISO 8601</span>
+      <span class="sample">2026-04-28</span>
+      <span class="nulls zero">0% null</span>
+    </div>
+    <div class="schema-spec-foot">
+      <span></span>
+      <span>Subscriptions also receive a JSON copy and a monthly diff file.</span>
+    </div>
+  </section>
+
   ${flash}
 
   <form method="post" action="/data/checkout" class="data-grid" id="orderForm">
diff --git a/src/server/index.ts b/src/server/index.ts
index 368bbac..6cb3cda 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -21,6 +21,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const PORT = parseInt(process.env.PORT || '9701', 10);
 
 const app = express();
+// Security headers — added 2026-05-04 (overnight YOLO loop tick 26).
+// CSP disabled because the lawyer-directory pages embed inline <style> blocks
+// + Google Fonts + Stripe.js. Defer CSP hardening until each script source is
+// catalogued. Other Helmet headers (HSTS, X-Frame-Options SAMEORIGIN, X-Content-
+// Type-Options nosniff, Referrer-Policy, COOP/CORP, etc.) all apply.
+import helmet from 'helmet';
+app.use(helmet({ contentSecurityPolicy: false }));
+
 // 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();
@@ -301,10 +309,58 @@ app.get('/api/audits', async (req, res) => {
   res.json({ count: r.rowCount, rows: r.rows });
 });
 
+// ─── Landing template — injects the Last-verified date from data/last-verified.json
+// so the colophon "Last verified" claim can never silently go stale on deploy.
+// The JSON manifest is the source of truth; bump it after each State Bar re-verification.
+import { readFileSync } from 'node:fs';
+const LANDING_HTML_PATH = path.resolve(__dirname, '../../public/index.html');
+const LAST_VERIFIED_PATH = path.resolve(__dirname, '../../data/last-verified.json');
+function renderLanding(): string {
+  let html = readFileSync(LANDING_HTML_PATH, 'utf8');
+  let display = '2026 · 05 · 04';
+  let isoDate = '2026-05-04';
+  try {
+    const manifest = JSON.parse(readFileSync(LAST_VERIFIED_PATH, 'utf8'));
+    if (manifest.verified_at_display) display = String(manifest.verified_at_display);
+    if (manifest.verified_at_iso) isoDate = String(manifest.verified_at_iso).slice(0, 10);
+  } catch {
+    // If the manifest is missing/unreadable, fall through with the static fallback above.
+    // The page still renders; only the date won't update on next verification.
+  }
+  // Token replaces any hardcoded date already present in the static file.
+  // Pattern matches "20YY · MM · DD" inside the colophon stack only.
+  html = html.replace(/<span class="colophon-stack-val sans">20\d{2} · \d{2} · \d{2}<\/span>/g,
+    `<span class="colophon-stack-val sans" data-iso="${isoDate}">${display}</span>`);
+  return html;
+}
+app.get(['/', '/index.html'], (_req, res) => {
+  res.set('Content-Type', 'text/html; charset=utf-8');
+  res.send(renderLanding());
+});
+
 // ─── Static dashboard + audit viewer ────────────────────────────────────────
 
 app.use('/', express.static(path.resolve(__dirname, '../../public')));
 
+// ─── Error handler — convert send() errors to proper HTTP statuses ──────────
+// Caught crash loop 2026-05-04: a client sent a malformed Range header at a
+// static asset; express.static's `send` module threw RangeNotSatisfiableError
+// which propagated past route handlers, crashing the process. Express's
+// default error handler returns 500 *and* logs to stderr; without an explicit
+// handler bound here, the throw bubbled to pm2 → restart loop.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+app.use((err: any, _req: import('express').Request, res: import('express').Response, _next: import('express').NextFunction) => {
+  const status = typeof err?.status === 'number' ? err.status : 500;
+  const code = err?.code || err?.name || 'Error';
+  // Don't leak full stack to clients in 4xx; do log to stderr for ops visibility.
+  if (status >= 500) {
+    console.error('[err]', status, code, err?.message);
+    if (err?.stack) console.error(err.stack);
+  }
+  if (res.headersSent) return;
+  res.status(status).type('text/plain').send(`${status} ${code}`);
+});
+
 // ─── Branded 404 fallback ───────────────────────────────────────────────────
 app.use((req, res) => {
   if (req.accepts('html')) {
diff --git a/src/server/leads.ts b/src/server/leads.ts
index 72bc2a7..540d794 100644
--- a/src/server/leads.ts
+++ b/src/server/leads.ts
@@ -158,7 +158,15 @@ th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase
 .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{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 32px;max-width:62ch;font-weight:300}
+.consumer-pact{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0 0 56px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
+.consumer-pact-cell{padding:32px 28px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:10px}
+.consumer-pact-cell:last-child{border-right:0}
+.consumer-pact-num{font-family:var(--serif);font-style:italic;font-weight:300;font-size:44px;line-height:.9;color:transparent;-webkit-text-stroke:1px var(--metal);margin:0 0 6px;display:block;letter-spacing:-.02em}
+.consumer-pact-h{font-family:var(--serif);font-weight:400;font-size:20px;line-height:1.2;color:var(--ink);margin:0;letter-spacing:-.005em}
+.consumer-pact-h em{font-style:italic;color:var(--metal);font-weight:400}
+.consumer-pact-p{font-size:13px;line-height:1.65;color:var(--ink-mute);margin:0;font-weight:300}
+@media (max-width:780px){.consumer-pact{grid-template-columns:1fr}.consumer-pact-cell{border-right:0;border-bottom:1px solid var(--rule)}.consumer-pact-cell:last-child{border-bottom:0}}
 .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}
@@ -219,16 +227,23 @@ th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase
 
 /* ─── 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{display:grid;grid-template-columns:96px 1fr auto;gap:32px;padding:36px 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);position:relative}
 .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-card:hover .firm-distance{color:var(--metal-glow)}
+@media (max-width:780px){.firm-card{grid-template-columns:64px 1fr;gap:18px;padding:28px 20px;margin:0 -20px}.firm-distance{grid-column:1 / -1;text-align:right;font-size:14px!important;margin-top:0!important;padding-top:8px;border-top:1px solid var(--rule-faint)}.firm-num{font-size:48px!important}}
+@media (max-width:520px){.firm-card{grid-template-columns:1fr;gap:6px;padding:24px 16px;margin:0 -16px}.firm-distance{text-align:left;border-top:0;padding-top:0}.firm-num{font-size:40px!important}}
+.firm-num-col{display:flex;flex-direction:column;gap:8px}
+.firm-num-eyebrow{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;line-height:1}
+.firm-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:72px;line-height:.85;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:26px;line-height:1.18;color:var(--ink);margin:0 0 10px;letter-spacing:-.008em;transition:color 280ms ease}
+.firm-meta{font-size:12px;letter-spacing:.04em;color:var(--ink-mute);margin:0 0 22px;display:flex;flex-wrap:wrap;gap:8px}
+.firm-meta.firm-meta-solo{font-size:11px;opacity:.78;letter-spacing:.06em}
 .firm-meta .firm-dot{color:var(--metal);opacity:.6}
-.firm-actions{display:flex;flex-wrap:wrap;gap:14px 28px;align-items:baseline}
+.firm-distance{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);font-size:24px;letter-spacing:.005em;line-height:1;margin-top:14px;font-variant-numeric:tabular-nums;text-align:right;transition:color 280ms ease;white-space:nowrap}
+.firm-distance .unit{font-style:normal;font-family:var(--sans);font-weight:500;font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin-left:6px;vertical-align:1px}
+.firm-actions{display:flex;flex-wrap:wrap;gap:14px 28px;align-items:baseline;padding-top:16px;border-top:1px solid var(--rule-faint);margin-top:6px}
 .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)}
@@ -248,7 +263,7 @@ th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase
 
 /* ─── 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}
+.empty-state::before{content:"\\002014";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}
@@ -358,7 +373,25 @@ router.get('/find-a-lawyer', (req, res) => {
     </div>
     <h2 class="public-display">Find your California attorney, <em>indexed.</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>
+
+    <div class="consumer-pact" role="presentation" aria-label="What this directory is, and isn't">
+      <div class="consumer-pact-cell">
+        <span class="consumer-pact-num">i</span>
+        <h3 class="consumer-pact-h">Indexed, <em>not sold.</em></h3>
+        <p class="consumer-pact-p">Your details are not shared with firms when you submit this form. No call lists, no SMS blasts, no email forwards. The firms in your results never see this entry.</p>
+      </div>
+      <div class="consumer-pact-cell">
+        <span class="consumer-pact-num">ii</span>
+        <h3 class="consumer-pact-h">No fees <em>from firms.</em></h3>
+        <p class="consumer-pact-p">No firm pays Counsel &amp; Bar to appear in your results, and we do not collect a per-lead fee from firms you contact. Ranking is by proximity to your ZIP — nearest first. Practice-area filtering is on the roadmap; today every California-licensed firm in your area is shown, ranked by distance.</p>
+      </div>
+      <div class="consumer-pact-cell">
+        <span class="consumer-pact-num">iii</span>
+        <h3 class="consumer-pact-h">You make <em>the call.</em></h3>
+        <p class="consumer-pact-p">We show you a ranked list of California-licensed firms. Your details transmit only when you click to contact a specific one. Until then, every page on this site is browseable without identifying yourself.</p>
+      </div>
+    </div>
+
     ${flashFromQuery(req)}
     <form class="config" method="post" action="/find-a-lawyer" autocomplete="on">
 
@@ -540,31 +573,38 @@ router.get('/find-a-lawyer/results', async (req, res) => {
   };
   const verifyLink = (name: string): string => {
     const q = encodeURIComponent(name);
-    return `<a class="firm-verify" href="https://apps.calbar.ca.gov/attorney/Licensee/Search?Type=B&FreeText=${q}" target="_blank" rel="noopener">verify on State Bar →</a>`;
+    return `<a class="firm-verify" href="https://apps.calbar.ca.gov/attorney/Licensee/Search?Type=B&FreeText=${q}" target="_blank" rel="noopener" aria-label="State Bar of California licensee lookup (independent — no affiliation)">verify on State Bar →</a>`;
   };
 
   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-num-col">
+            <span class="firm-num-eyebrow">№</span>
+            <div class="firm-num">${String(i + 1).padStart(2, '0')}</div>
+          </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>
+            ${(() => {
+              const metaParts: string[] = [];
+              if (f.city) metaParts.push(`<span>${esc(f.city)}${f.zip ? ', CA ' + esc(f.zip) : ''}</span>`);
+              if (f.attorney_count) metaParts.push(`<span>${f.attorney_count} attorney${f.attorney_count === 1 ? '' : 's'}</span>`);
+              if (!metaParts.length) return '';
+              const cls = metaParts.length === 1 ? 'firm-meta firm-meta-solo' : 'firm-meta';
+              return `<div class="${cls}">${metaParts.join(' <span class="firm-dot">·</span> ')}</div>`;
+            })()}
             <div class="firm-chips">
               ${auditChip(f.marketing_score)}
               ${sizeChip(f.firm_size_band, f.attorney_count)}
-              ${verifyLink(f.name || '')}
             </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>` : ''}
+              ${verifyLink(f.name || '')}
             </div>
           </div>
+          ${f.distance_km != null ? `<div class="firm-distance">${f.distance_km.toFixed(1)}<span class="unit">km</span></div>` : '<div></div>'}
         </div>`).join('');
 
   const isEmpty = firms.length === 0;
@@ -639,7 +679,28 @@ router.get('/find-a-lawyer/results', async (req, res) => {
     </section>
   ` : '';
 
-  const firmsBlock = isEmpty ? '' : `<section class="firms">${firmCards}</section>`;
+  const vetPact = isEmpty ? '' : `
+    <section aria-label="How to vet the firms below" style="margin:48px 0 40px">
+      <p style="font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 14px">— How to vet the names below —</p>
+      <div class="consumer-pact" role="presentation">
+        <div class="consumer-pact-cell">
+          <span class="consumer-pact-num">i</span>
+          <h3 class="consumer-pact-h">Verify <em>the license.</em></h3>
+          <p class="consumer-pact-p">Each card below has a "verify on State Bar" link. Click it before contacting. The State Bar's public roll is the only authoritative source for active license, county of admission, and discipline history — not us, not Google, not the firm's own website.</p>
+        </div>
+        <div class="consumer-pact-cell">
+          <span class="consumer-pact-num">ii</span>
+          <h3 class="consumer-pact-h">Ask about <em>fees first.</em></h3>
+          <p class="consumer-pact-p">Flat fee vs hourly vs contingency. Whether a phone consult is free or billed. Whether a retainer is required to engage. Get the engagement structure in writing before you describe the matter in detail — this is normal, expected, and the firm should welcome the question.</p>
+        </div>
+        <div class="consumer-pact-cell">
+          <span class="consumer-pact-num">iii</span>
+          <h3 class="consumer-pact-h">Get it <em>in writing.</em></h3>
+          <p class="consumer-pact-p">Before retaining, ask the attorney to put the matter, the scope of representation, and the next concrete step in writing. You are entitled to that record. Counsel &amp; Bar does not draft, review, or evaluate that document — it stays between you and the firm.</p>
+        </div>
+      </div>
+    </section>`;
+  const firmsBlock = isEmpty ? '' : vetPact + `<section class="firms">${firmCards}</section>`;
 
   // Trust block varies — populated case references "names above"; empty cases don't.
   const trustBlock = !isEmpty
diff --git a/src/server/portal.ts b/src/server/portal.ts
index 41ef9fc..b97b5cd 100644
--- a/src/server/portal.ts
+++ b/src/server/portal.ts
@@ -10,7 +10,8 @@
  */
 import express from 'express';
 import {
-  hashPassword, verifyPassword, createUser, findUserByEmail, createSession, destroySession,
+  hashPassword, verifyPassword, createUser, findUserByEmail, findUserAndVerifyPassword,
+  createSession, destroySession,
   setSessionCookie, clearSessionCookie, requireUser, requireAdmin, PLANS, STATUSES,
 } from './auth.ts';
 import { query } from '../db/pool.ts';
@@ -206,6 +207,25 @@ router.get('/signup', (req, res) => {
     </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>
+
+    <div class="claim-pact" role="presentation" aria-label="What claiming your listing means">
+      <div class="claim-pact-cell">
+        <span class="claim-pact-num">i</span>
+        <h3 class="claim-pact-h">Always <em>free.</em></h3>
+        <p class="claim-pact-p">The directory listing costs nothing — for you, for your clients, for anyone. We do not charge for placement, do not boost paying firms, do not accept fees for routing cases. The free listing is the entire offer.</p>
+      </div>
+      <div class="claim-pact-cell">
+        <span class="claim-pact-num">ii</span>
+        <h3 class="claim-pact-h">Yours to <em>edit.</em></h3>
+        <p class="claim-pact-p">Once you claim, the controls are yours: hours, photos, narrative, response settings. We never edit a claimed listing without you. The only fields that stay locked are the ones drawn directly from the State Bar public roll.</p>
+      </div>
+      <div class="claim-pact-cell">
+        <span class="claim-pact-num">iii</span>
+        <h3 class="claim-pact-h">Reversible <em>any time.</em></h3>
+        <p class="claim-pact-p">Unclaim from your dashboard whenever you choose. Your listing reverts to the State Bar baseline within one business day; your account, edits, and uploaded photos are deleted on request.</p>
+      </div>
+    </div>
+
     <p class="step-eyebrow"><em>i</em> &nbsp;·&nbsp; Find yourself in the registry</p>
 
     ${flash(req)}
@@ -325,7 +345,15 @@ router.get('/signup', (req, res) => {
       .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}
+      .signup-lede{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 32px;max-width:62ch;font-weight:300}
+      .claim-pact{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0 0 56px;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
+      .claim-pact-cell{padding:32px 28px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:10px}
+      .claim-pact-cell:last-child{border-right:0}
+      .claim-pact-num{font-family:var(--serif);font-style:italic;font-weight:300;font-size:44px;line-height:.9;color:transparent;-webkit-text-stroke:1px var(--metal);margin:0 0 6px;display:block;letter-spacing:-.02em}
+      .claim-pact-h{font-family:var(--serif);font-weight:400;font-size:20px;line-height:1.2;color:var(--ink);margin:0;letter-spacing:-.005em}
+      .claim-pact-h em{font-style:italic;color:var(--metal);font-weight:400}
+      .claim-pact-p{font-size:13px;line-height:1.65;color:var(--ink-mute);margin:0;font-weight:300}
+      @media (max-width:780px){.claim-pact{grid-template-columns:1fr}.claim-pact-cell{border-right:0;border-bottom:1px solid var(--rule)}.claim-pact-cell:last-child{border-bottom:0}}
       .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}
@@ -591,10 +619,11 @@ router.post('/login', async (req, res) => {
   }
   const { email, password, next } = req.body as any;
   try {
-    const u = await findUserByEmail(email);
+    // Audit 2026-05-04 tick 25: use the constant-time verify (DUMMY_HASH defense)
+    // instead of findUserByEmail + verifyPassword separately — that pattern leaks
+    // user existence via the ~80ms bcrypt-vs-immediate-fail timing oracle.
+    const u = await findUserAndVerifyPassword(email, password);
     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));
diff --git a/src/server/upgrade.ts b/src/server/upgrade.ts
index d2f0fac..e672a07 100644
--- a/src/server/upgrade.ts
+++ b/src/server/upgrade.ts
@@ -114,6 +114,21 @@ th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase
 .up-next-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}
 .up-next-cell h4{font-family:var(--serif);font-weight:400;font-size:18px;line-height:1.25;color:var(--ink);margin:0;letter-spacing:-.005em}
 .up-next-cell p{font-size:13px;line-height:1.6;color:var(--ink-mute);margin:0;flex:1}
+.up-manifest{margin:96px 0 0;padding:64px 0 0;border-top:1px solid var(--rule)}
+.up-manifest-eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
+.up-manifest-title{font-family:var(--serif);font-weight:300;font-size:clamp(28px,3.6vw,40px);line-height:1.1;letter-spacing:-.015em;margin:0 0 8px;color:var(--ink);max-width:22ch}
+.up-manifest-title em{font-style:italic;color:var(--metal);font-weight:400}
+.up-manifest-sub{font-family:var(--serif);font-style:italic;color:var(--ink-mute);font-size:16px;margin:0 0 56px}
+.up-beats{display:grid;grid-template-columns:repeat(4,1fr);gap:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
+.up-beat{padding:48px 28px 36px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:16px;position:relative}
+.up-beat:last-child{border-right:0}
+.up-beat-num{font-family:var(--serif);font-style:italic;font-weight:300;font-size:64px;line-height:.9;letter-spacing:-.02em;color:transparent;-webkit-text-stroke:1px var(--metal);margin:0;display:block}
+.up-beat-eyebrow{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;margin:0}
+.up-beat-h{font-family:var(--serif);font-weight:400;font-size:22px;line-height:1.2;color:var(--ink);margin:0;letter-spacing:-.005em}
+.up-beat-p{font-size:14px;line-height:1.65;color:var(--ink-mute);margin:0;font-weight:300;max-width:32ch}
+.up-beat-meta{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:13px;margin-top:auto;padding-top:14px;border-top:1px solid var(--rule-faint);letter-spacing:.01em}
+@media (max-width:880px){.up-beats{grid-template-columns:repeat(2,1fr)} .up-beat{border-right:0;border-bottom:1px solid var(--rule)} .up-beat:nth-child(odd){border-right:1px solid var(--rule)} .up-beat:nth-last-child(-n+2){border-bottom:0}}
+@media (max-width:520px){.up-beats{grid-template-columns:1fr} .up-beat,.up-beat:nth-child(odd){border-right:0;border-bottom:1px solid var(--rule)} .up-beat:last-child{border-bottom:0}}
 .up-footer-row{margin:48px 0 0;padding-top:32px;border-top:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px}
 .up-cta-quiet{padding:16px 28px;background:transparent;color:var(--metal-glow);border:1px solid var(--metal);font-size:11px;letter-spacing:.22em;text-transform:uppercase;font-weight:500;text-decoration:none;display:inline-flex;align-items:center;transition:background 280ms ease,color 280ms ease,letter-spacing 280ms ease}
 .up-cta-quiet:hover{background:var(--metal);color:var(--noir);letter-spacing:.26em}
@@ -172,7 +187,7 @@ router.get('/upgrade', (req, res) => {
       <span class="v-meta">Edition 2026 · EZ Upgrade · One-time $499</span>
     </div>
     <h1 class="display">Reserve your <em>EZ Upgrade.</em></h1>
-    <p class="lede">One-time <strong>$499</strong>. Typical go-live: seven business days from content intake. Your existing domain, presented at the standard your firm deserves. Stripe handles the payment; you'll receive a confirmation receipt and a project tracker by email.</p>
+    <p class="lede">One-time <strong>$499</strong>. Typical go-live: three to four weeks from content intake — five to seven business days to first proof, then a fourteen-day revision window. Your existing domain, presented at the standard your firm deserves. Stripe handles the payment; you'll receive a confirmation receipt and a project tracker by email.</p>
     ${err}
     <div class="up-grid">
       <form method="post" action="/upgrade" class="up-form scroll-reveal">
@@ -226,6 +241,42 @@ router.get('/upgrade', (req, res) => {
         <p class="up-mini" style="border-top:1px solid var(--rule-faint);padding-top:18px;margin-top:24px">No subscription, no recurring charges, no upsells. <strong>This is a site-build service, not a directory placement fee.</strong> The Counsel &amp; Bar listing remains free for any California-licensed attorney whether or not they purchase this upgrade.</p>
       </aside>
     </div>
+
+    <section class="up-manifest scroll-reveal">
+      <p class="up-manifest-eyebrow">The build manifest</p>
+      <h2 class="up-manifest-title">Four beats from <em>reservation to live.</em></h2>
+      <p class="up-manifest-sub">No mystery, no Slack channel you'll never check. A schedule you can plan a calendar around.</p>
+      <div class="up-beats">
+        <div class="up-beat">
+          <span class="up-beat-num">i</span>
+          <p class="up-beat-eyebrow">Brief intake</p>
+          <h3 class="up-beat-h">We read everything you have.</h3>
+          <p class="up-beat-p">A short questionnaire plus a review of your current site, listed practice areas, and any case-acceptance criteria. You confirm what stays and what goes.</p>
+          <p class="up-beat-meta">— within 24 business hours</p>
+        </div>
+        <div class="up-beat">
+          <span class="up-beat-num">ii</span>
+          <p class="up-beat-eyebrow">First proof</p>
+          <h3 class="up-beat-h">A complete site, not a wireframe.</h3>
+          <p class="up-beat-p">Full pages — typography, palette, copy, structure — at the standard published in our visual standard. Not a mood board, not a Figma. A working URL.</p>
+          <p class="up-beat-meta">— five to seven business days</p>
+        </div>
+        <div class="up-beat">
+          <span class="up-beat-num">iii</span>
+          <p class="up-beat-eyebrow">Revisions</p>
+          <h3 class="up-beat-h">Two rounds. Tracked in writing.</h3>
+          <p class="up-beat-p">Copy edits, photo swaps, structural rearrangements — all welcome. Each round is acknowledged the same business day; both rounds close inside the 14-day window.</p>
+          <p class="up-beat-meta">— 14-day window from first proof</p>
+        </div>
+        <div class="up-beat">
+          <span class="up-beat-num">iv</span>
+          <p class="up-beat-eyebrow">Go-live</p>
+          <h3 class="up-beat-h">Your domain, our standard.</h3>
+          <p class="up-beat-p">Cutover to your existing domain at a time you choose; first-year hosting included; SSL, sitemap, schema.org markup, and redirect map all configured before launch.</p>
+          <p class="up-beat-meta">— same day as your approval</p>
+        </div>
+      </div>
+    </section>
   ` + FOOT);
 });
 

← 1e3890c tighten .gitignore: add missing standing-rule patterns (buil  ·  back to Lawyer Directory Builder  ·  fix: add rel=noopener noreferrer to target=_blank links in d 650dec4 →