feat: page interactive, démos Play/Step et simulateur Carnot

Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,412 @@
'use client'
import { useEffect, useState } from 'react'
import { Clapperboard, Loader2, Globe } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { PageView } from '@/components/interactive-page/page-view'
import {
generateInteractivePage,
generateInteractivePagePlan,
generateInteractivePageSection,
type PagePlan,
} from '@/lib/ai/services/interactive-page-client.service'
import {
validateInteractivePage,
type PageSection,
type PageSpecV1,
} from '@/lib/interactive-page'
import { useLanguage } from '@/lib/i18n'
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
function slugId(title: string): string {
const s = title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
return s ? `page.${s}` : 'page.generated'
}
/** Degraded section when its LLM call failed — page still completes. */
function fallbackSection(
sectionId: string,
plan: PagePlan['sections'][number],
lang: string
): PageSection {
const fr = lang.startsWith('fr')
return {
id: sectionId,
title: plan.title,
blocks: [
{ type: 'prose', md: plan.goal },
{
type: 'callout',
kind: 'note',
title: fr ? 'En bref' : 'In short',
md: plan.demoGoal || plan.goal,
},
],
}
}
function assemblePage(
plan: PagePlan,
sections: PageSection[],
lang: string
): PageSpecV1 {
return {
schemaVersion: 1,
id: slugId(plan.heroTitle),
lang,
hero: {
kicker: lang.startsWith('fr')
? 'EXPLAINER INTERACTIF'
: 'INTERACTIVE EXPLAINER',
title: plan.heroTitle,
subtitle: plan.heroSubtitle,
meta: lang.startsWith('fr')
? 'Généré depuis votre note'
: 'Generated from your note',
},
overview: {
lead: plan.overviewLead,
cards: plan.overviewCards.map((c) => ({
badge: c.badge,
title: c.title,
body: c.body,
intent: c.intent as any,
})),
},
sections,
}
}
/**
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
* → validate → preview → publish (pageSpec snapshot, no double quota).
* Falls back to the deterministic page when the LLM path is unavailable.
*/
export function InteractivePagePublishDialog({
open,
onOpenChange,
noteId,
content,
language,
onPublished,
}: {
open: boolean
onOpenChange: (open: boolean) => void
noteId: string
content: string
language: string
onPublished: (slug: string) => void
}) {
const { t } = useLanguage()
const [phase, setPhase] = useState<Phase>('idle')
const [page, setPage] = useState<PageSpecV1 | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<string | null>(null)
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!open || phase !== 'generating') {
if (!open) setElapsedSec(0)
return
}
const t0 = Date.now()
const id = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
}, 500)
return () => window.clearInterval(id)
}, [open, phase])
useEffect(() => {
if (!open) {
setPhase('idle')
setPage(null)
setError(null)
setProgress(null)
return
}
// Guard: empty content from editor race
const wordCount = content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 30) {
setPhase('error')
setError(
t('richTextEditor.publishInteractivePageTooShort') ||
'Note trop courte — ajoutez du contenu puis réessayez'
)
return
}
let cancelled = false
const run = async () => {
setPhase('generating')
setError(null)
setPage(null)
const legacyFallback = async (notice?: string) => {
const result = await generateInteractivePage({
content,
lang: language,
noteId,
})
if (cancelled) return
if (!result.ok) {
setPhase('error')
setError(
result.reason ||
result.error ||
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la page interactive'
)
if (result.quotaExceeded) {
toast.error(t('ai.quotaExceeded'))
}
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
if (notice) toast.info(notice)
setPage(result.page)
setPhase('preview')
}
// 1. LLM plan (billed)
setProgress(
t('richTextEditor.publishInteractivePagePlanning') ||
'Analyse du contenu — plan de la page…'
)
const planResult = await generateInteractivePagePlan({
content,
lang: language,
noteId,
})
if (cancelled) return
if (!planResult.ok) {
if (planResult.quotaExceeded) {
setPhase('error')
setError(planResult.error)
toast.error(t('ai.quotaExceeded'))
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
if (planResult.error === 'unsuitable_content') {
setPhase('error')
setError(
planResult.reason ||
t('richTextEditor.publishInteractivePageFailed') ||
'Contenu inadapté'
)
window.dispatchEvent(new Event('ai-usage-changed'))
return
}
// LLM plan unavailable → deterministic full page
await legacyFallback(
t('richTextEditor.publishInteractivePageFallback') ||
'Génération IA indisponible — page simplifiée affichée'
)
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
// 2. One LLM call per section, with real progress
const plan = planResult.plan
const sections: PageSection[] = []
let degraded = 0
for (let i = 0; i < plan.sections.length; i++) {
const planSection = plan.sections[i]
const sectionId = `s${i + 1}`
setProgress(
(
t('richTextEditor.publishInteractivePageSectionProgress') ||
'Section {current}/{total} : {title}'
)
.replace('{current}', String(i + 1))
.replace('{total}', String(plan.sections.length))
.replace('{title}', planSection.title)
)
const sectionResult = await generateInteractivePageSection({
content,
lang: language,
noteId,
pageTitle: plan.heroTitle,
sectionId,
section: planSection,
})
if (cancelled) return
if (sectionResult.ok) {
sections.push(sectionResult.section)
} else {
degraded += 1
sections.push(fallbackSection(sectionId, planSection, language))
}
}
// 3. Assemble + hard validation client-side
const candidate = assemblePage(plan, sections, language)
const validated = validateInteractivePage(candidate)
if (!validated.ok) {
await legacyFallback(
t('richTextEditor.publishInteractivePageFallback') ||
'Génération IA indisponible — page simplifiée affichée'
)
return
}
if (degraded > 0) {
toast.info(
t('richTextEditor.publishInteractivePagePartialFallback') ||
'Certaines sections ont été générées en mode simplifié'
)
}
setPage(validated.page)
setPhase('preview')
}
void run()
return () => {
cancelled = true
}
// Intentionally omit `t` — unstable identity cancels in-flight generation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, content, language, noteId])
const handlePublish = async () => {
if (!page || phase === 'publishing') return
setPhase('publishing')
try {
const res = await fetch('/api/notes/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
action: 'publish',
mode: 'interactive-page',
template: 'interactive-page',
language,
pageSpec: page,
}),
})
const data = await res.json()
if (!res.ok) {
toast.error(
data.reason ||
data.error ||
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la publication'
)
setPhase('preview')
return
}
toast.success(
t('richTextEditor.publishInteractivePageSuccess') ||
'Page interactive publiée !'
)
onPublished(data.slug)
onOpenChange(false)
} catch {
toast.error(
t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la publication'
)
setPhase('preview')
}
}
const generatingHint =
progress ||
(elapsedSec < 15
? t('richTextEditor.publishInteractivePageGenerating') ||
'Génération de la page…'
: elapsedSec < 40
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
'Construction des sections et démos…'
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle className="flex items-center gap-2 text-base">
<Clapperboard className="h-4 w-4 text-brand-accent" />
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</DialogTitle>
<DialogDescription className="text-xs">
{phase === 'generating'
? generatingHint
: t('richTextEditor.publishInteractivePagePreviewHint') ||
'Aperçu — vérifiez puis publiez sur lURL publique'}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
{phase === 'generating' ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
<p>{generatingHint}</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
{elapsedSec}s
</p>
</div>
) : null}
{phase === 'error' ? (
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
<p className="font-medium">
{t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la page interactive'}
</p>
<p className="mt-2 text-muted-foreground">{error}</p>
</div>
) : null}
{phase === 'preview' || phase === 'publishing' ? (
page ? <PageView page={page} demoMode="interactive" /> : null
) : null}
</div>
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={phase === 'publishing'}
>
{t('general.cancel') || 'Annuler'}
</Button>
<Button
type="button"
onClick={handlePublish}
disabled={phase !== 'preview' || !page}
className="gap-2"
>
{phase === 'publishing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
{t('richTextEditor.publishInteractivePageConfirm') ||
'Publier la page'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
import { AlertCircle } from 'lucide-react'
/**
* Public / preview shell for published interactive pages.
* Parses stored PageSpecV1 JSON from `publishedContent`.
*/
export function InteractivePublishedPage({
publishedContent,
isStale,
}: {
publishedContent: string
isStale?: boolean
}) {
let page: PageSpecV1 | null = null
let error: string | null = null
try {
const raw = JSON.parse(publishedContent)
const result = validateInteractivePage(raw)
if (result.ok) page = result.page
else error = result.issues[0]?.message || 'PageSpec invalide'
} catch {
error = 'JSON de page interactive illisible'
}
if (!page) {
return (
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p>{error || 'Page interactive indisponible'}</p>
</div>
)
}
return (
<div>
{isStale ? (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
Le contenu source a évolué cette page interactive est à régénérer.
</div>
) : null}
<PageView page={page} demoMode="interactive" />
</div>
)
}

View File

@@ -0,0 +1,382 @@
'use client'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
import { SimBlockView } from '@/components/interactive-page/sim-block'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
import type { PageBlock } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
const set = new Set<IntentId>()
for (const panel of demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
for (const act of demo.acts) {
for (const step of act.steps) {
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
}
}
return [...set]
}
const DEMO_INTENT_LABELS: Record<IntentId, { fr: string; en: string }> = {
highlight: { fr: 'Focus', en: 'Focus' },
flow: { fr: 'Flux', en: 'Flow' },
cache: { fr: 'Mémoire', en: 'Memory' },
compute: { fr: 'Calcul', en: 'Compute' },
output: { fr: 'Résultat', en: 'Result' },
warning: { fr: 'Attention', en: 'Warning' },
}
function DemoLegend({ demo, lang }: { demo: InteractiveDemoV1; lang: string }) {
const dark = useDarkMode()
const fr = lang.startsWith('fr')
const intents = collectDemoIntents(demo)
if (!intents.length) return null
return (
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{fr ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => (
<span key={id} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: intentColor(id, dark) }}
/>
{fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
</span>
))}
</div>
)
}
const CALLOUT_STYLES: Record<
string,
{ border: string; bg: string; badge: string }
> = {
definition: {
border: 'border-sky-500/30',
bg: 'bg-sky-500/5',
badge: 'text-sky-700 dark:text-sky-300',
},
warning: {
border: 'border-amber-500/35',
bg: 'bg-amber-500/5',
badge: 'text-amber-800 dark:text-amber-300',
},
tip: {
border: 'border-emerald-500/30',
bg: 'bg-emerald-500/5',
badge: 'text-emerald-800 dark:text-emerald-300',
},
note: {
border: 'border-border',
bg: 'bg-muted/40',
badge: 'text-muted-foreground',
},
}
function IntentBadge({
label,
intent,
}: {
label: string
intent?: IntentId
}) {
const dark = useDarkMode()
const color = intentColor(intent, dark)
return (
<span
className="inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
style={{
color,
backgroundColor: `${color}22`,
border: `1px solid ${color}44`,
}}
>
{label}
</span>
)
}
function ChartBlockView({
block,
}: {
block: Extract<PageBlock, { type: 'chart' }>
}) {
const dark = useDarkMode()
const series = block.payload.series
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
const data = Array.from({ length: maxLen }, (_, i) => {
const row: Record<string, number | string> = { i: String(i + 1) }
for (const s of series) row[s.id] = s.values[i] ?? 0
return row
})
const Chart =
block.payload.chartType === 'bar'
? BarChart
: block.payload.chartType === 'area'
? AreaChart
: LineChart
return (
<figure
className="my-6 rounded-xl border p-4"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="h-56 min-h-[14rem] min-w-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis dataKey="i" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} width={36} axisLine={false} tickLine={false} />
<Tooltip />
{series.map((s) => {
const stroke = intentColor(s.intent, dark)
if (block.payload.chartType === 'bar') {
return (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
)
}
if (block.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={0.22}
strokeWidth={2}
isAnimationActive={false}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
{block.caption ? (
<figcaption className="mt-3 text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
export function PageBlockView({
block,
demoMode = 'static',
lang = 'fr',
}: {
block: PageBlock
demoMode?: 'interactive' | 'static'
lang?: string
}) {
if (block.type === 'prose') {
return <PageMd md={block.md} className="my-4 text-[17px] leading-[1.7]" />
}
if (block.type === 'formula') {
return <PageFormula tex={block.tex} caption={block.caption} />
}
if (block.type === 'callout') {
const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
return (
<aside
className={cn(
'my-5 rounded-xl border px-4 py-3.5 shadow-sm',
style.border,
style.bg
)}
>
<p
className={cn(
'mb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]',
style.badge
)}
>
{block.kind} · {block.title}
</p>
<PageMd md={block.md} className="text-[15px] [&_p]:my-1" />
</aside>
)
}
if (block.type === 'demo') {
return (
<figure className="my-8">
{block.caption ? (
<p className="mb-3 text-[15px] leading-relaxed text-muted-foreground">
{block.caption}
</p>
) : null}
<InteractiveDemoPlayer demo={block.demo} mode={demoMode} />
<DemoLegend demo={block.demo} lang={lang} />
{demoMode === 'static' ? (
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm text-muted-foreground">
{block.demo.acts.flatMap((a) =>
a.steps.map((s) => (
<li key={s.id}>
<strong>{a.title}</strong> {s.speak}
</li>
))
)}
</ol>
</noscript>
) : null}
</figure>
)
}
if (block.type === 'sim') {
return <SimBlockView block={block} lang={lang} />
}
if (block.type === 'chart') {
return <ChartBlockView block={block} />
}
if (block.type === 'stats') {
return (
<div className="my-8 grid grid-cols-2 gap-3 md:grid-cols-4">
{block.items.map((item, i) => (
<div
key={`${item.label}-${i}`}
className="rounded-[14px] border px-4 py-3.5"
style={{ background: 'var(--pp-paper)', borderColor: 'var(--pp-line)' }}
>
<p
className="font-extrabold leading-tight tabular-nums"
style={{
fontSize: 'clamp(22px, 2.4vw, 30px)',
color: 'var(--pp-plum)',
}}
>
{item.value}
</p>
<p className="mt-1.5 text-[13px] leading-snug" style={{ color: 'var(--pp-ink)' }}>
{item.label}
</p>
</div>
))}
</div>
)
}
if (block.type === 'table') {
return (
<figure
className="my-6 overflow-x-auto rounded-xl border"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<table className="w-full min-w-[460px] border-collapse text-[13.5px]">
{block.caption ? (
<caption
className="px-3 py-2.5 text-left text-xs"
style={{ fontFamily: 'var(--pp-mono)', color: 'var(--pp-muted)' }}
>
{block.caption}
</caption>
) : null}
<thead>
<tr style={{ borderBottom: '1px solid var(--pp-line)', background: 'var(--pp-paper)' }}>
{block.columns.map((c) => (
<th
key={c}
className="px-3 py-2.5 text-left font-semibold tracking-tight"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr
key={ri}
style={{ borderBottom: ri < block.rows.length - 1 ? '1px solid var(--pp-line)' : undefined }}
className="last:border-0"
>
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-2.5 align-top">
<PageMd md={cell} className="text-sm [&_p]:my-0" />
</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
)
}
if (block.type === 'image') {
return (
<figure className="my-6">
{ }
<img
src={block.src}
alt={block.alt}
className="mx-auto max-h-[480px] w-auto max-w-full rounded-xl border border-border/50 shadow-sm"
/>
{block.caption ? (
<figcaption className="mt-2 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
return null
}
export { IntentBadge }

View File

@@ -0,0 +1,91 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import { marked } from 'marked'
import { sanitizeRichHtml } from '@/lib/sanitize-content'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
/** Light markdown + inline $KaTeX$ for page prose / callouts / speak. */
export function PageMd({
md,
className,
}: {
md: string
className?: string
}) {
const html = useMemo(() => {
const placeholders: string[] = []
const withSlots = md.replace(/\$([^$]+)\$/g, (_, tex: string) => {
const i = placeholders.length
try {
placeholders.push(
katex.renderToString(tex, { displayMode: false, throwOnError: false })
)
} catch {
placeholders.push(tex)
}
return `%%KATEX${i}%%`
})
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
out = out.replace(`%%KATEX${i}%%`, frag)
})
return sanitizeRichHtml(out)
}, [md])
return (
<div
className={cn(
'prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:tracking-tight',
className
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function PageFormula({
tex,
caption,
}: {
tex: string
caption?: string
}) {
const html = useMemo(() => {
try {
return katex.renderToString(tex, {
displayMode: true,
throwOnError: false,
})
} catch {
return tex
}
}, [tex])
return (
<figure
className="my-5 overflow-x-auto rounded-xl border px-4 py-4 md:px-5"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
borderLeft: '4px solid var(--pp-plum)',
}}
>
<div
className="text-[1.15em] [&_.katex-display]:m-0"
dangerouslySetInnerHTML={{ __html: html }}
/>
{caption ? (
<figcaption
className="mt-2.5 text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{caption}
</figcaption>
) : null}
</figure>
)
}

View File

@@ -0,0 +1,151 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId } from '@/lib/interactive-demo/types'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { cn } from '@/lib/utils'
function collectIntents(page: PageSpecV1): IntentId[] {
const set = new Set<IntentId>()
for (const card of page.overview?.cards ?? []) {
if (card.intent) set.add(card.intent)
}
for (const section of page.sections) {
for (const block of section.blocks) {
if (block.type === 'stats') {
for (const item of block.items) {
if (item.intent) set.add(item.intent)
}
}
if (block.type === 'chart') {
for (const s of block.payload.series) {
if (s.intent) set.add(s.intent)
}
}
if (block.type === 'demo') {
for (const panel of block.demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
}
}
}
return [...set]
}
const INTENT_LABELS_FR: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flux',
cache: 'Mémoire',
compute: 'Calcul',
output: 'Résultat',
warning: 'Attention',
}
const INTENT_LABELS_EN: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flow',
cache: 'Memory',
compute: 'Compute',
output: 'Result',
warning: 'Warning',
}
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
const [active, setActive] = useState(page.sections[0]?.id ?? '')
const dark = useDarkMode()
const intents = useMemo(() => collectIntents(page), [page])
useEffect(() => {
const nodes = page.sections
.map((s) => document.getElementById(s.id))
.filter(Boolean) as HTMLElement[]
if (!nodes.length) return
const obs = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
const top = visible[0]?.target?.id
if (top) setActive(top)
},
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [page.sections])
return (
<div
className="sticky top-0 z-30 backdrop-blur-sm"
style={{
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
borderTop: '1px solid var(--pp-line)',
borderBottom: '1px solid var(--pp-line)',
}}
>
<nav
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
aria-label="Sections"
style={{ fontFamily: 'var(--pp-mono)' }}
>
{page.sections.map((s, i) => (
<a
key={s.id}
href={`#${s.id}`}
className={cn(
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
active === s.id ? 'font-semibold' : 'hover:opacity-80'
)}
style={
active === s.id
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{i + 1} · {s.title}
</a>
))}
</nav>
{intents.length > 0 ? (
<div
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
style={{
borderTop: '1px solid var(--pp-line)',
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-muted)',
}}
>
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => {
const color = intentColor(id, dark)
return (
<span
key={id}
className="inline-flex items-center gap-1.5"
>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
{page.lang.startsWith('fr')
? INTENT_LABELS_FR[id]
: INTENT_LABELS_EN[id]}
</span>
)
})}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,270 @@
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
import { PageMd } from '@/components/interactive-page/page-md'
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
export type PageViewProps = {
page: PageSpecV1
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
demoMode?: 'interactive' | 'static'
className?: string
paper?: boolean
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
function useScrollReveal(enabled: boolean) {
useEffect(() => {
if (!enabled) {
document
.querySelectorAll<HTMLElement>('[data-scroll-init]')
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
return
}
const nodes = Array.from(
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
)
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.setAttribute('data-scroll-visible', 'true')
obs.unobserve(e.target)
}
}
},
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [enabled])
}
/**
* PageView — Kimi/AttnRes-style explainer page.
* Design tokens replicated from the reference page (paper, plum accent,
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
*/
export function PageView({
page,
demoMode = 'interactive',
className,
paper = true,
}: PageViewProps) {
const reducedMotion = usePrefersReducedMotion()
useScrollReveal(!reducedMotion)
const paperStyle = {
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
} as CSSProperties
return (
<article
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
data-page-id={page.id}
style={paperStyle}
>
<style>{`
.interactive-page {
--pp-paper: #F4F0E8;
--pp-paper-deep: #EAE4D9;
--pp-card: #FFFDF8;
--pp-ink: #242422;
--pp-muted: #686762;
--pp-line: #D6D0C6;
--pp-plum: #9F3F70;
--pp-plum-soft: #F2DCE8;
--pp-blue: #3F6F9F;
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--pp-ink);
}
.dark .interactive-page {
--pp-paper: #17140F;
--pp-paper-deep: #201C15;
--pp-card: #221E17;
--pp-ink: #EDE7DB;
--pp-muted: #A39C8D;
--pp-line: #3B352A;
--pp-plum: #D07AA6;
--pp-plum-soft: #3A2530;
--pp-blue: #7FA8CC;
}
.interactive-page.page-paper {
background-color: var(--pp-paper);
background-image: radial-gradient(
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
transparent 0.7px
);
background-size: 18px 18px;
}
.pp-card {
background: var(--pp-card);
border: 1px solid var(--pp-line);
border-radius: 14px;
}
.pp-mono {
font-family: var(--pp-mono);
}
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
opacity: 0;
transform: translateY(12px);
}
.interactive-page [data-scroll-init] {
transition: opacity 420ms ease, transform 420ms ease;
will-change: opacity, transform;
}
.interactive-page [data-scroll-visible] {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
`}</style>
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
<p
className="pp-mono mb-2.5 text-xs uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
>
{page.hero.kicker}
</p>
<h1
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
>
{page.hero.title}
</h1>
{page.hero.subtitle ? (
<p
className="mt-2.5 font-semibold"
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
>
{page.hero.subtitle}
</p>
) : null}
{page.hero.meta ? (
<p
className="pp-mono mt-3 leading-relaxed"
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
>
{page.hero.meta}
</p>
) : null}
</header>
<PageStickyNav page={page} />
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
{/* ── One-minute overview ── */}
{page.overview ? (
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
<p
className="pp-mono mb-3 text-[11px] uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
>
{page.lang.startsWith('fr')
? 'Lessentiel en une minute'
: 'One-minute overview'}
</p>
<PageMd
md={page.overview.lead}
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
/>
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{page.overview.cards.map((card) => (
<div
key={card.badge + card.title}
className="rounded-xl border p-4"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
}}
>
<IntentBadge label={card.badge} intent={card.intent} />
<h3 className="mt-3 text-base font-semibold tracking-tight">
{card.title}
</h3>
<PageMd
md={card.body}
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
/>
</div>
))}
</div>
</section>
) : null}
{/* ── Sections ── */}
<div className="space-y-20">
{page.sections.map((section, i) => (
<section
key={section.id}
id={section.id}
className="scroll-mt-28"
data-scroll-init
>
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
<span
className="pp-mono text-sm font-semibold"
style={{ color: 'var(--pp-plum)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
{section.title}
</h2>
<div>
{section.blocks.map((block, bi) => {
const narrow =
block.type === 'prose' ||
block.type === 'formula' ||
block.type === 'callout'
return (
<div
key={`${section.id}-${bi}`}
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
>
<PageBlockView
block={block}
demoMode={demoMode}
lang={page.lang}
/>
</div>
)
})}
</div>
</section>
))}
</div>
{page.footer ? (
<footer
className="pp-mono mt-16 border-t pt-5 text-xs italic"
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
>
{page.footer}
</footer>
) : null}
</div>
</article>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { GENERIC_SIM_ID, getPlugin } from '@/lib/simulators'
import { ANIM_VIEWS, SIMULATOR_VIEWS } from '@/components/simulators'
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
import { GenericFormulaView } from '@/components/simulators/generic-formula-view'
import type { SimBlock } from '@/lib/interactive-page'
/** Renders a `sim` block: catalog plugin (bespoke view) or generic formula. */
export function SimBlockView({
block,
lang,
}: {
block: SimBlock
lang: string
}) {
const sim = block.sim
const fr = lang.startsWith('fr')
const plugin = sim.simId === GENERIC_SIM_ID ? null : getPlugin(sim.simId)
const kindLabel =
plugin?.family === 'anim'
? fr
? 'Animation interactive'
: 'Interactive animation'
: fr
? 'Simulation interactive'
: 'Interactive simulation'
let title: string | undefined
let body: React.ReactNode = null
if (sim.simId === GENERIC_SIM_ID) {
title = sim.title
body = (
<GenericFormulaView
sim={sim as Extract<typeof sim, { simId: 'generic-formula' }>}
lang={lang}
/>
)
} else {
if (!plugin) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
if (plugin.family === 'anim') {
const AnimScene = ANIM_VIEWS[sim.simId]
if (!AnimScene) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
</div>
)
}
body = (
<AnimPlayerShell beats={plugin.beats} lang={lang} disclaimer={plugin.disclaimer}>
{(stepIndex) => <AnimScene step={stepIndex} lang={lang} />}
</AnimPlayerShell>
)
} else {
const View = SIMULATOR_VIEWS[sim.simId]
if (!View) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
const preset = (sim as { preset?: Record<string, number> }).preset
body = (
<View
preset={preset}
title={title}
disclaimer={sim.disclaimer}
lang={lang}
/>
)
}
}
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{kindLabel}
</span>
</div>
{body}
{block.caption ? (
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}