← back to Small Business Builder
src/render/website-analysis.js
481 lines
// website-analysis app — entry page + result page (brutalist theme).
//
// Brutalist design system:
// bg #fff, ink #000, accent #facc15, secondary-accent #dc2626 (sparingly)
// typefaces: 'Archivo Black' (display) + 'Space Mono' (UI/body)
// hard borders, no rounded corners, tape-marquee, no-nonsense.
import { esc } from '../lib/escape.js';
import { computeGrade } from '../lib/audit-grade.js';
const HEAD = (title) => `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#fff;--ink:#000;--paper:#fafaf6;--accent:#facc15;--muted:#666;--soft:#fef3c7;--soft-hi:#fef2f2;--green:#f0fdf4}
[data-theme="dark"]{--bg:#0a0a08;--ink:#fafaf6;--paper:#1a1a18;--accent:#facc15;--muted:#999;--soft:#1f1f1d;--soft-hi:#1f0a0a;--green:#0a2014}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Space Mono',monospace;background:var(--bg);color:var(--ink);line-height:1.4;min-height:100vh;display:flex;flex-direction:column;transition:background .15s,color .15s}
a{color:inherit}
.theme-toggle{background:transparent;color:#fff;border:1px solid #fff;padding:8px 12px;font-family:'Archivo Black',sans-serif;font-size:12px;cursor:pointer;text-transform:uppercase;letter-spacing:.04em;margin-left:8px}
.theme-toggle:hover{background:var(--accent);color:#000;border-color:var(--accent)}
.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:' ★ WEBSITE ANALYSIS ★ POWERED BY 7 SKILLS ★ AGENT ABRAMS ★ '}
@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;gap:24px}
.lg{font-family:'Archivo Black',sans-serif;font-size:28px;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 14px;border:1px solid #fff;margin-right:-1px;text-decoration:none}
.nv a.active{background:#facc15;color:#000}
main{flex:1}
.ft{background:#000;color:#aaa;padding:24px;text-align:center;font-size:11px;letter-spacing:.04em;text-transform:uppercase;border-top:4px solid #000}
.ft a{color:#facc15;text-decoration:none}
</style>
<script>(function(){try{var t=localStorage.getItem('lsa-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark')}catch(e){}})()</script>
</head><body>
<div class="tape"><span></span></div>
<header class="hd"><div class="hd-inner">
<div class="lg">WEBSITE / ANALYSIS</div>
<nav class="nv"><a class="active" href="/website-analysis">START</a><a href="/wall">THE WALL</a><a href="/grades">GRADES</a><button class="theme-toggle" id="theme-toggle" title="dark/light">★ <span id="theme-icon">☾</span></button></nav>
</div></header>
<script>(function(){const t=document.getElementById('theme-toggle'),i=document.getElementById('theme-icon');function r(){const d=document.documentElement.getAttribute('data-theme')==='dark';if(i)i.textContent=d?'☀':'☾'}r();t?.addEventListener('click',()=>{const d=document.documentElement.getAttribute('data-theme')==='dark';if(d){document.documentElement.removeAttribute('data-theme');localStorage.setItem('lsa-theme','light')}else{document.documentElement.setAttribute('data-theme','dark');localStorage.setItem('lsa-theme','dark')}r()})})()</script>
<main>`;
const FOOT = `</main>
<footer class="ft">© ${new Date().getFullYear()} AGENT ABRAMS ★ ALL ANALYSIS RUNS LOCALLY ★ <a href="https://sdccanalysis.agentabrams.com">REFERENCE PATTERN</a></footer>
</body></html>`;
// ---------------------------------------------------------------------------
// /website-analysis — entry page
// ---------------------------------------------------------------------------
export function renderEntry({ error = '' } = {}) {
return HEAD('Website Analysis — Get Started') + `
<section style="padding:96px 24px;border-bottom:4px solid #000">
<div style="max-width:880px;margin:0 auto">
<div style="display:inline-block;border:3px solid #000;padding:8px 14px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px;transform:rotate(-1.5deg);background:#dc2626;color:#fff">★ ANALYZE ANY SITE ★</div>
<h1 style="font-family:'Archivo Black',sans-serif;font-size:120px;line-height:.85;letter-spacing:-.05em;text-transform:uppercase;margin-bottom:24px">SEE YOUR<br>SITE — <mark style="background:#facc15;padding:0 8px">REBUILT.</mark></h1>
<p style="font-size:18px;line-height:1.5;font-weight:500;max-width:60ch;margin-bottom:48px">Drop in your website. Add your Instagram. We'll show you your site as it is — and three new directions you could take it. No signup. No spam.</p>
${error ? `<div style="border:4px solid #dc2626;background:#fef2f2;color:#dc2626;padding:16px;font-weight:700;margin-bottom:24px;text-transform:uppercase;font-size:13px">★ ${esc(error)}</div>` : ''}
<form method="post" action="/api/website-analysis" style="display:grid;gap:0;border:4px solid #000;background:#fff">
<div style="display:grid;grid-template-columns:200px 1fr;border-bottom:4px solid #000">
<label for="url" style="background:#000;color:#facc15;padding:24px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;border-right:4px solid #000;display:flex;align-items:center">★ WEBSITE</label>
<input id="url" name="url" type="text" required placeholder="yourbusiness.com" autocomplete="url"
style="padding:24px;border:0;font-family:'Space Mono',monospace;font-size:18px;font-weight:700;background:#fff;outline:none">
</div>
<div style="display:grid;grid-template-columns:200px 1fr;border-bottom:4px solid #000">
<label for="ig" style="background:#000;color:#facc15;padding:24px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;border-right:4px solid #000;display:flex;align-items:center">★ INSTAGRAM</label>
<input id="ig" name="ig_handle" type="text" placeholder="@yourbusiness (optional)"
style="padding:24px;border:0;font-family:'Space Mono',monospace;font-size:18px;font-weight:700;background:#fff;outline:none">
</div>
<button type="submit"
style="background:#facc15;color:#000;padding:28px;border:0;font-family:'Archivo Black',sans-serif;font-size:24px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;border-top:0">
GET STARTED →
</button>
</form>
<p style="margin-top:32px;font-size:12px;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em">★ NO ACCOUNT ★ NO STORED PII ★ ANALYSIS RUNS IN ~30 SECONDS · <a href="/wall" style="color:#000;text-decoration:none;border-bottom:2px solid #facc15">SEE THE WALL →</a></p>
</div>
</section>
<section style="padding:48px 24px;background:#000;color:#facc15;border-bottom:4px solid #000">
<div style="max-width:880px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:18px;margin-bottom:18px">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:6px">★ NO COMMITMENT</div>
<h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;color:#fafaf6;margin:0">JUST WANT A ROAST?</h2>
</div>
<div style="font-size:13px;color:#aaa;font-weight:500;max-width:32ch">qwen3:14b will read your URL and write a stand-up paragraph dragging your design choices. Loving but savage. Free. Local. ~10 sec.</div>
</div>
<form id="roast-form" style="display:flex;gap:0;border:4px solid #facc15;background:#000">
<input id="roast-url" type="text" required placeholder="https://yoursite.com" autocomplete="url"
style="flex:1;padding:18px;border:0;font-family:'Space Mono',monospace;font-size:16px;font-weight:700;background:#000;color:#facc15;outline:none">
<button type="submit"
style="background:#facc15;color:#000;padding:18px 28px;border:0;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;border-left:4px solid #facc15">★ ROAST ME →</button>
</form>
<div id="roast-out" style="margin-top:18px;font-family:'Space Mono',monospace;font-size:16px;line-height:1.5;color:#fafaf6;min-height:24px"></div>
</div>
</section>
<script>
(function(){
const form = document.getElementById('roast-form');
const out = document.getElementById('roast-out');
if (!form || !out) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('roast-url').value.trim();
if (!url) return;
const u = /^https?:\\/\\//i.test(url) ? url : 'https://' + url;
out.style.color = '#aaa';
out.textContent = '★ qwen3 is judging you…';
try {
const r = await fetch('/api/roast-me', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: u }) });
const data = await r.json();
if (!r.ok) { out.style.color = '#f87171'; out.textContent = '★ ' + (data.error || 'something broke'); return; }
out.style.color = '#fafaf6';
const safe = (s) => s.replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]));
const tweet = encodeURIComponent('★ qwen3 just roasted ' + data.host + ':\\n\\n"' + data.roast.slice(0, 200) + (data.roast.length > 200 ? '…' : '') + '"\\n\\n— roasted free by livesiteaudit.agentabrams.com');
const tweetUrl = 'https://twitter.com/intent/tweet?text=' + tweet;
out.innerHTML = '<div style="border-left:4px solid #facc15;padding:8px 16px;font-style:italic">' + safe(data.roast) + '</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:10px">' +
'<div style="font-size:11px;color:#aaa;letter-spacing:.06em;text-transform:uppercase">★ ROAST OF ' + safe(data.host) + '</div>' +
'<a href="' + tweetUrl + '" target="_blank" rel="noopener" style="background:#facc15;color:#000;padding:8px 14px;text-decoration:none;font-family:Archivo Black,sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border:3px solid #facc15">★ TWEET THIS ROAST →</a>' +
'</div>';
} catch (err) {
out.style.color = '#f87171';
out.textContent = '★ network error — try again';
}
});
})();
</script>
<section style="padding:64px 24px;background:var(--soft)">
<div style="max-width:1180px;margin:0 auto">
<h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;margin-bottom:32px">★ HOW IT WORKS</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0;border:4px solid var(--ink);background:var(--paper);color:var(--ink)">
<div style="padding:32px;border-right:4px solid #000">
<div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">01</div>
<h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">YOUR SITE</h3>
<p style="font-size:14px;line-height:1.5;font-weight:500">We pull your homepage, your colors, your images, your Instagram. Nothing private. All public stuff.</p>
</div>
<div style="padding:32px;border-right:4px solid #000;background:var(--soft)">
<div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">02</div>
<h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">3 REBUILDS</h3>
<p style="font-size:14px;line-height:1.5;font-weight:500">Three new directions — your same content, three completely different aesthetic systems. Pick one.</p>
</div>
<div style="padding:32px">
<div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">03</div>
<h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">SHIP IT</h3>
<p style="font-size:14px;line-height:1.5;font-weight:500">Like one? Push the button. We'll launch it on a free subdomain — or your own domain if you have one.</p>
</div>
</div>
</div>
</section>
` + FOOT;
}
// ---------------------------------------------------------------------------
// /wa/:slug — result page (your site iframe + 3 mockup cards)
// ---------------------------------------------------------------------------
export function renderResult({ analysis, mockups, isPublic = false }) {
const url = analysis.url;
const ig = analysis.ig_handle ? `@${String(analysis.ig_handle).replace(/^@/, '')}` : null;
const iframeBlocked = !!(analysis.meta_json && analysis.meta_json.iframe_blocked);
const grade = computeGrade({ analysis });
// Synthesize a palette deterministically from the slug + vertical when none
// was scraped, so the result page is never empty. Vertical-tinted base hue.
function synthPalette(slug, vertical) {
const VERT_HUE = { salon:330, restaurant:18, lawyer:215, vet:160, advocacy:0, 'saas-landing':210, ecom:285, generic:265 };
const base = VERT_HUE[vertical] ?? 220;
let h = 0; for (let i = 0; i < slug.length; i += 1) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
const start = (base + (h % 60) - 30 + 360) % 360;
const out = [];
for (let i = 0; i < 5; i += 1) {
const hue = (start + i * 24) % 360;
const sat = 55 + ((h >> i) & 0xf) * 2;
const lig = 32 + i * 12;
out.push(`hsl(${hue} ${sat}% ${lig}%)`);
}
return out;
}
const rawPalette = (analysis.meta_json?.palette || []).slice(0, 5);
const palette = rawPalette.length ? rawPalette : synthPalette(analysis.slug, analysis.vertical);
const paletteSource = rawPalette.length ? 'scraped' : 'synthesized';
const swatches = palette.map(c => `<div title="${esc(c)}" style="width:28px;height:28px;background:${esc(c)};border:3px solid #000"></div>`).join('');
const verticalPill = analysis.vertical
? `<div style="display:inline-block;background:#000;color:#facc15;padding:4px 10px;font-family:'Archivo Black',sans-serif;font-size:12px;text-transform:uppercase;letter-spacing:.04em">★ ${esc(analysis.vertical)}</div>`
: '';
const cards = mockups.map(m => `
<div class="cd" style="display:block;border:4px solid var(--ink);background:var(--paper)">
<a href="/wa/${esc(analysis.slug)}/m/${esc(m.mockup_id)}" target="_blank" rel="noopener" style="display:block;text-decoration:none;color:#000">
<div style="background:#000;color:#facc15;padding:14px 20px;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;border-bottom:4px solid #000;display:flex;justify-content:space-between;align-items:center">
<span>★ OPTION ${esc(m.mockup_id.split('-')[0])}</span>
<span>${esc(m.theme.toUpperCase())}</span>
</div>
<div style="aspect-ratio:4/3;position:relative;overflow:hidden;background:#fafaf6">
<iframe src="/wa/${esc(analysis.slug)}/m/${esc(m.mockup_id)}"
style="position:absolute;inset:0;width:200%;height:200%;border:0;transform:scale(.5);transform-origin:0 0;pointer-events:none"
loading="lazy" sandbox="allow-same-origin"></iframe>
</div>
<div style="padding:18px 20px;border-top:4px solid #000;background:var(--soft)">
<div style="font-family:'Archivo Black',sans-serif;font-size:20px;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:6px">${esc(m.title)}</div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666">★ CLICK TO PREVIEW FULL SIZE</div>
</div>
</a>
${isPublic ? '' : `<form method="post" action="/api/wa/${esc(analysis.slug)}/ship/${esc(m.mockup_id)}" style="margin:0">
<button type="submit"
style="width:100%;background:#facc15;color:#000;border:0;border-top:4px solid #000;padding:18px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer">
★ SHIP THIS REBUILD →
</button>
</form>`}
</div>
`).join('');
// We must ALWAYS see the screen. When iframe is blocked, fall back to a Playwright
// screenshot served from /wa/:slug/screenshot.png (disk-cached, 7-day TTL).
const shotUrl = `/wa/${esc(analysis.slug)}/screenshot.png`;
const yourSite = iframeBlocked
? `<div style="border:4px solid #000;background:#fff;position:relative;overflow:hidden">
<a href="${esc(url)}" target="_blank" rel="noopener" style="display:block;line-height:0">
<img src="${shotUrl}" alt="screenshot of ${esc(new URL(url).hostname)}"
style="width:100%;height:auto;display:block;background:#f4f4f4 url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%2240%22><text x=%2260%22 y=%2225%22 font-family=%22monospace%22 font-size=%2212%22 text-anchor=%22middle%22 fill=%22%23999%22>capturing screenshot…</text></svg>') no-repeat center;min-height:240px"
onerror="this.parentElement.parentElement.innerHTML='<div style="padding:48px 32px;background:#000;color:#facc15;text-align:left"><div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase">★ COULD NOT CAPTURE PREVIEW</div><div style="font-family:Archivo Black,sans-serif;font-size:24px;text-transform:uppercase;color:#fff;margin-top:12px">OPEN ${esc(new URL(url).hostname).replace(/'/g,"'")} TO SEE THE SITE</div><a href="${esc(url)}" target="_blank" rel="noopener" style="display:inline-block;margin-top:18px;background:#facc15;color:#000;padding:12px 20px;text-decoration:none;font-family:Archivo Black,sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ OPEN ↗</a></div>'">
</a>
<div style="position:absolute;top:14px;left:14px;background:#000;color:#facc15;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:10px;letter-spacing:.06em;text-transform:uppercase;border:2px solid #facc15">★ STATIC SCREENSHOT — IFRAME BLOCKED</div>
<a href="${esc(url)}" target="_blank" rel="noopener" style="position:absolute;top:14px;right:14px;background:#facc15;color:#000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;text-decoration:none;border:2px solid #000">OPEN LIVE ↗</a>
</div>`
: `<div style="border:4px solid #000;background:#fff;aspect-ratio:16/9;position:relative;overflow:hidden">
<img src="${shotUrl}" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top">
<iframe src="${esc(url)}" style="position:absolute;inset:0;width:100%;height:100%;border:0;background:transparent" loading="lazy" referrerpolicy="no-referrer"></iframe>
</div>`;
// Live audit panel — only present when audit-public-sites.mjs has run on this row.
const live = analysis.summary_json?.live_audit;
const liveColor = live?.grade?.score >= 80 ? '#22c55e' : live?.grade?.score >= 65 ? '#84cc16' : live?.grade?.score >= 45 ? '#facc15' : live?.grade?.score >= 25 ? '#f97316' : '#dc2626';
const fetchedAt = live?.fetched_at ? new Date(live.fetched_at) : null;
const fetchedAtStr = fetchedAt && !Number.isNaN(fetchedAt.getTime()) ? fetchedAt.toLocaleString() : 'unknown';
const liveScoreStr = String(live?.grade?.score ?? '');
const liveBytesKB = Number.isFinite(live?.bytes) ? Math.round(live.bytes / 1024) : 0;
const liveFetchMs = Number.isFinite(live?.fetch_ms) ? live.fetch_ms : 0;
const livePanel = live ? `
<section style="padding:48px 24px;border-bottom:4px solid var(--ink);background:var(--paper)">
<div style="max-width:1320px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:14px">
<div>
<div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:6px">★ LIVE HTML AUDIT · FROM A FRESH FETCH</div>
<h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;text-transform:uppercase;letter-spacing:-.02em">WHAT YOUR SITE ACTUALLY SAYS</h2>
</div>
<div title="Real HTML signals — title length, meta description, og:image, viewport, alt-text coverage, JSON-LD, etc."
style="display:flex;align-items:center;gap:14px;background:${liveColor};color:#000;border:4px solid #000;padding:8px 18px">
<div style="font-family:'Archivo Black',sans-serif;font-size:42px;line-height:1">${esc(live.grade.letter)}</div>
<div>
<div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ LIVE HTML</div>
<div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">${esc(liveScoreStr)}/100</div>
</div>
</div>
</div>
<p style="font-size:13px;color:#444;margin-bottom:22px;font-weight:500;max-width:80ch">Signals from a live fetch: title, meta description, og:image, viewport, alt-text coverage, JSON-LD, HTTPS, lang attribute, canonical, h1 count.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px">
<div style="border:4px solid #000;background:#fef2f2;padding:18px 22px">
<div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink);margin-bottom:12px;border-bottom:2px solid var(--ink);padding-bottom:8px">TOP ISSUES</div>
${(live.top_3_issues?.length ? live.top_3_issues : ['(none — site is well-tagged)']).map(i => `<div style="font-size:14px;font-weight:600;line-height:1.5;margin-bottom:6px">→ ${esc(i)}</div>`).join('')}
</div>
<div style="border:4px solid #000;background:#f0fdf4;padding:18px 22px">
<div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink);margin-bottom:12px;border-bottom:2px solid var(--ink);padding-bottom:8px">WHAT'S WORKING</div>
${(live.top_3_wins?.length ? live.top_3_wins : ['(none yet)']).map(w => `<div style="font-size:14px;font-weight:600;line-height:1.5;margin-bottom:6px">→ ${esc(w)}</div>`).join('')}
</div>
</div>
<div style="margin-top:14px;font-size:11px;font-weight:700;color:#666;font-family:'Space Mono',monospace">★ FETCHED ${esc(fetchedAtStr)} · ${esc(String(liveBytesKB))}KB · ${esc(String(liveFetchMs))}MS</div>
</div>
</section>` : '';
// JSON-LD for /wa/:slug — review-style structured data with grade as rating.
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Review',
itemReviewed: { '@type': 'WebSite', url: analysis.url, name: analysis.url ? new URL(analysis.url).hostname : analysis.slug },
author: { '@type': 'Organization', name: 'Live Site Audit', url: 'https://livesiteaudit.agentabrams.com' },
...(live ? { reviewRating: { '@type': 'Rating', ratingValue: live.grade?.score, bestRating: 100, worstRating: 0 } } : {}),
reviewBody: analysis.summary_json?.copy_pack?.tagline || analysis.summary_json?.tagline || analysis.meta_json?.tagline || '',
url: `https://livesiteaudit.agentabrams.com/wa/${analysis.slug}`,
};
return HEAD(`${analysis.slug} — Website Analysis`).replace('</head>', `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script></head>`) + `
<section style="padding:48px 24px 24px;border-bottom:4px solid #000">
<div style="max-width:1320px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:24px;flex-wrap:wrap">
<div>
<div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:8px">★ ANALYZING</div>
<h1 style="font-family:'Archivo Black',sans-serif;font-size:56px;line-height:.9;letter-spacing:-.03em;text-transform:uppercase">${esc(url.replace(/^https?:\/\//,'').replace(/\/$/,''))}</h1>
${ig ? `<div style="margin-top:8px;font-size:14px;font-weight:700">★ <a href="https://instagram.com/${esc(ig.replace(/^@/, ''))}" style="text-decoration:none;border-bottom:2px solid #000">${esc(ig)}</a></div>` : ''}
</div>
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<div title="Audit grade based on metadata completeness — title, tagline, palette, hero, IG, vertical detection, embedability"
style="display:flex;align-items:center;gap:14px;background:${grade.color};color:#000;border:4px solid #000;padding:8px 18px">
<div style="font-family:'Archivo Black',sans-serif;font-size:48px;line-height:1">${grade.letter}</div>
<div>
<div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ AUDIT</div>
<div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">${grade.score}/100</div>
</div>
</div>
<a href="/website-analysis" style="background:#000;color:#fff;padding:14px 24px;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;border:4px solid #000">← ANALYZE ANOTHER</a>
<form method="post" action="/api/wa/${esc(analysis.slug)}/deep-dive" style="display:inline;margin:0">
<button type="submit" title="Synthesize a mini site-audit report (SEO + content + composition checks, vertical-specific tips, suggested headlines)" style="background:#facc15;color:#000;padding:14px 24px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;border:4px solid #000;cursor:pointer">★ DEEP DIVE →</button>
</form>
</div>
</div>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:14px 0;border-top:2px solid #000;border-bottom:2px solid #000">
${verticalPill}
${swatches ? `<div style="display:flex;align-items:center;gap:8px"><div style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.06em">PALETTE${paletteSource === 'synthesized' ? ' (SYNTH)' : ''}</div><div style="display:flex;gap:0">${swatches}</div></div>` : ''}
${iframeBlocked ? '<div style="display:inline-block;background:#dc2626;color:#fff;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ NO IFRAME</div>' : '<div style="display:inline-block;background:#22c55e;color:#000;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ EMBEDABLE</div>'}
${analysis.business_id ? '<div style="display:inline-block;background:#facc15;color:#000;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ SHIPPED</div>' : ''}
</div>
</div>
</section>
${livePanel}
<section style="padding:32px 24px;background:var(--soft);border-bottom:4px solid #000">
<div style="max-width:1320px;margin:0 auto">
<h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;text-transform:uppercase;letter-spacing:-.02em;margin-bottom:24px">★ YOUR SITE — RIGHT NOW</h2>
${yourSite}
</div>
</section>
<section style="padding:64px 24px">
<div style="max-width:1320px;margin:0 auto">
<h2 style="font-family:'Archivo Black',sans-serif;font-size:48px;text-transform:uppercase;letter-spacing:-.03em;margin-bottom:8px">★ 3 NEW DIRECTIONS</h2>
<p style="font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:32px">★ SAME CONTENT — THREE COMPLETELY DIFFERENT AESTHETIC SYSTEMS</p>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:24px">${cards}</div>
</div>
</section>
${mockups.length >= 2 ? `
<section style="padding:64px 24px;background:#000;color:#fafaf6;border-top:4px solid #facc15;border-bottom:4px solid #000">
<div style="max-width:1320px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:18px;margin-bottom:24px">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:6px">★ A/B SLIDER</div>
<h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;color:#fff">DRAG TO COMPARE</h2>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${mockups.slice(0, 6).map((m, i) => `<button class="ab-pick" data-side="left" data-id="${esc(m.mockup_id)}" data-label="${esc(m.theme.toUpperCase())}" style="background:${i === 0 ? '#facc15' : '#222'};color:${i === 0 ? '#000' : '#facc15'};padding:8px 12px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;border:2px solid #facc15;cursor:pointer;text-transform:uppercase;letter-spacing:.04em">${esc(m.theme.toUpperCase())}</button>`).join('')}
</div>
</div>
<div id="ab-slider" style="position:relative;border:4px solid #facc15;background:#fff;aspect-ratio:16/9;overflow:hidden;user-select:none">
<iframe id="ab-left" src="/wa/${esc(analysis.slug)}/m/${esc(mockups[0].mockup_id)}" style="position:absolute;inset:0;width:100%;height:100%;border:0" loading="lazy" sandbox="allow-same-origin"></iframe>
<div id="ab-right-wrap" style="position:absolute;inset:0;width:50%;overflow:hidden;border-right:3px solid #facc15">
<iframe id="ab-right" src="/wa/${esc(analysis.slug)}/m/${esc(mockups[1].mockup_id)}" style="position:absolute;inset:0;width:200%;height:100%;border:0" loading="lazy" sandbox="allow-same-origin"></iframe>
</div>
<div id="ab-handle" style="position:absolute;top:0;bottom:0;left:50%;width:6px;background:#facc15;cursor:ew-resize;z-index:10;transform:translateX(-50%)">
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#000;color:#facc15;width:48px;height:48px;border:3px solid #facc15;display:flex;align-items:center;justify-content:center;font-family:'Archivo Black',sans-serif;font-size:24px">↔</div>
</div>
<div style="position:absolute;top:14px;left:14px;background:#000;color:#facc15;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;border:2px solid #facc15;z-index:5">★ <span id="ab-label-left">${esc(mockups[0].theme.toUpperCase())}</span></div>
<div style="position:absolute;top:14px;right:14px;background:#000;color:#facc15;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;border:2px solid #facc15;z-index:5"><span id="ab-label-right">${esc(mockups[1].theme.toUpperCase())}</span> ★</div>
</div>
<p style="margin-top:14px;font-size:12px;font-weight:700;letter-spacing:.04em;color:#aaa;text-transform:uppercase">★ DRAG THE GOLD HANDLE LEFT/RIGHT — TAP THE THEME CHIPS TO SWAP EITHER SIDE</p>
</div>
</section>
<script>
(function(){
const wrap = document.getElementById('ab-right-wrap');
const handle = document.getElementById('ab-handle');
const slider = document.getElementById('ab-slider');
const leftIfr = document.getElementById('ab-left');
const rightIfr = document.getElementById('ab-right');
const labL = document.getElementById('ab-label-left');
const labR = document.getElementById('ab-label-right');
if (!wrap || !handle || !slider) return;
let dragging = false;
function setPos(pct) {
pct = Math.max(2, Math.min(98, pct));
wrap.style.width = pct + '%';
handle.style.left = pct + '%';
rightIfr.style.width = (10000 / pct) + '%';
}
function onMove(e) {
if (!dragging) return;
const rect = slider.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
setPos((x / rect.width) * 100);
}
handle.addEventListener('mousedown', () => { dragging = true; });
handle.addEventListener('touchstart', () => { dragging = true; }, {passive:true});
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, {passive:true});
document.addEventListener('mouseup', () => { dragging = false; });
document.addEventListener('touchend', () => { dragging = false; });
slider.addEventListener('click', (e) => {
if (e.target === handle || handle.contains(e.target)) return;
const rect = slider.getBoundingClientRect();
setPos(((e.clientX - rect.left) / rect.width) * 100);
});
// Theme chip clicks: alternate left/right slot.
let nextSide = 'right';
document.querySelectorAll('.ab-pick').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const label = btn.dataset.label;
const slug = '${esc(analysis.slug)}';
if (nextSide === 'right') {
rightIfr.src = '/wa/' + slug + '/m/' + id;
labR.textContent = label;
nextSide = 'left';
} else {
leftIfr.src = '/wa/' + slug + '/m/' + id;
labL.textContent = label;
nextSide = 'right';
}
document.querySelectorAll('.ab-pick').forEach(b => {
b.style.background = '#222'; b.style.color = '#facc15';
});
btn.style.background = '#facc15'; btn.style.color = '#000';
});
});
})();
</script>
` : ''}
${analysis.summary_json?.audit_report ? renderAuditReport(analysis.summary_json.audit_report) : ''}
` + FOOT;
}
function renderAuditReport(r) {
if (!r || !r.sections) return '';
const lvl = r.score >= 70 ? '#22c55e' : r.score >= 40 ? '#facc15' : '#dc2626';
const findings = (label, sec) => {
if (!sec.findings || !sec.findings.length) return '';
return `<div style="border-top:2px solid #000;padding:16px 0">
<div style="font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;display:flex;justify-content:space-between"><span>★ ${esc(label)}</span><span style="color:${sec.score_delta >= 0 ? '#22c55e' : '#dc2626'}">${sec.score_delta >= 0 ? '+' : ''}${sec.score_delta}</span></div>
<ul style="list-style:none;font-size:13px;line-height:1.5">
${sec.findings.map(f => `<li style="padding:4px 0;display:flex;gap:10px"><span style="color:${f.pts >= 0 ? '#22c55e' : '#dc2626'};font-weight:700;min-width:32px">${f.pts >= 0 ? '+' : ''}${f.pts}</span><span>${esc(f.msg)}</span></li>`).join('')}
</ul>
</div>`;
};
return `<section id="audit-report" style="padding:64px 24px;background:#000;color:#fff;border-top:4px solid #000">
<div style="max-width:980px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:32px;flex-wrap:wrap">
<div>
<div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:8px">★ DEEP DIVE</div>
<h2 style="font-family:'Archivo Black',sans-serif;font-size:56px;text-transform:uppercase;letter-spacing:-.03em">AUDIT REPORT.</h2>
<div style="font-size:11px;font-family:'Space Mono',monospace;color:#888;margin-top:4px">★ GENERATED ${esc(new Date(r.generated_at).toISOString().slice(0,16).replace('T',' '))}</div>
</div>
<div style="display:flex;align-items:center;gap:14px;background:${lvl};color:#000;border:4px solid #fff;padding:14px 24px">
<div style="font-family:'Archivo Black',sans-serif;font-size:64px;line-height:1">${r.score}</div>
<div>
<div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ AUDIT SCORE</div>
<div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">/100</div>
</div>
</div>
</div>
<div style="background:#fff;color:#000;padding:24px;border:4px solid #fff">
${findings('SEO', r.sections.seo)}
${findings('CONTENT', r.sections.content)}
${findings('COMPOSITION', r.sections.composition)}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:32px">
<div style="background:#facc15;color:#000;padding:24px;border:4px solid #fff">
<div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ HEADLINES TO TRY</div>
${(r.suggested_headlines||[]).map((h, i) => `<div style="padding:10px 0;border-bottom:2px solid #000;font-size:14px;font-weight:700;line-height:1.35"><span style="font-family:'Archivo Black',sans-serif;margin-right:8px">${i+1}.</span>${esc(h)}</div>`).join('')}
</div>
<div style="background:#fff;color:#000;padding:24px;border:4px solid #fff">
<div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ VERTICAL TIPS</div>
${(r.vertical_tips||[]).map((t, i) => `<div style="padding:10px 0;border-bottom:2px solid #000;font-size:13px;line-height:1.5"><span style="font-family:'Archivo Black',sans-serif;margin-right:8px">${String.fromCharCode(65+i)}.</span>${esc(t)}</div>`).join('')}
</div>
</div>
${r.quick_wins?.length ? `<div style="margin-top:32px;background:#dc2626;color:#fff;padding:24px;border:4px solid #fff">
<div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ ${r.quick_wins.length} QUICK WINS</div>
<ul style="list-style:none;font-size:13px;line-height:1.5">${r.quick_wins.map(q => `<li style="padding:6px 0;border-bottom:1px solid #fff8">→ ${esc(q)}</li>`).join('')}</ul>
</div>` : ''}
</div>
</section>`;
}