← back to Lawyer Directory Builder

src/enrich/render_mockups.ts

518 lines

/**
 * Render mockup screenshots for law firms.
 *
 * For each firm scope (default: Beverly Hills), generate 4 variants and
 * persist into site_mockups:
 *   a — Editorial Noir       (deterministic, mockup_templates.ts)
 *   b — Clean Modern Light   (deterministic, mockup_templates.ts)
 *   c — Bold Confident       (deterministic, mockup_templates.ts)
 *   x — LLM Bespoke          (gemma3:12b custom HTML per firm)
 *
 * All HTML is saved to public/mockups/firm-{id}-{variant}.html and rendered
 * to a PNG at public/mockups/firm-{id}-{variant}.png. Both files are served
 * statically by the existing express.static('/').
 *
 * CLI:
 *   npx tsx src/enrich/render_mockups.ts --city "Beverly Hills"
 *   npx tsx src/enrich/render_mockups.ts --city "Beverly Hills" --skip-llm
 *   npx tsx src/enrich/render_mockups.ts --zips 90210,90211,90212,90213
 *   npx tsx src/enrich/render_mockups.ts --limit 10 --re-render
 *   npx tsx src/enrich/render_mockups.ts --la-county
 */
import 'dotenv/config';
import { chromium, Browser } from 'playwright';
import { load } from 'cheerio';
import fs from 'node:fs';
import path from 'node:path';
import { pool, query } from '../db/pool.ts';
import { fetchCompliant } from '../lib/compliance.ts';

const MOCKUP_DIR = path.resolve(process.cwd(), 'public/mockups');
const TEMPLATES_DIR = path.resolve(process.cwd(), 'public/mockup-templates');
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const LLM_MODEL  = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 120000);

// ─── load top-N rotation pool of leading-firm templates ─────────────────────
type Template = { id: number; name: string; website: string; score: number; primary_color: string; body_file: string };
let TEMPLATES: Template[] = [];
try {
  const tpath = path.join(TEMPLATES_DIR, 'top12.json');
  if (fs.existsSync(tpath)) {
    TEMPLATES = JSON.parse(fs.readFileSync(tpath, 'utf8'));
  }
} catch { /* empty pool — fall back to old prompt */ }
function pickTemplate(firmId: number): Template | null {
  if (TEMPLATES.length === 0) return null;
  return TEMPLATES[firmId % TEMPLATES.length];
}
function loadTemplateBody(t: Template): string {
  try {
    // Cap at 2.5KB so the prompt stays under model context budget
    return fs.readFileSync(path.join(TEMPLATES_DIR, t.body_file), 'utf8').slice(0, 2500);
  } catch { return ''; }
}

const argv = process.argv.slice(2);
const has = (f: string) => argv.includes(f);
const arg = (f: string): string | undefined => {
  const i = argv.indexOf(f);
  return i >= 0 ? argv[i + 1] : undefined;
};
const CITY = arg('--city') || null;
const ZIPS = (arg('--zips') || '').split(',').map(s => s.trim()).filter(Boolean);
const LIMIT = Number(arg('--limit') || 0);
const ID_MOD = Number(arg('--id-mod') || 0);
const ID_REM = Number(arg('--id-rem') || 0);
const RE_RENDER = has('--re-render');
const SKIP_LLM = has('--skip-llm');
const LA_COUNTY = has('--la-county');

// LA County cities (a workable subset — Beverly Hills, surrounding, downtown LA)
const LA_COUNTY_CITIES = [
  'Los Angeles','Beverly Hills','Santa Monica','Pasadena','Long Beach','Glendale',
  'Burbank','Culver City','West Hollywood','El Segundo','Torrance','Hermosa Beach',
  'Manhattan Beach','Sherman Oaks','Encino','Studio City','Van Nuys','Marina Del Rey',
  'Hollywood','North Hollywood','Inglewood','Redondo Beach','Westwood','Brentwood',
  'Century City','Pacific Palisades','Malibu','Calabasas','Woodland Hills','Tarzana',
];

// ─── firm loader ────────────────────────────────────────────────────────────

type Firm = {
  id: number; name: string;
  city: string | null; address: string | null;
  zip: string | null; phone: string | null; website: string | null;
  practice_areas: string[] | null;
  site_intel_title: string | null;
  site_intel_h1: string | null;
  site_intel_meta_desc: string | null;
  site_intel_body: string | null;
  site_intel_practice: string[] | null;
  site_intel_attorneys: string[] | null;
  site_intel_at: Date | null;
  audit_primary_color: string | null;
  audit_palette: { hex: string; fraction: number }[] | null;
};

type SiteIntel = {
  title: string | null;
  h1: string | null;
  metaDesc: string | null;
  body: string | null;        // first ~2KB of cleaned text
  practice: string[];
  attorneys: string[];
};

const SITE_INTEL_TIMEOUT_MS = Number(process.env.SITE_INTEL_TIMEOUT_MS || 12000);
const SITE_INTEL_MAX_AGE_DAYS = Number(process.env.SITE_INTEL_MAX_AGE_DAYS || 30);

// HSL hue from firm.id via golden-ratio scrambling — guarantees ~5000+ distinct hues
// across the queue, with adjacent ids landing on visually distinct colors.
function uniqueAccent(firmId: number): { hex: string; hsl: string } {
  const phi = 0.6180339887;
  const hue = Math.floor(((firmId * phi) % 1) * 360);
  const sat = 65 + ((firmId * 13) % 20);                // 65-84
  const lit = 52 + ((firmId * 7) % 12);                 // 52-63
  return { hex: hslToHex(hue, sat, lit), hsl: `hsl(${hue} ${sat}% ${lit}%)` };
}
function hslToHex(h: number, s: number, l: number): string {
  s /= 100; l /= 100;
  const k = (n: number) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  const toHex = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0');
  return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}

const PRACTICE_AREA_KEYWORDS = [
  'Personal Injury','Family Law','Divorce','Criminal Defense','Estate Planning',
  'Wills','Trusts','Probate','Real Estate','Business Law','Corporate','Employment',
  'Immigration','Bankruptcy','Tax','Intellectual Property','Patent','Trademark',
  'Civil Litigation','Workers Compensation','Medical Malpractice','Wrongful Death',
  'Class Action','Securities','Mergers','Entertainment','Insurance Defense',
  'Construction','Environmental','Elder Law','DUI','Traffic',
];

function extractPracticeAreas(body: string): string[] {
  const found = new Set<string>();
  const lower = body.toLowerCase();
  for (const kw of PRACTICE_AREA_KEYWORDS) {
    if (lower.includes(kw.toLowerCase())) found.add(kw);
    if (found.size >= 6) break;
  }
  return [...found];
}

function extractAttorneys($: ReturnType<typeof load>): string[] {
  const cands = new Set<string>();
  // Look for elements that suggest "Attorney/Partner/Counsel" near a name
  $('h2, h3, h4, .attorney, .lawyer, .partner, .name, .team-member').each((_, el) => {
    const t = $(el).text().trim();
    if (!t) return;
    // 2-4 capitalized words, optional middle initial, optional comma+suffix
    const m = t.match(/^([A-Z][a-z]+(?:\s+[A-Z]\.?)?(?:\s+[A-Z][a-z]+){1,3})(?:,?\s*(?:Esq\.?|JD|J\.D\.|Attorney|Partner))?\b/);
    if (m && m[1].length < 60) cands.add(m[1].trim());
  });
  return [...cands].slice(0, 8);
}

async function fetchSiteIntel(firm: Firm): Promise<SiteIntel | null> {
  if (!firm.website) return null;
  let url: URL;
  try { url = new URL(firm.website); } catch { return null; }
  try {
    const res = await fetchCompliant(url.href, { timeoutMs: SITE_INTEL_TIMEOUT_MS });
    if (!res || res.status >= 400) return null;
    const html = await res.text();
    const $ = load(html);
    const title = ($('title').first().text() || '').trim().slice(0, 200) || null;
    const h1 = ($('h1').first().text() || '').trim().slice(0, 200) || null;
    const metaDesc = ($('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '').trim().slice(0, 300) || null;
    $('script, style, noscript, header nav, footer').remove();
    const body = ($('main').text() || $('body').text() || '')
      .replace(/\s+/g, ' ').trim().slice(0, 2000) || null;
    const practice = body ? extractPracticeAreas(body) : [];
    const attorneys = extractAttorneys($);
    return { title, h1, metaDesc, body, practice, attorneys };
  } catch { return null; }
}

async function ensureSiteIntel(firm: Firm): Promise<Firm> {
  // Skip if cached and fresh
  if (firm.site_intel_at) {
    const ageDays = (Date.now() - firm.site_intel_at.getTime()) / 86400000;
    if (ageDays < SITE_INTEL_MAX_AGE_DAYS) return firm;
  }
  const intel = await fetchSiteIntel(firm);
  if (!intel) {
    // Mark attempted so we don't retry on every variant
    await query(`UPDATE organizations SET site_intel_at = NOW() WHERE id = $1`, [firm.id]);
    return firm;
  }
  await query(`
    UPDATE organizations
       SET site_intel_title=$2, site_intel_h1=$3, site_intel_meta_desc=$4,
           site_intel_body=$5, site_intel_practice=$6, site_intel_attorneys=$7,
           site_intel_at=NOW()
     WHERE id=$1
  `, [firm.id, intel.title, intel.h1, intel.metaDesc, intel.body, intel.practice, intel.attorneys]);
  return {
    ...firm,
    site_intel_title: intel.title,
    site_intel_h1: intel.h1,
    site_intel_meta_desc: intel.metaDesc,
    site_intel_body: intel.body,
    site_intel_practice: intel.practice,
    site_intel_attorneys: intel.attorneys,
    site_intel_at: new Date(),
  };
}

async function loadFirms(): Promise<Firm[]> {
  const where: string[] = [`o.type='law_firm'`, `o.website IS NOT NULL`];
  const params: any[] = [];
  if (LA_COUNTY) {
    params.push(LA_COUNTY_CITIES.map(s => s.toLowerCase()));
    where.push(`LOWER(o.city) = ANY($${params.length}::text[])`);
  } else if (CITY) {
    params.push(CITY.toLowerCase());
    where.push(`(LOWER(o.city) = $${params.length} OR LOWER(o.neighborhood) = $${params.length})`);
  }
  if (ZIPS.length) {
    params.push(ZIPS);
    where.push(`o.zip = ANY($${params.length}::text[])`);
  }
  if (ID_MOD > 0) {
    params.push(ID_MOD); params.push(ID_REM);
    where.push(`(o.id % $${params.length - 1}) = $${params.length}`);
  }
  const lim = LIMIT > 0 ? `LIMIT ${LIMIT}` : '';
  const r = await query<Firm>(`
    SELECT o.id, o.name, o.city, o.address, o.zip, o.phone, o.website,
           o.practice_areas,
           o.site_intel_title, o.site_intel_h1, o.site_intel_meta_desc,
           o.site_intel_body, o.site_intel_practice, o.site_intel_attorneys,
           o.site_intel_at,
           latest_audit.primary_color AS audit_primary_color,
           latest_audit.palette       AS audit_palette
    FROM organizations o
    LEFT JOIN LATERAL (
      SELECT primary_color, palette
      FROM site_audits sa
      WHERE sa.organization_id = o.id
      ORDER BY sa.audited_at DESC
      LIMIT 1
    ) latest_audit ON TRUE
    WHERE ${where.join(' AND ')}
    ORDER BY o.id
    ${lim}
  `, params);
  return r.rows;
}

async function alreadyRendered(firmId: number, variant: string): Promise<boolean> {
  if (RE_RENDER) return false;
  const r = await query<{ id: number }>(
    `SELECT id FROM site_mockups WHERE organization_id = $1 AND variant = $2 LIMIT 1`,
    [firmId, variant]);
  return (r.rowCount || 0) > 0;
}

// ─── LLM bespoke generation ─────────────────────────────────────────────────

const LLM_SYSTEM = `You are a senior web designer. Output ONLY a complete, valid <!doctype html> page — no preamble, no markdown fences, no commentary, just the HTML. The page must be a single self-contained file with all CSS in one <style> tag in the head. No external assets except Google Fonts via <link>.`;

// Each firm gets 3 LLM-generated variants, each with a distinct aesthetic + palette + template.
const AESTHETICS = [
  {
    key: 'x1', label: 'Editorial Modern',
    surface: 'light cream (#faf8f3) with charcoal (#1a1814) text',
    typo: 'serif display (Cormorant Garamond, Playfair, EB Garamond) for headlines, Inter sans for body',
    flavor: 'magazine/editorial — long-form intro paragraphs, generous tracking, all-caps eyebrows, pull quotes, hairline rules between sections',
  },
  {
    key: 'x2', label: 'Bold Confident',
    surface: 'high-contrast white (#ffffff) base with one bold black/accent statement section in middle',
    typo: 'extra-bold geometric sans (Inter 700-900, or Geist Bold) for headlines, Inter 400 for body',
    flavor: 'confident — oversized headline (90-120px), zero ornament, square-bracket tags, button-as-logo, tight grid with thick rule borders',
  },
  {
    key: 'x3', label: 'Web3 Immersive',
    surface: 'deep slate (#0b0d12) with subtle radial-gradient hero glow + glassmorphism header (backdrop-filter blur 12px)',
    typo: 'Inter or Geist throughout, weight 600 headlines, letter-spacing -0.02em',
    flavor: 'modern dark — hover-lift cards, 1px hairline rgba(255,255,255,0.08) borders, gradient-edge CTAs, 8-14px rounded corners, 21st.dev/shadcn-style segmented controls and glass cards',
  },
] as const;
type Aesthetic = typeof AESTHETICS[number];

function pickAccent(firm: Firm, variantIndex: number): string {
  // Prefer the firm's actual site primary_color (from site_audits) on x3 (web3 vibe matches dark sites);
  // otherwise generate a per-firm-per-variant unique HSL from the firm.id.
  if (variantIndex === 2 && firm.audit_primary_color && /^#[0-9a-f]{6}$/i.test(firm.audit_primary_color)) {
    return firm.audit_primary_color;
  }
  return uniqueAccent(firm.id * 7 + variantIndex * 113).hex;
}

function pickTemplateForVariant(firmId: number, variantIndex: number): Template | null {
  if (TEMPLATES.length === 0) return null;
  // Different template for each (firm, variant) so all 3 variants are structurally distinct
  return TEMPLATES[(firmId * 3 + variantIndex * 5) % TEMPLATES.length];
}

function llmPrompt(f: Firm, variantIndex: number, aesthetic: Aesthetic, accent: string): string {
  const city = f.city || 'California';
  const tpl = pickTemplateForVariant(f.id, variantIndex);
  const tplBody = tpl ? loadTemplateBody(tpl) : '';
  const tplBlock = tpl && tplBody
    ? `\n\nSTRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (audit ${tpl.score}/100) LA law firm:\nFirm: ${tpl.name}  Site: ${tpl.website}\n--- TEMPLATE BODY (HTML excerpt — for structure inspiration ONLY, do NOT copy text or branding) ---\n${tplBody}\n--- END TEMPLATE ---\n\nFollow the same number/order of sections, the same visual hierarchy, and the same component density. IGNORE the template's color palette and typography — apply the aesthetic specified below instead.\n`
    : '';

  // Build the real-content block from cached site intel
  const intelLines: string[] = [];
  if (f.site_intel_title) intelLines.push(`- Page title: "${f.site_intel_title}"`);
  if (f.site_intel_h1) intelLines.push(`- H1 on their actual site: "${f.site_intel_h1}"`);
  if (f.site_intel_meta_desc) intelLines.push(`- Their meta description: "${f.site_intel_meta_desc}"`);
  if (f.site_intel_practice && f.site_intel_practice.length) intelLines.push(`- Practice areas mentioned on their site: ${f.site_intel_practice.join(', ')}`);
  if (f.site_intel_attorneys && f.site_intel_attorneys.length) intelLines.push(`- Attorneys named on their site: ${f.site_intel_attorneys.slice(0, 6).join(', ')}`);
  if (f.site_intel_body) intelLines.push(`- About-text excerpt from their site (use this to write copy that sounds like THEM): "${f.site_intel_body.slice(0, 800)}"`);
  const intelBlock = intelLines.length
    ? `\nREAL CONTENT FROM THEIR ACTUAL WEBSITE (use this — do not invent generic copy):\n${intelLines.join('\n')}\n`
    : `\nNo cached site content available — write reasonable, firm-specific copy based on the name "${f.name}" and city "${city}". Do NOT use placeholder text like "Our team of attorneys".\n`;

  const practiceForSeo = (f.site_intel_practice && f.site_intel_practice.length ? f.site_intel_practice : (f.practice_areas || [])).slice(0, 4).join(', ') || 'legal services';

  return `Design a UNIQUE law-firm landing page for: ${f.name}
Variant: ${aesthetic.key} — ${aesthetic.label}
Firm city: ${city}
Phone: ${f.phone || '(310) 555-0100'}
Address: ${f.address || `${city}, CA`}
Accent color (use this EXACT hex everywhere accent is needed): ${accent}
${intelBlock}
AESTHETIC for this variant — match strictly:
- Surface: ${aesthetic.surface}
- Typography: ${aesthetic.typo}
- Visual flavor: ${aesthetic.flavor}
- Use ONLY the accent color ${accent} for highlights — no other vibrant colors. Neutrals/grays/black/white are unlimited.
- 21st.dev / shadcn-influenced component patterns where they fit the aesthetic: pill badges with subtle border, segmented filter controls, ghost-bordered buttons, kbd-style chips for stats, divider lines between rows.
- Hero headline 60-110px (depending on aesthetic), tight tracking
- Generous section padding (80-140px), single-column 768px breakpoint

REQUIRED — these MUST appear in the <head> (proper SEO):
- <title>${f.name} — ${practiceForSeo} | ${city}</title>
- <meta name="description" content="..."> — ONE sentence, 140-160 chars, mentions firm name + city + 1-2 practice areas, written in firm's voice
- <meta property="og:title">, <meta property="og:description">, <meta name="robots" content="index,follow">

Required sections, in this order:
1. Sticky banner (1px accent border-bottom): "MOCKUP — DETAILS PENDING VERIFICATION" — uppercase, 10px, opacity 0.6
2. Header: firm name as text logo, 4 nav links (Practice, Attorneys, Results, Contact), 1 CTA button
3. Hero: headline that uses real site H1 or echoes the firm's actual messaging — NOT generic "Trusted Legal Counsel". Sub-headline 1 sentence. Two CTAs.
4. Trust band: 4 distinctive metrics relevant to THIS firm (years, cases, recovery, reviews) — vary numbers per firm so they're not all "30+ Years"
5. Practice areas: render the practice areas FROM THE REAL SITE (or firm DB) as 4-6 cards with firm-specific descriptions. If only 3 known, render 3 — don't fabricate.
6. Attorneys / People section: if site_intel attorneys are present, list them with placeholder bio lines; else a single bio block for the firm
7. Process / approach: 3 steps written in firm-specific language
8. Contact: phone (large), address, form (name/email/matter/message + accent CTA)
9. Footer: hairline border, copyright + state-bar advertising notice, address

CRITICAL — DO NOT:
- Use placeholder/lorem text or generic "Our team of attorneys is dedicated to..."
- Use a different accent color than ${accent}
- Add inline images, pattern backgrounds, or external assets other than Google Fonts <link>
- Output anything before <!doctype html> or after </html>

Self-contained: ALL CSS in one <style> tag. Mobile-responsive. Validates as HTML5.${tplBlock}

Output the full HTML now, starting with <!doctype html>:`;
}

function stripCodeFences(s: string): string {
  // Some models wrap in ```html ... ```, strip if present
  const fenced = s.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
  if (fenced) return fenced[1].trim();
  return s.trim();
}

async function generateLLMHtml(f: Firm, variantIndex: number, aesthetic: Aesthetic, accent: string, signal: AbortSignal): Promise<{ html: string; ms: number } | null> {
  const t0 = Date.now();
  try {
    const r = await fetch(`${OLLAMA_URL}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal,
      body: JSON.stringify({
        model: LLM_MODEL,
        system: LLM_SYSTEM,
        prompt: llmPrompt(f, variantIndex, aesthetic, accent),
        stream: false,
        // higher temp + per-firm seed → variation across firms.
        // num_ctx=8192 keeps RAM bounded; default would be 128K for gemma3 → OOM on 24GB Macs.
        options: { temperature: 0.7, top_p: 0.92, num_predict: 3500, num_ctx: 8192, seed: f.id * 100 + variantIndex },
      }),
    });
    if (!r.ok) { console.warn(`[llm] http ${r.status} for firm ${f.id}`); return null; }
    const j = await r.json() as { response?: string };
    const raw = (j.response || '').trim();
    if (!raw) return null;
    const html = stripCodeFences(raw);
    if (!html.toLowerCase().includes('<!doctype') && !html.toLowerCase().includes('<html')) {
      console.warn(`[llm] firm ${f.id} no <html> in output (${html.length} chars)`);
      return null;
    }
    return { html, ms: Date.now() - t0 };
  } catch (e) {
    if ((e as any).name === 'AbortError') return null;
    console.warn(`[llm] err firm ${f.id}: ${(e as Error).message}`);
    return null;
  }
}

// ─── render an HTML string to a PNG via Playwright ──────────────────────────

async function snapshotHtml(browser: Browser, html: string, outPng: string, opts: { fullPage?: boolean } = {}): Promise<void> {
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 1700 } });
  const page = await ctx.newPage();
  try {
    await page.setContent(html, { waitUntil: 'load', timeout: 20000 });
    await page.waitForTimeout(800); // settle fonts
    await page.screenshot({ path: outPng, type: 'png', fullPage: opts.fullPage ?? false });
  } finally {
    await ctx.close().catch(() => {});
  }
}

// ─── persist a single mockup ────────────────────────────────────────────────

async function persistMockup(firmId: number, variant: string, label: string, htmlPath: string, pngPath: string, model: string | null, ms: number | null): Promise<void> {
  await query(`
    INSERT INTO site_mockups (organization_id, variant, template_label, screenshot_path, html_path, llm_model, gen_ms)
    VALUES ($1, $2, $3, $4, $5, $6, $7)
    ON CONFLICT (organization_id, variant) DO UPDATE SET
      template_label  = EXCLUDED.template_label,
      screenshot_path = EXCLUDED.screenshot_path,
      html_path       = EXCLUDED.html_path,
      llm_model       = EXCLUDED.llm_model,
      gen_ms          = EXCLUDED.gen_ms,
      generated_at    = NOW()
  `, [firmId, variant, label, pngPath, htmlPath, model, ms]);
}

// ─── render one firm — all 4 variants ───────────────────────────────────────

async function renderFirm(browser: Browser, firmIn: Firm): Promise<{ done: number; skipped: number; errs: number }> {
  const out = { done: 0, skipped: 0, errs: 0 };

  // Fetch + cache real-site intel ONCE per firm (used by all 3 variants)
  const firm = await ensureSiteIntel(firmIn);

  for (let i = 0; i < AESTHETICS.length; i++) {
    const aesthetic = AESTHETICS[i];
    const variantKey = aesthetic.key;
    if (await alreadyRendered(firm.id, variantKey)) { out.skipped++; continue; }
    if (SKIP_LLM) { out.skipped++; continue; }

    const accent = pickAccent(firm, i);
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
    try {
      const r = await generateLLMHtml(firm, i, aesthetic, accent, ac.signal);
      if (r) {
        const htmlPath = path.join(MOCKUP_DIR, `firm-${firm.id}-${variantKey}.html`);
        const pngPath  = path.join(MOCKUP_DIR, `firm-${firm.id}-${variantKey}.png`);
        fs.writeFileSync(htmlPath, r.html);
        await snapshotHtml(browser, r.html, pngPath);
        await persistMockup(
          firm.id, variantKey,
          `${aesthetic.label} (${LLM_MODEL}, ${accent})`,
          `/mockups/firm-${firm.id}-${variantKey}.html`,
          `/mockups/firm-${firm.id}-${variantKey}.png`,
          LLM_MODEL, r.ms,
        );
        out.done++;
      } else {
        out.errs++;
      }
    } catch (e) {
      out.errs++;
      console.error(`[mockup] firm ${firm.id} variant ${variantKey} err: ${(e as Error).message}`);
    } finally {
      clearTimeout(timer);
    }
  }
  return out;
}

// ─── main ──────────────────────────────────────────────────────────────────

async function main() {
  fs.mkdirSync(MOCKUP_DIR, { recursive: true });
  const firms = await loadFirms();
  if (firms.length === 0) { console.log('[mockup] no firms match'); await pool.end(); return; }

  const scope = LA_COUNTY ? 'LA County' : (CITY ? `city=${CITY}` : (ZIPS.length ? `zips=${ZIPS.join(',')}` : 'all'));
  console.log(`[mockup] rendering ${firms.length} firms (scope: ${scope})  llm=${SKIP_LLM ? 'OFF' : LLM_MODEL}  re-render=${RE_RENDER}  → ${MOCKUP_DIR}`);

  const browser = await chromium.launch({ headless: true });
  let done = 0, skipped = 0, errs = 0;
  try {
    for (let i = 0; i < firms.length; i++) {
      const firm = firms[i];
      const t0 = Date.now();
      const r = await renderFirm(browser, firm);
      done += r.done; skipped += r.skipped; errs += r.errs;
      const dt = ((Date.now() - t0) / 1000).toFixed(1);
      console.log(`[mockup] ${i + 1}/${firms.length}  #${firm.id}  ${firm.name.slice(0, 38).padEnd(38)} done=${r.done} skip=${r.skipped} err=${r.errs}  ${dt}s`);
    }
  } finally {
    await browser.close();
  }
  console.log(`[mockup] complete. done=${done} skipped=${skipped} errs=${errs}`);
  await pool.end();
}

main().catch(async err => {
  console.error('[mockup] fatal:', err);
  try { await pool.end(); } catch {}
  process.exit(1);
});