← back to Professional Directory
scripts/generate-mockups-v4.js
318 lines
#!/usr/bin/env node
/**
* V4 mockup generator — quality-first.
*
* Differences from v3:
* - Model: qwen3:32b on MS1 (much stronger design + copy than gemma3:12b)
* - Prompt: concrete editorial detail asks (drop caps, asymmetric grids,
* photo gradient art, typographic ornaments, real services per practice
* type, brand-voice constraints, section-transition specifics)
* - Per-variant aesthetic philosophy preamble (vs. just style notes)
* - Demands NO em-dash, NO "elevate", NO "premium-quality" — anti-LLM tells
* - Uniqueness pool unchanged (1,120 cells)
*
* Run:
* ORG_ID=74415 node scripts/generate-mockups-v4.js # one org × all 3 variants
* LIMIT=5 node scripts/generate-mockups-v4.js # top-5 orgs × 3 variants
* FORCE=1 ... # overwrite existing
* MODEL_HOST=http://192.168.1.133:11434 ... # already default to MS1
*/
const fs = require('fs');
const path = require('path');
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 5;
const ORG_ID = process.env.ORG_ID ? Number(process.env.ORG_ID) : null;
const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
const FORCE = process.env.FORCE === '1';
const OLLAMA = process.env.MODEL_HOST || 'http://192.168.1.133:11434'; // MS1 default
const MODEL = process.env.LLM_MOCKUP_MODEL || 'qwen3:32b';
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 720_000); // 12 min/variant
const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
const OUT_DIR = path.resolve(__dirname, '../data/mockups');
fs.mkdirSync(OUT_DIR, { recursive: true });
const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
// ─── Aesthetic dimensions ─────────────────────────────────────────────────
const PALETTES = [
{ id: 'cream-moss-gold', bg:'#fbf7f0', ink:'#1c1f1c', accent:'#28412f', accent2:'#b48a3a', soul:'editorial cream paper, deep mossy green, restrained gold' },
{ id: 'noir-violet', bg:'#0a0a0c', ink:'#f4f1ea', accent:'#7c3aed', accent2:'#fbbf24', soul:'midnight noir, electric violet, single warm gold rim' },
{ id: 'rose-terracotta', bg:'#fff5ed', ink:'#291919', accent:'#c97e74', accent2:'#a08458', soul:'soft rose, dusty terracotta, hand-mixed boutique' },
{ id: 'midnight-mint', bg:'#0e1726', ink:'#e2e8f0', accent:'#10b981', accent2:'#34d399', soul:'midnight slate, signal mint, softly glowing' },
{ id: 'paper-cobalt', bg:'#f5f0e8', ink:'#1f2937', accent:'#0369a1', accent2:'#7dd3fc', soul:'aged paper, library cobalt ink, archival' },
{ id: 'amber-night', bg:'#1a0f0a', ink:'#fde68a', accent:'#f59e0b', accent2:'#dc2626', soul:'cinnamon amber, candle warm, deep red glow' },
{ id: 'aqua-cool', bg:'#ecfeff', ink:'#164e63', accent:'#0891b2', accent2:'#22d3ee', soul:'sea-glass aqua, calm cool clinical' },
];
const TYPE_PAIRS = [
{ id: 'serif-sans-classic', head: 'Cormorant Garamond', body: 'Inter', ornament: 'old-style figures + small caps' },
{ id: 'magazine-modern', head: 'Playfair Display', body: 'Inter', ornament: 'editorial sub-heads in italic' },
{ id: 'editorial-fresh', head: 'Fraunces', body: 'DM Sans', ornament: 'soft optical sizes, tracked-out caps' },
{ id: 'literary-clean', head: 'Lora', body: 'Manrope', ornament: 'wide-tracked all-caps eyebrows' },
{ id: 'considered-tech', head: 'Spectral', body: 'Plus Jakarta Sans', ornament: 'monospaced metadata footnotes' },
];
const VOICES = [
{ id: 'warm-trustworthy', description: 'Calm. Family-doctor warmth. Speaks plainly, never corporate. Phrases: "we listen first", "your time matters", "for the long haul".' },
{ id: 'clinical-precise', description: 'Confident, evidence-forward. No fluff. Phrases: "evidence-based", "outcomes you can verify", "transparent pricing".' },
{ id: 'boutique-confident', description: 'Quiet luxury. Hand-picked, considered. Phrases: "small enough to know your name", "carefully curated", "by appointment".' },
{ id: 'neighborhood-friendly', description: 'LA-local, casual but professional. Phrases: "your block", "we live here too", "drop in any morning".' },
];
// Variant philosophy — each variant has a DIFFERENT design POV, not just style notes.
const VARIANT_PHILOSOPHY = {
a: {
label: 'Architectural Editorial',
pov: `An award-winning architecture monograph, but for a healthcare practice.
The design is restrained: deep negative space, justified text columns, asymmetric
two-column grids that feel hand-set. The hero is large quiet typography over a
single duotone gradient (no photo). Section headers are in small caps with a
3px hairline rule beneath. Body copy uses a drop-cap on the first paragraph
of each section. Numbers are old-style. The page reads like printed matter.
NO oversized buttons; CTAs are quiet text-with-underline that grows on hover.`,
structural_constraints: [
'Hero: split 60/40 grid — left = headline (clamp 56-92px) + 1-line subhead + tiny pair of buttons; right = a single duotone gradient block (palette accent → palette ink) with a 1px hairline border, NO photo placeholder labels',
'Section titles in small-caps tracking 0.18em + 1px rule beneath',
'First paragraph of each section: drop cap, 4-line span, in headline serif',
'Asymmetric services grid: 2-1-2 pattern (NOT uniform 3-col)',
'Footnote-style metadata under hero: est. year · specialty count · neighborhood',
'Numbers throughout use old-style figures (font-feature-settings: "onum")',
],
},
b: {
label: 'Glass Modernist',
pov: `A 2025 product launch page — dark surface, single vibrant accent,
geometric clarity. Glassmorphism on header (backdrop-filter: blur 12px). Hero
has a subtle radial-gradient glow behind the headline. Cards have a 1px
rgba(255,255,255,0.08) border and lift 2px on hover. NO drop shadows over
0 6px 24px rgba(0,0,0,0.3). NO neon glows. ONE accent color used sparingly
across CTAs, eyebrows, and active states. Typography is geometric sans-serif,
tight (-0.02em) at large sizes. Trust band: 4 metrics in horizontal row,
big numbers in accent color, small uppercase labels below.`,
structural_constraints: [
'Hero: full-width with radial-gradient glow at 30% 0% (palette accent at 12% opacity)',
'h1: 64-80px, weight 600, line-height 1.05, letter-spacing -0.02em',
'Glass header: backdrop-filter: blur(12px), background rgba(palette-bg, 0.7), 1px bottom border at rgba(255,255,255,0.08)',
'CTA: filled accent button + ghost ghost-with-1px-border button',
'Services 4-col grid; cards rounded-12, 1px border at rgba(255,255,255,0.08), hover translateY(-2px)',
'Process: 3 horizontal steps with thin connector lines, numbered 01/02/03 in accent color',
],
},
c: {
label: 'Boutique Hand-set',
pov: `A small batch coffee roaster site, but for healthcare. Warm,
photo-forward, hand-curated. The hero is photo-dominant (large gradient
DIV simulating a candid practice photo, with a soft inner shadow). The
headline overlays the photo with a script-display word followed by a
display-serif phrase. Asymmetric grid throughout. Hand-drawn SVG dividers
(simple ink stroke paths) between sections. Body copy is set in a slightly
larger, looser type size with longer line-heights. Pull-quotes from
testimonials with large quotation marks. Footer is a personal sign-off.`,
structural_constraints: [
'Hero: photo-led full-bleed gradient 16:10 with inner shadow, headline overlays at bottom-left with: <span class="script">Welcome to</span> <h1>...</h1>',
'Script font on first 2-3 words: Pinyon Script or Allura via Google Fonts',
'h1 uses display serif at 56-72px',
'Hand-drawn SVG dividers between sections (inline SVG, simple stroke path)',
'Asymmetric services grid: 1 large card on left + 2 stacked cards on right',
'Pull-quote block with 200px-tall quotation mark in palette accent2',
'Footer: handwritten-style sign-off line ("— the team") in script font',
],
},
};
const ANTI_TELLS = [
'NO em-dashes (use commas, periods, or colons instead)',
'NO "elevate", NO "premium-quality", NO "unparalleled"',
'NO "our team of dedicated professionals"',
'NO "we are committed to providing"',
'NO "passionate about" anything',
'NO emoji in body copy',
'Trust band metrics MUST be specific and plausible (e.g. "7 years on this block", "2,400+ patients served", "94% would refer a friend"). NOT round numbers like 1000+ or 10000+.',
];
function pickCombo(orgId, variant) {
const variantOffset = variant.charCodeAt(0) - 97;
const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
return {
template: TEMPLATES[seed % TEMPLATES.length],
palette: PALETTES[(seed >>> 3) % PALETTES.length],
typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
voice: VOICES[(seed >>> 11) % VOICES.length],
};
}
function chatWidget(orgId, palette) {
const acc = palette.accent;
return `
<!-- drvouch ai-chat -->
<style>
#vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
#vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
#vw-chat-panel.open{display:flex}
#vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
#vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
#vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
#vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
#vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
#vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
#vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
#vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
#vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
</style>
<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
<div id="vw-chat-panel" role="dialog"><div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div><div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, or location.</div></div><form id="vw-chat-form" onsubmit="return vwChatSend(event)"><input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off"/><button id="vw-chat-send" type="submit">Send</button></form></div>
<script>(function(){const ORG_ID=${orgId};const API=(window.PD_API_BASE||'')+'/api/chat';const log=()=>document.getElementById('vw-chat-log');const history=[];function add(t,who){const d=document.createElement('div');d.className='vw-msg vw-'+who;d.textContent=t;log().appendChild(d);log().scrollTop=log().scrollHeight;return d}window.vwChatSend=async function(ev){ev.preventDefault();const i=document.getElementById('vw-chat-input');const m=i.value.trim();if(!m)return false;add(m,'me');i.value='';history.push({role:'user',content:m});const t=add('typing…','bot');t.style.opacity='.7';try{const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({orgId:ORG_ID,message:m,history:history.slice(-6)})});const j=await r.json();t.style.opacity='1';t.textContent=j.reply||'(error: '+(j.error||'no response')+')';if(j.reply)history.push({role:'assistant',content:j.reply})}catch(e){t.style.opacity='1';t.textContent='(network error)'}return false};})();</script>`;
}
const SYSTEM_PROMPT = `You are an award-winning web designer + healthcare brand strategist. You output ONLY a complete <!doctype html> page — no markdown fences, no preamble, no commentary, no chain-of-thought. All CSS in ONE <style> tag in <head>. External assets only via Google Fonts <link>. Include thoughtful SEO meta + a JSON-LD <script type="application/ld+json"> block matching the practice type.
Your work is judged on:
1. Real editorial restraint — not LLM-bland "elevate / premium-quality" filler.
2. Concrete typographic detail — drop caps, small caps, optical sizes, asymmetric grids, ornaments.
3. Voice in copy — every word feels written by a human who knows this practice.
4. Trust-band metrics that are SPECIFIC and PLAUSIBLE (not "10,000+ happy patients").
5. Working semantic HTML5 — header/nav/main/section/article/footer.`;
function buildPrompt(o, variant, combo) {
const v = VARIANT_PHILOSOPHY[variant];
const kind = (o.type || 'practice').replace(/_/g, ' ');
const tplBody = (() => {
try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 5000); }
catch { return ''; }
})();
return `Design a high-craft, mobile-friendly landing page for: ${o.name} (a ${kind} in ${o.city || 'Los Angeles'}).
Phone: ${o.phone || '(310) 555-0100'}
Address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
VARIANT ${variant.toUpperCase()}: ${v.label}
PHILOSOPHY:
${v.pov}
STRUCTURAL CONSTRAINTS (must be visible in output):
${v.structural_constraints.map((c, i) => ` ${i + 1}. ${c}`).join('\n')}
PALETTE (${combo.palette.id} — ${combo.palette.soul}):
- background: ${combo.palette.bg}
- ink: ${combo.palette.ink}
- accent: ${combo.palette.accent}
- accent2: ${combo.palette.accent2}
TYPOGRAPHY (${combo.typePair.id}):
- Headlines: '${combo.typePair.head}' (load via Google Fonts <link>)
- Body: '${combo.typePair.body}'
- Ornament: ${combo.typePair.ornament}
VOICE (${combo.voice.id}):
${combo.voice.description}
ANTI-TELLS — these are LLM giveaways. AVOID:
${ANTI_TELLS.map(t => '- ' + t).join('\n')}
STRUCTURAL TEMPLATE (top-rated ${combo.template.score}/100 site, for SECTION ORDERING + density inspiration ONLY — IGNORE its colors, fonts, copy):
Reference: ${combo.template.name} (${combo.template.site})
--- TEMPLATE EXCERPT ---
${tplBody}
--- END ---
REQUIRED SECTIONS in order:
1. Sticky header (logo wordmark + 3-4 nav links + accent phone CTA)
2. Hero (per variant philosophy)
3. Trust band (4 SPECIFIC metrics — see anti-tells)
4. Services / specialties (per variant grid pattern)
5. Meet the team (3-4 placeholder cards with role + city, no fake names)
6. Visit / location (split: address + phone + hours / map placeholder)
7. FAQ (4 honest Q&As — insurance, scheduling, services, telehealth)
8. Footer (per variant)
SEO:
- <title> "${o.name} — ${kind} in ${o.city || 'LA'}, CA"
- <meta name="description"> 145-160 chars
- Open Graph + Twitter card tags
- JSON-LD with @type matching ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital/HealthAndBeautyBusiness/etc), name, address, telephone, openingHoursSpecification
Self-contained: ALL CSS in one <style> tag. Mobile breakpoint at 768px. Must validate as HTML5.
Output the complete page now, starting with <!doctype html>:`;
}
function stripFences(s) {
const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
return (m ? m[1] : s).trim();
}
function injectChatWidget(html, orgId, palette) {
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, chatWidget(orgId, palette) + '</body>');
return html + chatWidget(orgId, palette);
}
async function generateOne(o, variant, combo) {
const t0 = Date.now();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
try {
const r = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
model: MODEL, system: SYSTEM_PROMPT, prompt: buildPrompt(o, variant, combo),
stream: false,
options: { temperature: 0.62, top_p: 0.92, num_predict: 9000, num_ctx: 8192 },
}),
});
clearTimeout(timer);
if (!r.ok) { console.warn(`[v4] HTTP ${r.status}`); return null; }
const j = await r.json();
const raw = (j.response || '').trim();
if (!raw) return null;
let html = stripFences(raw);
// Strip qwen-style <think> blocks if present.
html = html.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
if (!/<!doctype|<html/i.test(html)) { console.warn(`[v4] no <html> in output (${html.length}b)`); return null; }
html = injectChatWidget(html, o.id, combo.palette);
return { html, ms: Date.now() - t0 };
} catch (e) {
clearTimeout(timer);
if (e.name === 'AbortError') console.warn(`[v4] timed out after ${LLM_TIMEOUT_MS}ms`);
else console.warn(`[v4] err: ${e.message}`);
return null;
}
}
async function main() {
let rows;
if (ORG_ID) {
rows = (await query(`SELECT id, name, type, address, city, state, zip, phone FROM organizations WHERE id = $1`, [ORG_ID])).rows;
} else {
rows = (await query(`
SELECT id, name, type, address, city, state, zip, phone
FROM organizations
WHERE opted_out = false AND lat IS NOT NULL
AND claim_status = 'unclaimed'
AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
ORDER BY lead_score DESC NULLS LAST, id
LIMIT $1`, [LIMIT])).rows;
}
console.log(`[v4] processing ${rows.length} orgs × ${VARIANTS.length} variants on ${OLLAMA} model=${MODEL}`);
let ok = 0, skip = 0, fail = 0;
for (const o of rows) {
for (const v of VARIANTS) {
const out = path.join(OUT_DIR, `${o.id}-${v}-v4.html`);
if (fs.existsSync(out) && !FORCE) { skip++; continue; }
const combo = pickCombo(o.id, v);
const tag = `${combo.template.id}/${combo.palette.id}/${combo.typePair.id}/${combo.voice.id}`;
process.stdout.write(`[v4] ${String(o.id).padEnd(7)} ${o.name.slice(0, 28).padEnd(28)} ${v}=${tag.padEnd(60)} ... `);
const result = await generateOne(o, v, combo);
if (!result) { console.log('FAIL'); fail++; continue; }
fs.writeFileSync(out, result.html);
console.log(`${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb`);
ok++;
}
}
console.log(`[v4] done. ok=${ok} skip=${skip} fail=${fail}`);
await pool.end();
}
main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });