/** * Server-side HTML renderer for presentations. * Takes a structured JSON spec and produces a complete standalone HTML file. * The AI only needs to output data — this module handles all the visual rendering. */ // ── Recipe definitions ────────────────────────────────────────────────────── interface Recipe { bg: string; text: string; textSecondary: string; textMuted: string accent1: string; accent2: string glassBg: string; glassBorder: string; svgGrid: string fontDisplay: string; fontBody: string; fontUrl: string isDark: boolean } const RECIPES: Record = { 'midnight-cathedral': { bg: '#0a0f1e', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#C9A84C', accent2: '#E8D5B5', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: true }, 'aurora-borealis': { bg: '#0f0a2a', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#7C3AED', accent2: '#06B6D4', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Instrument Serif'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true }, 'tokyo-neon': { bg: '#0a0a0f', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#FF006E', accent2: '#3A86FF', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Bebas Neue'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true }, 'sunlit-gallery': { bg: '#FAF7F0', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#D4A574', accent2: '#5B9BD5', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Abril Fatface'", fontBody: "'Epilogue'", fontUrl: 'https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Epilogue:wght@300;400;600;700;900&display=swap', isDark: false }, 'clinical-precision': { bg: '#F8FAFC', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#0891B2', accent2: '#34D399', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Manrope'", fontBody: "'Manrope'", fontUrl: 'https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;600;700;800;900&display=swap', isDark: false }, 'venture-pitch': { bg: '#18181B', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#F97316', accent2: '#14B8A6', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Archivo Black'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: true }, 'forest-floor': { bg: '#0D1B0E', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#22C55E', accent2: '#A3B18A', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Outfit'", fontBody: "'Outfit'", fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800;900&display=swap', isDark: true }, 'steel-glass': { bg: '#292524', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#D4C5A9', accent2: '#94A3B8', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Prata'", fontBody: "'Work Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Prata&family=Work+Sans:wght@300;400;600;700&display=swap', isDark: true }, 'cyberpunk-terminal': { bg: '#0A0A0A', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#00FF41', accent2: '#FFA500', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'JetBrains Mono'", fontBody: "'JetBrains Mono'", fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600;700;800&display=swap', isDark: true }, 'editorial-ink': { bg: '#FFFCF5', text: '#1A1A2E', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1A1A2E', accent2: '#800020', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'DM Serif Display'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: false }, 'coastal-morning': { bg: '#F0F7FF', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#2563EB', accent2: '#F97066', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Plus Jakarta Sans'", fontBody: "'Plus Jakarta Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700;800&display=swap', isDark: false }, 'paper-studio': { bg: '#FEFCF8', text: '#1E293B', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1E293B', accent2: '#C2410C', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Literata'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Literata:wght@400;700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: false }, 'architectural-saas': { bg: '#F2F0E9', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#A47148', accent2: '#4A4E69', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@300;400;600;700;900&display=swap', isDark: false }, } function resolveRecipe(name?: string): Recipe { if (!name || name === 'auto') return RECIPES['architectural-saas'] const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') return RECIPES[key] ?? RECIPES['architectural-saas'] } // ── Slide types ───────────────────────────────────────────────────────────── export interface SlideTitle { type: 'title'; title: string; subtitle?: string } export interface SlideBullets { type: 'bullets'; title: string; items: string[] } export interface SlideChart { type: 'chart'; title: string; chartType: 'bar' | 'horizontal-bar' | 'line' | 'donut' | 'radar'; data: { label: string; value: number; color?: string }[]; subtitle?: string } export interface SlideStats { type: 'stats'; title: string; stats: { value: string; label: string }[] } export interface SlideTable { type: 'table'; title: string; headers: string[]; rows: string[][] } export interface SlideCards { type: 'cards'; title: string; cards: { title: string; description: string }[] } export interface SlideTimeline { type: 'timeline'; title: string; events: { date: string; title: string; description?: string }[] } export interface SlideQuote { type: 'quote'; quote: string; author?: string; context?: string } export interface SlideComparison { type: 'comparison'; title: string; left: { title: string; points: string[]; score?: string }; right: { title: string; points: string[]; score?: string } } export interface SlideEquation { type: 'equation'; title: string; equations: { latex: string; label?: string }[]; explanation?: string } export interface SlideImage { type: 'image'; title: string; url?: string; caption?: string } export interface SlideSummary { type: 'summary'; title: string; items: string[] } export type SlideSpec = SlideTitle | SlideBullets | SlideChart | SlideStats | SlideTable | SlideCards | SlideTimeline | SlideQuote | SlideComparison | SlideEquation | SlideImage | SlideSummary export interface PresentationInput { title: string theme?: string slides: SlideSpec[] } // ── Individual slide renderers ────────────────────────────────────────────── function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string { return `
${mesh(r)}

