← back to Small Business Builder
src/render/website-analysis-themes.js
582 lines
// website-analysis themes — 3 starter front-page concepts shown to the user
// as "see your site as a new website" cards.
//
// Each theme is a single-file HTML string, inline CSS, Google-Fonts CDN OK,
// no JS frameworks, no external images (uses CSS gradients + the user's own
// hero_image when present).
//
// Inputs (ctx):
// title string — site title or business name
// tagline string — short description / og:description
// palette string[] — extracted hex colors, longest hits first
// hero_image string|null
// vertical string — salon | restaurant | lawyer | vet | advocacy | ecom | saas-landing | generic
// ig_handle string|null — '@thing' or 'thing'
// url string — original site URL
import { esc } from '../lib/escape.js';
function clean(s, max = 240) {
if (!s) return '';
return String(s).replace(/\s+/g, ' ').trim().slice(0, max);
}
// Pull a vertical-aware CTA pair, preferring qwen3-enriched copy_pack values.
function ctas(ctx) {
return {
primary: ctx.cta_primary || 'Get in touch',
secondary: ctx.cta_secondary || 'Learn more',
};
}
// Pick 3 cards from copy_pack if present, else nothing — caller falls back.
function packCards(ctx) {
if (Array.isArray(ctx.cards) && ctx.cards.length >= 3) {
return ctx.cards.slice(0, 3).map(c => ({
title: clean(c.title || '', 32).toUpperCase(),
body: clean(c.body || '', 200),
}));
}
return null;
}
function igUrl(handle) {
if (!handle) return null;
const h = handle.replace(/^@/, '').trim();
return h ? `https://instagram.com/${encodeURIComponent(h)}` : null;
}
function pickColors(palette = [], fallback = ['#111111', '#facc15']) {
const p = palette.filter(c => /^#[0-9a-f]{6}$/i.test(c));
return [p[0] || fallback[0], p[1] || fallback[1], p[2] || '#ffffff'];
}
// Deterministic hash → number-in-range. Keeps the same project always rendering
// the same SVG texture across reloads.
function hashToInt(s, max) {
let h = 5381;
for (let i = 0; i < s.length; i += 1) h = (h * 33 + s.charCodeAt(i)) >>> 0;
return h % max;
}
// Inline-SVG hero "art" so every mockup has visual identity even when there's
// no real hero_image. Picks one of N patterns hashed off the project title +
// tints them with the project's palette. Returns a `data:image/svg+xml,...`
// usable as a CSS background.
function svgHero(ctx, [c1, c2]) {
const seed = ctx.title || ctx.url || 'x';
const variant = hashToInt(seed, 6);
const a = encodeURIComponent(c1);
const b = encodeURIComponent(c2);
const svgs = [
// 0 — concentric circles
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='none' stroke='${b}' stroke-width='2' opacity='.5'>${[...Array(12)].map((_,i)=>`<circle cx='300' cy='400' r='${30+i*32}'/>`).join('')}</g></svg>`,
// 1 — diagonal stripes
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='10' opacity='.6'>${[...Array(20)].map((_,i)=>`<line x1='${-200+i*60}' y1='0' x2='${200+i*60}' y2='800'/>`).join('')}</g></svg>`,
// 2 — grid of dots
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='${b}'>${[...Array(10)].flatMap((_,r)=>[...Array(8)].map((__,c)=>`<circle cx='${30+c*75}' cy='${30+r*82}' r='8'/>`)).join('')}</g></svg>`,
// 3 — zigzag
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?100:50}`).join(' ')}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?500:450}`).join(' ')}'/></svg>`,
// 4 — overlapping rectangles
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><rect x='80' y='60' width='320' height='400' fill='${b}' opacity='.6'/><rect x='200' y='220' width='320' height='480' fill='${b}' opacity='.4'/><rect x='40' y='400' width='280' height='320' fill='${b}' opacity='.7'/></svg>`,
// 5 — sun rays
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='4' opacity='.55'>${[...Array(36)].map((_,i)=>`<line x1='300' y1='400' x2='${300+Math.cos(i/36*Math.PI*2)*600}' y2='${400+Math.sin(i/36*Math.PI*2)*600}'/>`).join('')}</g><circle cx='300' cy='400' r='80' fill='${b}'/></svg>`,
];
const svg = svgs[variant].replace(/'/g, '"');
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
}
// ---------------------------------------------------------------------------
// THEME 1 · EDITORIAL
// Magazine-grade serif hero, generous whitespace, hairline rules, subtle warmth.
// ---------------------------------------------------------------------------
function renderEditorial(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#1a1a1a', '#b8956a']);
const T = clean(ctx.title, 80) || 'Untitled';
const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
const ig = igUrl(ctx.ig_handle);
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, [c1, c2])} center/cover, ${c1}11`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Editorial</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#fafaf6;color:${c1};line-height:1.55}
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
header{padding:32px 0;border-bottom:1px solid ${c1}22;display:flex;justify-content:space-between;align-items:center}
.lg{font-family:'Cormorant Garamond',serif;font-size:28px;letter-spacing:.02em}
nav a{margin-left:28px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:${c1};text-decoration:none}
.hero{padding:80px 0 64px;display:grid;grid-template-columns:1.4fr 1fr;gap:64px;align-items:end}
.hero h1{font-family:'Cormorant Garamond',serif;font-weight:500;font-size:88px;line-height:.95;letter-spacing:-.02em;margin-bottom:24px}
.hero h1 em{color:${c2};font-style:italic}
.hero p{font-size:19px;color:${c1}cc;max-width:42ch}
.hero-img{aspect-ratio:4/5;background:${heroBg};border-radius:2px}
.cta{display:inline-block;margin-top:28px;padding:14px 28px;border:1px solid ${c1};color:${c1};text-decoration:none;font-size:13px;letter-spacing:.12em;text-transform:uppercase}
.cta:hover{background:${c1};color:#fff}
.feat{padding:80px 0;border-top:1px solid ${c1}22;display:grid;grid-template-columns:repeat(3,1fr);gap:48px}
.f h3{font-family:'Cormorant Garamond',serif;font-size:28px;font-weight:500;margin-bottom:12px}
.f p{font-size:15px;color:${c1}aa}
footer{margin-top:64px;padding:32px 0;border-top:1px solid ${c1}22;font-size:13px;color:${c1}88;display:flex;justify-content:space-between}
</style></head><body>
<header><div class="wrap" style="display:flex;justify-content:space-between;width:100%;align-items:center">
<div class="lg">${esc(T)}</div>
<nav><a>About</a><a>Services</a><a>Journal</a><a>Contact</a>${ig ? `<a href="${ig}">Instagram</a>` : ''}</nav>
</div></header>
<main class="wrap">
<section class="hero">
<div>
<h1>${esc(T)} <em>—</em><br>quietly considered.</h1>
<p>${esc(sub)}</p>
<a class="cta" href="#">${esc(ctas(ctx).primary)} →</a>
</div>
<div class="hero-img"></div>
</section>
<section class="feat">
${(() => { const ec = packCards(ctx) || [{title:'Considered',body:'Every detail chosen, never templated. Materials, tone, light — held to the same bar.'},{title:'Familiar',body:'Repeat clients are the entire business. We learn the way you like things and keep them that way.'},{title:'Available',body:'Same-week appointments, calm rooms, and a real person who answers the phone.'}]; return ec.map(c=>`<div class="f"><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n '); })()}
</section>
</main>
<footer><div class="wrap" style="display:flex;justify-content:space-between;width:100%">
<span>© ${new Date().getFullYear()} ${esc(T)}</span>
<span>${ig ? `<a href="${ig}" style="color:inherit">${esc(ctx.ig_handle)}</a>` : ''}</span>
</div></footer>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 2 · BRUTALIST
// Black/yellow blocks, hard borders, Archivo Black + Space Mono, tape-marquee.
// Mirrors sdccanalysis 09-brutalist mockup.
// ---------------------------------------------------------------------------
function renderBrutalist(ctx) {
const T = (clean(ctx.title, 60) || 'Your business').toUpperCase();
const sub = clean(ctx.tagline, 200) || 'A new way to see your business online.';
const ig = igUrl(ctx.ig_handle);
// Build 3 brutalist cards from the actual description sentences instead of
// the static "NO TEMPLATE / NO FLUFF / NO BS" filler. If the project's
// tagline doesn't have 3 sentences, fall back to vertical-tinted defaults.
const VERT_DEFAULTS = {
salon: ['EXPERT', 'TIMELESS', 'YOURS'],
restaurant: ['SEASONAL', 'WALK-IN', 'FAMILY'],
lawyer: ['DIRECT', 'PROVEN', 'CALL US'],
vet: ['GENTLE', 'RAPID', 'TRUSTED'],
advocacy: ['URGENT', 'FUNDED', 'NOW'],
'saas-landing': ['FAST', 'BUILT', 'OPEN'],
ecom: ['CRAFTED', 'STOCKED', 'SHIPPED'],
generic: ['NO TEMPLATE', 'NO FLUFF', 'NO BS'],
};
// ★ Prefer qwen3-enriched copy_pack cards when present.
const enriched = packCards(ctx);
const cardWords = enriched ? enriched.map(c => c.title) : (VERT_DEFAULTS[ctx.vertical] || VERT_DEFAULTS.generic);
const sentences = (ctx.tagline || '').split(/[.!?]+/).map(s => s.trim()).filter(s => s.length >= 16).slice(0, 3);
const cardBodies = enriched
? enriched.map(c => c.body)
: [0, 1, 2].map(i => {
const sn = sentences[i];
if (sn) return clean(sn, 140);
return ['Hand-typeset. Hand-coded. Every block earned its place.',
'Real prices, real hours, real photos. No stock-photo theater.',
'If we say we\'ll call back, we call back. That\'s the whole pitch.'][i];
});
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Brutalist</title>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.3}
.tape{background:#facc15;border-bottom:4px solid #000;padding:8px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:.04em}
.tape span{display:inline-block;animation:scroll 40s linear infinite}
.tape span::after{content:' ★ ${esc(T)} ★ ${esc(ctx.url)} ★ ${ig ? `${esc(ctx.ig_handle)} ★ ` : ''} '}
@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
.hd{background:#000;color:#fff;border-bottom:4px solid #000}
.hd-inner{max-width:1320px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between}
.lg{font-family:'Archivo Black',sans-serif;font-size:32px;letter-spacing:-.04em;text-transform:uppercase}
.lg::after{content:'.';color:#facc15}
.nv{display:flex;font-size:12px;font-weight:700;text-transform:uppercase}
.nv a{padding:8px 16px;border:1px solid #fff;margin-right:-1px;color:#fff;text-decoration:none}
.nv a.active{background:#facc15;color:#000}
.hero{padding:64px 24px;border-bottom:4px solid #000}
.hero-inner{max-width:1320px;margin:0 auto;display:grid;grid-template-columns:1.1fr 1fr;gap:32px;align-items:center}
.hero h1{font-family:'Archivo Black',sans-serif;font-size:120px;line-height:.85;letter-spacing:-.05em;text-transform:uppercase}
.hero h1 mark{background:#facc15;padding:0 8px}
.hero-side{border-left:4px solid #000;padding-left:32px}
.hero-side p{font-size:17px;line-height:1.4;font-weight:500;margin-bottom:14px}
.cta{display:inline-block;background:#000;color:#fff;padding:18px 32px;font-family:'Archivo Black',sans-serif;font-size:18px;letter-spacing:.04em;text-transform:uppercase;border:4px solid #000;text-decoration:none}
.cta:hover{background:#facc15;color:#000}
.cards{padding:48px 24px;border-bottom:4px solid #000}
.cards-inner{max-width:1320px;margin:0 auto}
.cards-grid{display:grid;grid-template-columns:repeat(3,1fr);border:4px solid #000}
.cd{padding:28px;border-right:4px solid #000}
.cd:last-child{border-right:0}
.cd:nth-child(even){background:#fef3c7}
.cd-tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:#000;color:#fff;padding:3px 10px;margin-bottom:14px}
.cd h3{font-family:'Archivo Black',sans-serif;font-size:26px;line-height:1;text-transform:uppercase;margin-bottom:14px}
.ft{background:#000;color:#fff;padding:32px 24px;text-align:center;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
</style></head><body>
<div class="tape"><span></span></div>
<header class="hd"><div class="hd-inner">
<div class="lg">${esc(T.split(' ')[0])}</div>
<nav class="nv"><a class="active">HOME</a><a>WORK</a><a>STORE</a><a>ABOUT</a>${ig ? `<a href="${ig}">IG</a>` : ''}</nav>
</div></header>
<section class="hero"><div class="hero-inner">
<h1>${esc(T.split(' ').slice(0,2).join(' '))}<br><mark>BOLD.</mark></h1>
<div class="hero-side">
<p>${esc(sub)}</p>
<a class="cta" href="#">${esc(ctas(ctx).primary.toUpperCase())} →</a>
</div>
</div></section>
<section class="cards"><div class="cards-inner">
<div class="cards-grid">
<div class="cd"><span class="cd-tag">★ 01</span><h3>${esc(cardWords[0])}</h3><p>${esc(cardBodies[0])}</p></div>
<div class="cd"><span class="cd-tag">★ 02</span><h3>${esc(cardWords[1])}</h3><p>${esc(cardBodies[1])}</p></div>
<div class="cd"><span class="cd-tag">★ 03</span><h3>${esc(cardWords[2])}</h3><p>${esc(cardBodies[2])}</p></div>
</div>
</div></section>
<footer class="ft">© ${new Date().getFullYear()} ${esc(T)} ★ ${esc(ctx.url)}</footer>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 3 · MINIMALIST
// Single column, lots of whitespace, system font, off-white. Squarespace-killer.
// ---------------------------------------------------------------------------
function renderMinimalist(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#0f0f0f', '#6b6b6b']);
const T = clean(ctx.title, 80) || 'Untitled';
const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
const ig = igUrl(ctx.ig_handle);
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, ['#f5f5f5', c2])} center/cover`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Minimalist</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#fefefe;color:${c1};line-height:1.6;font-weight:300}
.wrap{max-width:760px;margin:0 auto;padding:0 24px}
header{padding:48px 0 32px;display:flex;justify-content:space-between;align-items:center;font-size:14px;letter-spacing:.02em}
.lg{font-weight:500}
nav a{margin-left:28px;color:${c2};text-decoration:none}
nav a:hover{color:${c1}}
.hero{padding:96px 0 80px;text-align:center}
.hero-img{width:100%;aspect-ratio:16/9;background:${heroBg};margin-bottom:64px;border-radius:6px}
.hero h1{font-size:56px;font-weight:300;letter-spacing:-.03em;line-height:1.05;margin-bottom:24px}
.hero p{font-size:19px;color:${c2};max-width:52ch;margin:0 auto}
.cta{display:inline-block;margin-top:40px;padding:14px 36px;background:${c1};color:#fff;text-decoration:none;border-radius:99px;font-size:14px;font-weight:500}
.feat{padding:80px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:48px;border-top:1px solid #eee}
.f{font-size:15px;color:${c2}}
.f h3{font-size:18px;font-weight:500;color:${c1};margin-bottom:8px}
footer{padding:48px 0;border-top:1px solid #eee;font-size:13px;color:${c2};text-align:center}
</style></head><body>
<div class="wrap">
<header>
<div class="lg">${esc(T)}</div>
<nav><a>Work</a><a>About</a><a>Contact</a>${ig ? `<a href="${ig}">Instagram</a>` : ''}</nav>
</header>
<section class="hero">
<div class="hero-img"></div>
<h1>${esc(T)}</h1>
<p>${esc(sub)}</p>
<a class="cta" href="#">${esc(ctas(ctx).primary)}</a>
</section>
<section class="feat">
${(() => { const ec = packCards(ctx) || [{title:'Quiet',body:"No pop-ups. No banners. No cookies you didn't ask for."},{title:'Fast',body:'Loads in under a second on any phone, anywhere.'},{title:'Yours',body:'Owned, hosted, and editable by you — not us.'}]; return ec.map(c=>`<div class="f"><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n '); })()}
</section>
<footer>© ${new Date().getFullYear()} · ${esc(T)} · <a href="${esc(ctx.url)}" style="color:inherit">${esc(ctx.url.replace(/^https?:\/\//,''))}</a></footer>
</div>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 4 · MAGAZINE
// Editorial weeklies: oversized hero number/date, alternating columns, big serif drop-cap.
// ---------------------------------------------------------------------------
function renderMagazine(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#0a0a0a', '#d63b3b']);
const T = clean(ctx.title, 80) || 'Untitled';
const sub = clean(ctx.tagline, 320) || 'A new way to see your business online.';
const ig = igUrl(ctx.ig_handle);
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, [c1, c2])} center/cover`;
const issueNum = (() => { let h=0; for (const c of (ctx.title||'x')) h = (h*31 + c.charCodeAt(0)) >>> 0; return String(100 + (h % 899)); })();
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Magazine</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#fafaf6;color:${c1};line-height:1.5}
.wrap{max-width:1280px;margin:0 auto;padding:0 32px}
header{padding:24px 0;border-bottom:3px solid ${c1};display:flex;justify-content:space-between;align-items:baseline}
.lg{font-family:'Playfair Display',serif;font-weight:900;font-size:36px;letter-spacing:-.02em}
.issue{font-size:11px;font-weight:700;color:${c2};letter-spacing:.16em;text-transform:uppercase}
nav a{margin-left:24px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:${c1};text-decoration:none;font-weight:600}
.hero{padding:64px 0 48px;display:grid;grid-template-columns:120px 1fr;gap:48px}
.hero-num{font-family:'Playfair Display',serif;font-weight:900;font-size:160px;line-height:.8;color:${c2}}
.hero-num small{display:block;font-size:14px;font-weight:700;color:${c1};letter-spacing:.16em;text-transform:uppercase;margin-top:18px;font-family:'Inter',sans-serif}
.hero-body h1{font-family:'Playfair Display',serif;font-weight:900;font-size:72px;line-height:.95;letter-spacing:-.02em;margin-bottom:18px}
.hero-body p{font-size:19px;color:${c1}cc;max-width:60ch;line-height:1.5}
.hero-img{aspect-ratio:21/9;background:${heroBg};margin-top:32px;border-bottom:3px solid ${c1}}
.lead{padding:64px 0;display:grid;grid-template-columns:1fr 1fr 1fr;gap:48px;border-bottom:1px solid ${c1}33}
.col p{font-size:15px;line-height:1.7;color:${c1}cc}
.col p::first-letter{font-family:'Playfair Display',serif;font-weight:900;font-size:48px;color:${c2};float:left;line-height:1;margin:6px 8px 0 0}
.col h3{font-family:'Playfair Display',serif;font-size:24px;font-weight:700;margin-bottom:12px;color:${c1}}
.byline{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${c2};font-weight:700;margin-bottom:14px}
footer{padding:48px 0;border-top:3px solid ${c1};font-size:12px;color:${c1}99;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
.hero-cta{display:inline-block;margin-top:20px;padding:12px 28px;background:${c1};color:#fafaf6;text-decoration:none;font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
.hero-cta:hover{background:${c2}}
</style></head><body>
<div class="wrap">
<header>
<div class="lg">${esc(T)}</div>
<nav><a>The Issue</a><a>Features</a><a>Archive</a><a>Subscribe</a>${ig ? `<a href="${ig}">@${esc(ctx.ig_handle)}</a>` : ''}</nav>
</header>
<section class="hero">
<div class="hero-num">${issueNum}<small>Issue · ${new Date().getFullYear()}</small></div>
<div class="hero-body"><h1>${esc(T)}.</h1><p>${esc(sub)}</p><a class="hero-cta" href="#">${esc(ctas(ctx).primary)} →</a></div>
</section>
<div class="hero-img"></div>
<section class="lead">
${(() => { const ec = packCards(ctx); const bylines = ['Field Notes','In Practice','For Visitors']; const defaults = [{title:'What we make',body:sub.slice(0,200)},{title:'How it works',body:"Take it from the people who built it. Quiet rooms, the right tools, and the patience to do the same thing twice if it isn't right the first time."},{title:'Get in touch',body:'Calls return same-day. Email returns inside 24 hours. Walk-ins welcome between 11 and 5.'}]; const cards = ec ? ec.map(c=>({title:c.title.charAt(0)+c.title.slice(1).toLowerCase(),body:c.body})) : defaults; return cards.map((c,i)=>`<div class="col"><div class="byline">${esc(bylines[i])}</div><h3>${esc(c.title)}</h3><p>${esc(c.body)}</p></div>`).join('\n '); })()}
</section>
<footer><div>${esc(T)} · Issue ${issueNum} · © ${new Date().getFullYear()}</div></footer>
</div>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 5 · NEON
// Saturated neon palette, glowing borders, dark bg, futurist sans, scanline detail.
// ---------------------------------------------------------------------------
function renderNeon(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#0d0721', '#e84393']);
const T = (clean(ctx.title, 80) || 'Untitled');
const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
const ig = igUrl(ctx.ig_handle);
const accent2 = '#00f5d4';
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, [c1, c2])} center/cover`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Neon</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Sora:wght@600;800&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'JetBrains Mono',monospace;background:${c1};color:#fff;line-height:1.5;min-height:100vh;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent 0 2px,#fff05 2px 3px);z-index:1}
.wrap{max-width:1280px;margin:0 auto;padding:0 32px;position:relative;z-index:2}
header{padding:32px 0;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid ${c2}66}
.lg{font-family:'Sora',sans-serif;font-weight:800;font-size:28px;color:${c2};text-shadow:0 0 12px ${c2}88,0 0 24px ${c2}44;letter-spacing:-.02em}
nav a{margin-left:18px;font-size:11px;color:${accent2};text-decoration:none;letter-spacing:.16em;text-transform:uppercase}
nav a:hover{text-shadow:0 0 12px ${accent2}}
.hero{padding:96px 0 64px;text-align:center}
.hero-tag{display:inline-block;border:1px solid ${accent2};color:${accent2};padding:6px 14px;font-size:10px;letter-spacing:.16em;text-transform:uppercase;margin-bottom:24px;text-shadow:0 0 8px ${accent2}}
.hero h1{font-family:'Sora',sans-serif;font-weight:800;font-size:104px;line-height:.9;letter-spacing:-.04em;margin-bottom:24px}
.hero h1 span{color:${c2};text-shadow:0 0 24px ${c2}aa,0 0 48px ${c2}66}
.hero p{font-size:17px;color:#fff8;max-width:60ch;margin:0 auto 36px}
.hero a{display:inline-block;background:${c2};color:${c1};padding:18px 36px;text-decoration:none;font-family:'Sora',sans-serif;font-weight:800;font-size:14px;letter-spacing:.12em;text-transform:uppercase;border:0;box-shadow:0 0 24px ${c2}66}
.hero a:hover{box-shadow:0 0 36px ${c2}aa}
.hero-img{aspect-ratio:21/9;background:${heroBg};margin-top:64px;border-top:1px solid ${c2}66;border-bottom:1px solid ${c2}66;position:relative}
.cards{padding:80px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
.cd{border:1px solid ${c2}66;padding:24px;background:${c1}aa;backdrop-filter:blur(8px)}
.cd:nth-child(2){border-color:${accent2}66}
.cd-num{font-family:'Sora',sans-serif;font-weight:800;font-size:11px;letter-spacing:.16em;color:${c2};margin-bottom:14px}
.cd:nth-child(2) .cd-num{color:${accent2}}
.cd h3{font-family:'Sora',sans-serif;font-weight:800;font-size:22px;line-height:1;margin-bottom:14px;text-transform:uppercase;letter-spacing:-.01em}
.cd p{font-size:13px;color:#fffb;line-height:1.6}
footer{padding:48px 0;border-top:1px solid ${c2}66;font-size:11px;color:#fff66;letter-spacing:.16em;text-transform:uppercase;text-align:center}
</style></head><body>
<div class="wrap">
<header><div class="lg">▲ ${esc(T)}</div><nav><a>Home</a><a>Work</a><a>Contact</a>${ig ? `<a href="${ig}">IG</a>` : ''}</nav></header>
<section class="hero">
<div class="hero-tag">▶ Now Live · ${new Date().getFullYear()}</div>
<h1>${esc(T.split(' ').slice(0,2).join(' '))} <span>online.</span></h1>
<p>${esc(sub)}</p>
<a href="#">${esc(ctas(ctx).primary)} →</a>
</section>
<div class="hero-img"></div>
<section class="cards">
${(() => { const ec = packCards(ctx) || [{title:'BUILT IN',body:sub.slice(0,140)},{title:'ALWAYS ON',body:'Same-week response time. Late hours by appointment. Real human on the other end of every message.'},{title:'HAND BUILT',body:'No template. No fluff. Every detail tested by people who actually use it.'}]; const nums=['▲ 01','▲ 02','▲ 03']; return ec.map((c,i)=>`<div class="cd"><div class="cd-num">${nums[i]}</div><h3>${esc(c.title)}</h3><p>${esc(c.body)}</p></div>`).join('\n '); })()}
</section>
<footer>${esc(T)} · © ${new Date().getFullYear()} ${ig ? `· ${esc(ctx.ig_handle)}` : ''}</footer>
</div>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 6 · WARM
// Veterinary / wellness / homey — soft sage + cream, rounded corners, hand-drawn feel.
// ---------------------------------------------------------------------------
function renderWarm(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#3f5645', '#e07a5f']);
const T = clean(ctx.title, 80) || 'Untitled';
const sub = clean(ctx.tagline, 280) || 'Care, on your schedule.';
const ig = igUrl(ctx.ig_handle);
const cream = '#f4ede0';
const sage = '#9aaf94';
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, [sage, c2])} center/cover`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Warm</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;500;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Nunito',sans-serif;background:${cream};color:${c1};line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
header{padding:28px 0;display:flex;justify-content:space-between;align-items:center}
.lg{font-family:'Fraunces',serif;font-weight:700;font-size:30px;letter-spacing:-.01em}
.lg::before{content:'❀ ';color:${c2}}
nav a{margin-left:24px;font-size:14px;font-weight:600;color:${c1};text-decoration:none}
.cta{background:${c2};color:#fff;padding:10px 22px;border-radius:99px;text-decoration:none;font-weight:700;font-size:14px}
.hero{padding:80px 0 64px;display:grid;grid-template-columns:1.1fr 1fr;gap:64px;align-items:center}
.hero h1{font-family:'Fraunces',serif;font-weight:700;font-size:72px;line-height:1;letter-spacing:-.02em;margin-bottom:24px}
.hero h1 em{color:${c2};font-style:italic}
.hero p{font-size:18px;color:${c1}cc;max-width:50ch;margin-bottom:28px}
.hero-cta{display:inline-block;background:${c1};color:${cream};padding:16px 32px;border-radius:99px;text-decoration:none;font-weight:700;font-size:15px;margin-right:8px}
.hero-link{color:${c1};text-decoration:underline;font-weight:600;font-size:14px}
.hero-img{aspect-ratio:1;background:${heroBg};border-radius:36px;border:8px solid ${cream};box-shadow:0 8px 0 ${sage}66}
.feat{padding:80px 0;background:${cream};display:grid;grid-template-columns:repeat(3,1fr);gap:32px;border-top:2px dashed ${sage}}
.f{background:#fff;border-radius:24px;padding:28px;border:2px solid ${sage}33}
.f-icon{width:48px;height:48px;background:${c2};color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Fraunces',serif;font-weight:700;font-size:22px;margin-bottom:14px}
.f h3{font-family:'Fraunces',serif;font-size:22px;font-weight:700;margin-bottom:10px}
.f p{font-size:14px;color:${c1}aa;line-height:1.55}
footer{padding:48px 0;text-align:center;font-size:13px;color:${c1}88;border-top:2px dashed ${sage}}
footer::before{content:'❀ ';color:${c2}}
</style></head><body>
<div class="wrap">
<header>
<div class="lg">${esc(T)}</div>
<div><nav style="display:inline-flex"><a>About</a><a>Services</a><a>Hours</a>${ig ? `<a href="${ig}">${esc(ctx.ig_handle)}</a>` : ''}</nav> <a class="cta" href="#">Book →</a></div>
</header>
<section class="hero">
<div>
<h1>${esc(T)}, <em>quietly cared for.</em></h1>
<p>${esc(sub)}</p>
<a class="hero-cta" href="#">${esc(ctas(ctx).primary)}</a>
<a class="hero-link" href="#">or ${esc(ctas(ctx).secondary.toLowerCase())}</a>
</div>
<div class="hero-img"></div>
</section>
<section class="feat">
${(() => { const ec = packCards(ctx) || [{title:'WELCOME',body:'New visitors get a 15-minute orientation. No pressure, no upsell — just so you know what to expect.'},{title:'FAMILIAR',body:'We learn your preferences. Your file remembers what worked last time.'},{title:'AVAILABLE',body:'Same-week appointments. Saturday mornings. Real people who answer the phone.'}]; const icons=['A','B','C']; return ec.map((c,i)=>`<div class="f"><div class="f-icon">${icons[i]}</div><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n '); })()}
</section>
<footer>${esc(T)} · © ${new Date().getFullYear()} ${ig ? `· ${esc(ctx.ig_handle)}` : ''}</footer>
</div>
</body></html>`;
}
// ---------------------------------------------------------------------------
// THEME 7 · AUTHORITATIVE
// Lawyer / professional / institutional — slate + gold, navy, narrow column, gravity.
// ---------------------------------------------------------------------------
function renderAuthoritative(ctx) {
const [c1, c2] = pickColors(ctx.palette, ['#0e1c36', '#b8965c']);
const T = clean(ctx.title, 80) || 'Untitled';
const sub = clean(ctx.tagline, 280) || 'Trusted counsel since 2000.';
const ig = igUrl(ctx.ig_handle);
const heroBg = ctx.hero_image
? `url(${esc(ctx.hero_image)}) center/cover`
: `${svgHero(ctx, [c1, c2])} center/cover`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(T)} — Authoritative</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'IBM Plex Sans',sans-serif;background:#fff;color:${c1};line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:0 40px}
.topbar{background:${c1};color:#fffaf0;padding:8px 0;font-size:12px;letter-spacing:.04em}
.topbar-inner{max-width:1180px;margin:0 auto;padding:0 40px;display:flex;justify-content:space-between}
.topbar a{color:${c2};text-decoration:none;margin-left:24px}
header{padding:36px 0;border-bottom:2px solid ${c2};display:flex;justify-content:space-between;align-items:baseline}
.lg{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:36px;letter-spacing:.02em}
.lg em{font-style:italic;color:${c2};font-weight:500}
nav a{margin-left:28px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:${c1};text-decoration:none;font-weight:500}
.hero{padding:96px 0 80px;display:grid;grid-template-columns:1fr 1fr;gap:64px;align-items:center}
.hero h1{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:72px;line-height:1.05;letter-spacing:-.01em;margin-bottom:18px}
.hero h1 em{color:${c2};font-style:italic;font-weight:500}
.eyebrow{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:${c2};font-weight:600;margin-bottom:18px}
.hero p{font-size:17px;color:${c1}cc;max-width:46ch;margin-bottom:28px}
.hero-cta{display:inline-block;background:${c1};color:#fffaf0;padding:18px 40px;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;border:1px solid ${c1}}
.hero-cta:hover{background:${c2};border-color:${c2}}
.hero-img{aspect-ratio:4/5;background:${heroBg};border:8px solid ${c1}}
.creds{padding:64px 0;border-top:1px solid ${c1}22;border-bottom:1px solid ${c1}22;display:grid;grid-template-columns:repeat(4,1fr);gap:32px;text-align:center}
.cr-num{font-family:'Cormorant Garamond',serif;font-size:54px;font-weight:700;color:${c2};line-height:1;margin-bottom:8px}
.cr-label{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${c1}99;font-weight:600}
.areas{padding:80px 0;display:grid;grid-template-columns:1fr 2fr;gap:64px}
.areas h2{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:48px;line-height:1;letter-spacing:-.01em}
.areas h2 em{color:${c2};font-style:italic;font-weight:500}
.areas-list{display:grid;grid-template-columns:1fr 1fr;gap:8px 32px}
.areas-list li{list-style:none;padding:14px 0;border-bottom:1px solid ${c1}22;font-size:15px;font-weight:500}
.areas-list li::before{content:'§ ';color:${c2};font-family:'Cormorant Garamond',serif;font-weight:700;font-size:18px}
footer{background:${c1};color:#fffaf0aa;padding:40px 0;font-size:12px;letter-spacing:.08em;text-align:center}
footer span{color:${c2}}
</style></head><body>
<div class="topbar"><div class="topbar-inner"><span>Established 2000 · Licensed in this jurisdiction</span><span><a href="#">Free Consultation</a><a href="#">Schedule Call</a></span></div></div>
<div class="wrap">
<header>
<div class="lg">${esc(T)}<em>.</em></div>
<nav><a>Practice Areas</a><a>Attorneys</a><a>Results</a><a>Contact</a>${ig ? `<a href="${ig}">${esc(ctx.ig_handle)}</a>` : ''}</nav>
</header>
<section class="hero">
<div>
<div class="eyebrow">Trusted Counsel · Discreet Service</div>
<h1>${esc(T)} <em>—</em> when it matters most.</h1>
<p>${esc(sub)}</p>
<a class="hero-cta" href="#">${esc(ctas(ctx).primary)} →</a>
</div>
<div class="hero-img"></div>
</section>
<section class="creds">
<div><div class="cr-num">25+</div><div class="cr-label">Years Practice</div></div>
<div><div class="cr-num">∞</div><div class="cr-label">Confidential</div></div>
<div><div class="cr-num">9.6</div><div class="cr-label">Avvo Rating</div></div>
<div><div class="cr-num">★★★</div><div class="cr-label">Peer Reviewed</div></div>
</section>
<section class="areas">
<div><h2>Practice <em>areas.</em></h2></div>
<ul class="areas-list">
${(() => { const ec = packCards(ctx); if (ec) { return ec.map(c=>`<li>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</li>`).join(''); } return '<li>Initial consultation</li><li>Case evaluation</li><li>Document review</li><li>Court representation</li><li>Settlement negotiation</li><li>Appeal preparation</li>'; })()}
</ul>
</section>
</div>
<footer>${esc(T)} · © ${new Date().getFullYear()} · <span>Attorney Advertising</span> · Past results do not guarantee future outcomes</footer>
</body></html>`;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
const RENDERERS = {
editorial: renderEditorial,
brutalist: renderBrutalist,
minimalist: renderMinimalist,
// aliases per pickThemesFor() — all map to one of the 3 above for now.
'minimalist-spa': renderMinimalist,
// ★ Distinct renderers (4 new) — were aliases, now real
magazine: renderMagazine,
neon: renderNeon,
warm: renderWarm,
authoritative: renderAuthoritative,
// ★ Still aliases (low-traffic verticals reuse closest match)
'tools-forward': renderBrutalist,
};
export function renderTheme(theme, ctx) {
const fn = RENDERERS[theme] || RENDERERS.editorial;
return fn(ctx);
}
export function listThemes() {
return Object.keys(RENDERERS);
}