Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
480 lines
39 KiB
TypeScript
480 lines
39 KiB
TypeScript
/**
|
|
* 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 items = slide.items.map((item, i) => `
|
|
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
|
|
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
|
|
<span style="font-size:1.1rem;line-height:1.65;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.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:32px;"></div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;">${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 cols = Math.min(slide.stats.length, 4)
|
|
const items = slide.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.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:32px;"></div>
|
|
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${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 cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
|
|
const items = slide.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;">
|
|
<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:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
|
<div style="font-size:0.88rem;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.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:32px;"></div>
|
|
<div style="display:grid;grid-template-columns:repeat(${cols},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 => `
|
|
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
|
|
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
|
|
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${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.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>
|
|
${eqs}
|
|
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }
|
|
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">
|
|
<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;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
|
.deck{width:100vw;height:100vh;position:relative;}
|
|
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s 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:1100px;padding:clamp(1.5rem,4vw,4rem);}
|
|
.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)">‹</button>
|
|
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
|
|
<button class="nav-btn" onclick="changeSlide(1)">›</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 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);}
|
|
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);},300);}
|
|
// 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>`
|
|
}
|