${esc(slide.title)}

${slide.subtitle ? `

${esc(slide.subtitle)}

` : ''}
` } function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string { const items = slide.items.map((item, i) => `
${esc(item)}
`).join('') return `
${mesh(r)}

${esc(slide.title)}

${items}
` } function renderChart(slide: SlideChart, r: Recipe, idx: number): string { const chartHtml = (() => { switch (slide.chartType) { case 'bar': return renderBarChart(slide.data, r) case 'horizontal-bar': return renderHBarChart(slide.data, r) case 'line': return renderLineChart(slide.data, r) case 'donut': return renderDonutChart(slide.data, r) case 'radar': return renderRadarChart(slide.data, r) default: return renderBarChart(slide.data, r) } })() return `
${mesh(r)}

${esc(slide.title)}

${slide.subtitle ? `

${esc(slide.subtitle)}

` : ''}
${chartHtml}
` } function renderStats(slide: SlideStats, r: Recipe, idx: number): string { const cols = Math.min(slide.stats.length, 4) const items = slide.stats.map(s => `
${esc(s.value)}
${esc(s.label)}
`).join('') return `
${mesh(r)}

${esc(slide.title)}

${items}
` } function renderTable(slide: SlideTable, r: Recipe, idx: number): string { const ths = slide.headers.map(h => `${esc(h)}`).join('') const rows = slide.rows.map((row, ri) => { const tds = row.map(cell => `${esc(cell)}`).join('') return `${tds}` }).join('') return `
${mesh(r)}

${esc(slide.title)}

${ths}${rows}
` } function renderCards(slide: SlideCards, r: Recipe, idx: number): string { const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3 const items = slide.cards.map((c, i) => `
${String(i + 1).padStart(2, '0')}
${esc(c.title)}
${esc(c.description)}
`).join('') return `
${mesh(r)}

${esc(slide.title)}

${items}
` } function renderTimeline(slide: SlideTimeline, r: Recipe, idx: number): string { const items = slide.events.map((ev, i) => `
${esc(ev.date)}
${esc(ev.title)}
${ev.description ? `
${esc(ev.description)}
` : ''}
`).join('') return `
${mesh(r)}

${esc(slide.title)}

