Files
Momento/memento-note/lib/ai/tools/slides-html-builder.ts
Antigravity 556a0b2f3f feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
2026-07-16 20:43:18 +00:00

496 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Recipe> = {
'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']
// Normalize: underscores to dashes for consistency
const key = name.toLowerCase().replace(/[\s_]/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 `<div class="slide" data-slide="${idx}">
${mesh(r)}
<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>
<div class="content" style="text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
<div class="reveal" style="width:52px;height:5px;background:${r.accent1};border-radius:3px;margin-bottom:24px;"></div>
<h1 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(2.8rem,5vw,4.5rem);font-weight:900;line-height:1.05;letter-spacing:-0.04em;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin:0;max-width:900px;">${esc(slide.title)}</h1>
${slide.subtitle ? `<p class="reveal" style="font-size:clamp(1rem,2vw,1.4rem);color:${r.textSecondary};margin-top:20px;max-width:640px;line-height:1.5;">${esc(slide.subtitle)}</p>` : ''}
</div>
</div>`
}
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
const list = (slide.items || []).filter(Boolean)
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
const twoCol = list.length >= 4
const items = list.map((item) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
</div>
</div>`
}
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 `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0;">${esc(slide.title)}</h2>
${slide.subtitle ? `<p class="reveal" style="font-size:0.95rem;color:${r.textMuted};margin:6px 0 0;">${esc(slide.subtitle)}</p>` : ''}
<div class="reveal" style="margin-top:32px;">${chartHtml}</div>
</div>
</div>`
}
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
// Never force empty columns: 1 stat = full width, 2 = half, 34 = equal
const n = Math.max(1, stats.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
const items = stats.map(s => `
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
const ths = slide.headers.map(h => `<th style="padding:12px 16px;text-align:left;font-size:0.75rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${r.accent1};border-bottom:2px solid ${r.accent1};">${esc(h)}</th>`).join('')
const rows = slide.rows.map((row, ri) => {
const tds = row.map(cell => `<td style="padding:11px 16px;font-size:0.9rem;color:${r.text};border-bottom:1px solid ${r.glassBorder};">${esc(cell)}</td>`).join('')
return `<tr style="background:${ri % 2 === 0 ? 'transparent' : r.glassBg};">${tds}</tr>`
}).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:24px;"></div>
<div class="reveal" style="overflow:auto;border-radius:12px;border:1px solid ${r.glassBorder};">
<table style="width:100%;border-collapse:collapse;"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>
</div>
</div>
</div>`
}
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
const n = Math.max(1, cards.length)
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
const items = cards.map((c, i) => `
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
</div>
</div>`
}
function renderTimeline(slide: SlideTimeline, r: Recipe, idx: number): string {
const items = slide.events.map((ev, i) => `
<div class="reveal" style="display:flex;align-items:flex-start;gap:20px;position:relative;padding-left:20px;">
<div style="position:absolute;left:0;top:8px;width:12px;height:12px;border-radius:50%;background:${r.accent1};border:3px solid ${r.bg};z-index:1;"></div>
<div>
<span style="font-size:0.7rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;color:${r.accent1};">${esc(ev.date)}</span>
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-top:2px;">${esc(ev.title)}</div>
${ev.description ? `<div style="font-size:0.85rem;color:${r.textSecondary};margin-top:4px;line-height:1.5;">${esc(ev.description)}</div>` : ''}
</div>
</div>`).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:flex;flex-direction:column;gap:20px;border-left:2px solid ${r.accent1};padding-left:8px;margin-left:6px;">${items}</div>
</div>
</div>`
}
function renderQuote(slide: SlideQuote, r: Recipe, idx: number): string {
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content" style="display:flex;flex-direction:column;justify-content:center;height:100%;">
<div class="reveal" style="font-size:5rem;color:${r.accent1};opacity:0.4;line-height:0.6;font-family:Georgia,serif;">"</div>
<blockquote class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.5rem,3vw,2.2rem);font-weight:600;font-style:italic;line-height:1.4;letter-spacing:-0.02em;color:${r.text};margin:16px 0 0;max-width:860px;">${esc(slide.quote)}</blockquote>
${slide.author ? `<div class="reveal" style="display:flex;align-items:center;gap:12px;margin-top:28px;"><div style="width:36px;height:2px;background:${r.accent1};"></div><span style="font-size:0.85rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:${r.accent1};">— ${esc(slide.author)}</span></div>` : ''}
${slide.context ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};margin-top:20px;line-height:1.7;max-width:700px;">${esc(slide.context)}</p>` : ''}
</div>
</div>`
}
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 => `<li style="display:flex;gap:8px;font-size:0.88rem;color:${r.textSecondary};line-height:1.5;"><span style="color:${accent};font-weight:700;flex-shrink:0;">✓</span>${esc(p)}</li>`).join('')
return `<div class="reveal" style="padding:28px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;border-top:3px solid ${accent};">
<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.2em;color:${accent};margin-bottom:14px;">${esc(side.title)}</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px;">${pts}</ul>
${side.score ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid ${r.glassBorder};"><div style="font-size:1.5rem;font-weight:900;color:${accent};">${esc(side.score)}</div></div>` : ''}
</div>`
}
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">${col(slide.left, r.accent1)}${col(slide.right, r.accent2)}</div>
</div>
</div>`
}
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
const eqs = (slide.equations || []).map((eq) => {
// data-latex for KaTeX client render; also show fallback monospaced text
const latex = eq.latex || ''
return `
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
</div>`
}).join('')
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
</div>
</div>`
}
function renderImage(slide: SlideImage, r: Recipe, idx: number): string {
const img = slide.url
? `<img src="${esc(slide.url)}" alt="${esc(slide.title)}" style="max-width:80%;max-height:60vh;border-radius:12px;object-fit:contain;" />`
: `<div style="width:400px;height:250px;background:${r.glassBg};border:1px dashed ${r.glassBorder};border-radius:12px;display:flex;align-items:center;justify-content:center;color:${r.textMuted};font-size:0.9rem;">Image placeholder</div>`
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
<div class="content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
<div class="reveal">${img}</div>
${slide.caption ? `<p class="reveal" style="font-size:0.85rem;color:${r.textMuted};margin-top:16px;text-align:center;">${esc(slide.caption)}</p>` : ''}
</div>
</div>`
}
function renderSummary(slide: SlideSummary, r: Recipe, idx: number, isLast: boolean): string {
const items = slide.items.map(item => `
<div class="reveal" style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
<div style="width:26px;height:26px;min-width:26px;background:${r.accent1};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;color:#fff;font-weight:900;">✓</div>
<span style="font-size:1rem;line-height:1.45;color:${r.text};">${esc(item)}</span>
</div>`).join('')
const canvas = isLast ? `<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>` : ''
return `<div class="slide" data-slide="${idx}">
${mesh(r)}
${canvas}
<div class="content">
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
<div style="display:flex;flex-direction:column;gap:12px;">${items}</div>
</div>
</div>`
}
// ── 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 `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
</div>`
}).join('')
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
}
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 `<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
<span style="width:100px;text-align:right;font-size:0.8rem;color:${r.textSecondary};flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
<div style="flex:1;background:${r.glassBg};border-radius:6px;height:28px;overflow:hidden;">
<div class="bar-fill" data-width="${pct}" style="height:100%;border-radius:6px;width:0%;background:linear-gradient(to right,${r.accent1},${r.accent2});"></div>
</div>
<span style="width:40px;font-size:0.8rem;font-weight:700;color:${r.text};">${d.value}</span>
</div>`
}).join('')
return `<div>${bars}</div>`
}
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 `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${r.svgGrid}" stroke-width="1"/>`
}).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 `<circle cx="${x}" cy="${y}" r="4" fill="${r.accent1}" stroke="${r.bg}" stroke-width="2"/>`
}).join('')
const labels = data.map((d, i) => {
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
${gridLines}
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
${dots}${labels}
</svg>`
}
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 = `<circle cx="100" cy="100" r="80" fill="none" stroke="${colors[i % colors.length]}" stroke-width="20" stroke-dasharray="${dashLen} 502" stroke-dashoffset="${-offset}" transform="rotate(-90 100 100)"/>`
offset += dashLen
return ring
}).join('')
const legend = data.map((d, i) => `<div style="display:flex;align-items:center;gap:8px;"><div style="width:10px;height:10px;border-radius:50%;background:${colors[i % colors.length]};"></div><span style="font-size:0.8rem;color:${r.textSecondary};">${esc(d.label)} (${Math.round((d.value / total) * 100)}%)</span></div>`).join('')
return `<div style="display:flex;align-items:center;gap:40px;justify-content:center;">
<svg viewBox="0 0 200 200" style="width:180px;height:180px;">${rings}<text x="100" y="105" text-anchor="middle" font-size="22" font-weight="900" fill="${r.text}">${total}</text></svg>
<div style="display:flex;flex-direction:column;gap:8px;">${legend}</div>
</div>`
}
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
// Choose grid color based on theme darkness
const gridColor = r.isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'
const labelColor = r.isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'
// 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 `<polygon points="${pts}" fill="none" stroke="${gridColor}" stroke-width="1"/>`
}).join('')
// Axis lines from center
const axisLines = Array.from({ length: n }, (_, i) => {
const a = i * angleStep - Math.PI / 2
return `<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(a) * radius}" y2="${cy + Math.sin(a) * radius}" stroke="${gridColor}" stroke-width="1"/>`
}).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 with better contrast
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 `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" font-weight="600" fill="${labelColor}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
${axisLines}
${gridLevels}
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.2" stroke="${r.accent1}" stroke-width="2.5"/>
${labels}
</svg>`
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s: string): string { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
function mesh(r: Recipe): string {
return `<div class="gradient-mesh"><div class="blob" style="width:500px;height:500px;top:-100px;right:-100px;background:${r.accent1};opacity:0.12;--dur:18s;"></div><div class="blob" style="width:300px;height:300px;bottom:-80px;left:-60px;background:${r.accent2};opacity:0.09;--dur:14s;"></div></div>`
}
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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>${esc(input.title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="${r.fontUrl}" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<style>
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
*,*::before,*::after{box-sizing:border-box;}
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
.eq-latex{max-width:100%;}
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
.nav-controls{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,0.45);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:8px 16px;}
.nav-dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,0.2);border:none;cursor:pointer;transition:all 0.3s;padding:0;flex-shrink:0;}
.nav-dot.active{background:var(--color-accent-1);transform:scale(1.3);box-shadow:0 0 8px ${r.accent1}66;}
.nav-btn{background:none;border:none;color:rgba(255,255,255,0.5);font-size:20px;cursor:pointer;padding:0 4px;}
.nav-btn:hover{color:var(--color-accent-1);}
.slide-counter{font-size:0.75rem;color:rgba(255,255,255,0.4);font-variant-numeric:tabular-nums;min-width:44px;text-align:center;}
.bar{border-radius:6px 6px 0 0;transition:height 0.8s cubic-bezier(0.34,1.56,0.64,1);}
.bar-fill{transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);}
.line-path{stroke-dasharray:9999;stroke-dashoffset:9999;transition:stroke-dashoffset 1.2s ease;}
</style>
</head>
<body>
<div class="deck">
${slidesHtml}
</div>
<div class="nav-controls" id="nav-controls">
<button class="nav-btn" onclick="changeSlide(-1)">&#8249;</button>
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
<button class="nav-btn" onclick="changeSlide(1)">&#8250;</button>
<span class="slide-counter" id="slide-counter">1 / ${total}</span>
</div>
<script>
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
// Particles
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
</script>
</body>
</html>`
}