${items}
` } function renderQuote(slide: SlideQuote, r: Recipe, idx: number): string { return `
${mesh(r)}
"
${esc(slide.quote)}
${slide.author ? `
— ${esc(slide.author)}
` : ''} ${slide.context ? `

${esc(slide.context)}

` : ''}
` } function renderComparison(slide: SlideComparison, r: Recipe, idx: number): string { const col = (side: { title: string; points: string[]; score?: string }, accent: string) => { const pts = side.points.map(p => `
  • ${esc(p)}
  • `).join('') return `
    ${esc(side.title)}
    ${side.score ? `
    ${esc(side.score)}
    ` : ''}
    ` } return `
    ${mesh(r)}

    ${esc(slide.title)}

    ${col(slide.left, r.accent1)}${col(slide.right, r.accent2)}
    ` } function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string { const eqs = slide.equations.map(eq => `
    ${esc(eq.latex)}
    ${eq.label ? `
    ${esc(eq.label)}
    ` : ''}
    `).join('') return `
    ${mesh(r)}

    ${esc(slide.title)}

    ${eqs} ${slide.explanation ? `

    ${esc(slide.explanation)}

    ` : ''}
    ` } function renderImage(slide: SlideImage, r: Recipe, idx: number): string { const img = slide.url ? `${esc(slide.title)}` : `
    Image placeholder
    ` return `
    ${mesh(r)}

    ${esc(slide.title)}

    ${img}
    ${slide.caption ? `

    ${esc(slide.caption)}

    ` : ''}
    ` } function renderSummary(slide: SlideSummary, r: Recipe, idx: number, isLast: boolean): string { const items = slide.items.map(item => `
    ${esc(item)}
    `).join('') const canvas = isLast ? `` : '' return `
    ${mesh(r)} ${canvas}

    ${esc(slide.title)}

    ${items}
    ` } // ── Chart renderers ───────────────────────────────────────────────────────── function renderBarChart(data: { label: string; value: number }[], r: Recipe): string { const max = Math.max(...data.map(d => d.value), 1) const bars = data.map(d => { const pct = Math.round((d.value / max) * 100) return `
    ${d.value}
    ${esc(d.label)}
    ` }).join('') return `
    ${bars}
    ` } function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string { const max = Math.max(...data.map(d => d.value), 1) const bars = data.map(d => { const pct = Math.round((d.value / max) * 100) return `
    ${esc(d.label)}
    ${d.value}
    ` }).join('') return `
    ${bars}
    ` } function renderLineChart(data: { label: string; value: number }[], r: Recipe): string { const max = Math.max(...data.map(d => d.value), 1) const w = 600, h = 200, pad = 50 const points = data.map((d, i) => { const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad) const y = h - pad - ((d.value / max) * (h - 2 * pad)) return `${x},${y}` }) const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p}`).join(' ') const areaD = `${pathD} L${pad + ((data.length - 1) / Math.max(data.length - 1, 1)) * (w - 2 * pad)},${h - pad} L${pad},${h - pad} Z` const gridLines = [0.25, 0.5, 0.75].map(f => { const y = h - pad - f * (h - 2 * pad) return `` }).join('') const dots = data.map((d, i) => { const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad) const y = h - pad - ((d.value / max) * (h - 2 * pad)) return `` }).join('') const labels = data.map((d, i) => { const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad) return `${esc(d.label)}` }).join('') return ` ${gridLines} ${dots}${labels} ` } function renderDonutChart(data: { label: string; value: number }[], r: Recipe): string { const total = data.reduce((s, d) => s + d.value, 0) || 1 const colors = [r.accent1, r.accent2, '#10b981', '#f59e0b', '#ef4444', '#6366f1'] let offset = 0 const rings = data.map((d, i) => { const pct = (d.value / total) * 100 const dashLen = (pct / 100) * 502 const ring = `` offset += dashLen return ring }).join('') const legend = data.map((d, i) => `
    ${esc(d.label)} (${Math.round((d.value / total) * 100)}%)
    `).join('') return `
    ${rings}${total}
    ${legend}
    ` } function renderRadarChart(data: { label: string; value: number }[], r: Recipe): string { const n = data.length const cx = 150, cy = 150, radius = 110 const max = Math.max(...data.map(d => d.value), 1) const angleStep = (2 * Math.PI) / n // Grid const gridLevels = [0.25, 0.5, 0.75, 1].map(f => { const pts = Array.from({ length: n }, (_, i) => { const a = i * angleStep - Math.PI / 2 return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}` }).join(' ') return `` }).join('') // Data polygon const dataPts = data.map((d, i) => { const a = i * angleStep - Math.PI / 2 const r2 = (d.value / max) * radius return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}` }).join(' ') // Labels const labels = data.map((d, i) => { const a = i * angleStep - Math.PI / 2 const lx = cx + Math.cos(a) * (radius + 20) const ly = cy + Math.sin(a) * (radius + 20) return `${esc(d.label)}` }).join('') return ` ${gridLevels} ${labels} ` } // ── Helpers ───────────────────────────────────────────────────────────────── function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function mesh(r: Recipe): string { return `
    ` } function extractNum(s: string): string { const m = s.match(/[\d.]+/); return m ? m[0] : '0' } function extractSuffix(s: string): string { const m = s.match(/[\d.]+(.*)/); return m ? m[1].trim() : '' } // ── Render a single slide by type ─────────────────────────────────────────── function renderSlide(slide: SlideSpec, r: Recipe, idx: number, total: number): string { switch (slide.type) { case 'title': return renderTitle(slide, r, idx) case 'bullets': return renderBullets(slide, r, idx) case 'chart': return renderChart(slide, r, idx) case 'stats': return renderStats(slide, r, idx) case 'table': return renderTable(slide, r, idx) case 'cards': return renderCards(slide, r, idx) case 'timeline': return renderTimeline(slide, r, idx) case 'quote': return renderQuote(slide, r, idx) case 'comparison': return renderComparison(slide, r, idx) case 'equation': return renderEquation(slide, r, idx) case 'image': return renderImage(slide, r, idx) case 'summary': return renderSummary(slide, r, idx, idx === total) default: return renderBullets({ type: 'bullets', title: (slide as any).title || 'Slide', items: (slide as any).items || (slide as any).content || ['Content'] }, r, idx) } } // ── Main export: build full HTML from spec ────────────────────────────────── export function buildPresentationHTML(input: PresentationInput): string { const r = resolveRecipe(input.theme) const total = input.slides.length const slidesHtml = input.slides.map((s, i) => renderSlide(s, r, i + 1, total)).join('\n') return ` ${esc(input.title)}
    ${slidesHtml}
    ` }