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,23 @@
import { notFound } from 'next/navigation'
import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage } from '@/lib/interactive-page'
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
/**
* Dev preview of PageView + thermo fixture (spec §8.3).
* Only available outside production.
*/
export default function InteractivePagePreviewPage() {
if (process.env.NODE_ENV === 'production') notFound()
const parsed = validateInteractivePage(thermoFixture)
if (!parsed.ok) {
return (
<div className="p-8 text-sm text-destructive">
Fixture invalide : {parsed.issues[0]?.message}
</div>
)
}
return <PageView page={parsed.page} demoMode="interactive" />
}

View File

@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
import { ReadingProgress } from '@/components/publish/reading-progress'
import { CopyLinkButton } from '@/components/publish/copy-link-button'
import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
import { isInteractivePageTemplate } from '@/lib/publish/types'
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
const note = await getPublishedNote(slug)
if (!note) notFound()
const isStale = Boolean(
note.publishedSourceHash
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
)
if (
isInteractivePageTemplate(note.publishedTemplate) &&
note.publishedContent
) {
return (
<InteractivePublishedPage
publishedContent={note.publishedContent}
isStale={isStale}
/>
)
}
const usesAiLayout = Boolean(note.publishedContent)
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
const bodyHtml = processNoteHtmlForPublish(rawHtml)
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
const readingTime = estimateReadingTime(readingSource)
const isStale = Boolean(
note.publishedSourceHash
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
)
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
export const maxDuration = 180
const requestSchema = z.object({
content: z.string().min(20),
selection: z.string().optional().nullable(),
lang: z.string().optional(),
noteId: z.string().optional(),
})
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const body = await req.json()
const parsed = requestSchema.parse(body)
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
const sourceRaw = parsed.selection?.trim() || parsed.content
const sourcePlain = stripHtml(sourceRaw)
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
if (wordCount < 20) {
return NextResponse.json(
{
error:
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
},
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
lane: 'chat',
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
}
const config = await getSystemConfig()
const lang = parsed.lang || 'fr'
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
const provider = getSlidesProvider(config)
const result = await generateInteractiveDemoFromContent({
content: sourceRaw,
lang,
provider,
})
if (!result.ok) {
const first = result.issues[0]
const detail = first
? `${first.path ? first.path + ': ' : ''}${first.message}`
: ''
console.error(
'[/api/ai/interactive-demo] validation failed after repairs',
result.issues.slice(0, 10)
)
return NextResponse.json(
{
error: detail
? `Démo invalide après correction — ${detail}`
: 'La génération a produit un JSON invalide après correction',
issues: result.issues.slice(0, 10),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({
demo: result.demo,
attempts: result.attempts,
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive demo'
console.error('[/api/ai/interactive-demo]', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
import {
generatePagePlan,
generatePageSection,
} from '@/lib/ai/services/interactive-page-llm.service'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
export const maxDuration = 60
const sectionPlanSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().optional(),
})
const requestSchema = z.object({
/** undefined = legacy deterministic full-page (fallback path) */
action: z.enum(['plan', 'section']).optional(),
content: z.string().min(40),
lang: z.string().optional(),
noteId: z.string().optional(),
notebookId: z.string().optional(),
pageTitle: z.string().optional(),
sectionId: z.string().optional(),
section: sectionPlanSchema.optional(),
})
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const body = await req.json()
const parsed = requestSchema.parse(body)
const wordCount = parsed.content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 40) {
return NextResponse.json(
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
{ status: 400 }
)
}
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
const lang = parsed.lang || 'fr'
// ── LLM plan (billed: page = 20 crédits, spec §8.5) ──────────────────
if (parsed.action === 'plan') {
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
}
const result = await generatePagePlan({
content: parsed.content,
lang,
provider,
})
if (!result.ok) {
return NextResponse.json(
{ error: result.error, reason: result.reason, attempts: result.attempts },
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
)
}
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
}
// ── LLM single section (already billed at plan time) ────────────────
if (parsed.action === 'section') {
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
return NextResponse.json(
{ error: 'section, sectionId and pageTitle are required' },
{ status: 400 }
)
}
const result = await generatePageSection({
content: parsed.content,
lang,
provider,
pageTitle: parsed.pageTitle,
sectionId: parsed.sectionId,
section: parsed.section,
})
if (!result.ok) {
return NextResponse.json(
{
error: 'section_generation_failed',
issues: result.issues?.slice(0, 12),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({ section: result.section, attempts: result.attempts })
}
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
const result = await generateInteractivePageFromContent({
content: parsed.content,
lang,
provider,
})
if (!result.ok) {
if (result.error === 'unsuitable_content') {
return NextResponse.json(
{
error: 'unsuitable_content',
reason: result.reason,
attempts: result.attempts,
},
{ status: 422 }
)
}
const first = result.issues?.[0]
const reason =
result.reason ||
(first
? `${first.path ? first.path + ': ' : ''}${first.message}`
: undefined)
return NextResponse.json(
{
error:
result.error === 'timeout'
? 'La génération a pris trop de temps — réessayez'
: reason ||
'La génération a produit une page invalide',
reason,
issues: result.issues?.slice(0, 12),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({
page: result.page,
attempts: result.attempts,
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive page'
console.error('[/api/ai/interactive-page]', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -5,8 +5,13 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId } from '@/lib/publish/types'
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
import { validateInteractivePage } from '@/lib/interactive-page'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
const MODERATION_TIMEOUT_MS = 12_000
@@ -93,13 +98,14 @@ export async function POST(request: NextRequest) {
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const { noteId, action, mode, template, language, rewrite } = body as {
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
mode?: 'simple' | 'ai'
mode?: 'simple' | 'ai' | 'interactive-page'
template?: string
language?: string
rewrite?: boolean
pageSpec?: unknown
}
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
@@ -111,13 +117,96 @@ export async function POST(request: NextRequest) {
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') {
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
if (!validatedPage?.ok) {
// Deterministic generate (no LLM quota)
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
const generated = await generateInteractivePageFromContent({
content: note.content || '',
lang: language || 'fr',
provider,
})
if (!generated.ok) {
return NextResponse.json(
{
error: generated.error || 'interactive_page_generation_failed',
reason: generated.reason,
issues: generated.issues?.slice(0, 12),
},
{ status: 422 }
)
}
validatedPage = { ok: true, page: generated.page }
}
// Guaranteed valid PageSpec after generate-or-validate above
if (!validatedPage?.ok) {
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
}
const textForModeration = [
validatedPage.page.hero.title,
validatedPage.page.hero.subtitle,
validatedPage.page.overview?.lead,
...validatedPage.page.sections.map((s) => s.title),
]
.filter(Boolean)
.join('\n')
const moderation = await moderateWithFallback(
note.title || '',
textForModeration
)
if (moderation.verdict === 'blocked') {
return NextResponse.json(
{
error: 'blocked',
reason: moderation.reason,
categories: moderation.categories,
},
{ status: 403 }
)
}
if (moderation.verdict === 'flagged') {
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
}
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
const sourceHash = computePublishedSourceHash(note.content || '')
await updateNotePublishState(noteId, {
isPublic: true,
publicSlug: slug,
publishedAt: new Date(),
publishedContent: JSON.stringify(validatedPage.page),
publishedTemplate: 'interactive-page',
publishedSourceHash: sourceHash,
})
return NextResponse.json({
success: true,
slug,
mode: 'interactive-page',
template: 'interactive-page',
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
})
}
const publishMode = mode === 'ai' ? 'ai' : 'simple'
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
if (!template || !isPublishTemplateId(template)) {
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
}

View File

@@ -0,0 +1,81 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
/** Render node/callout label: plain lines + inline $KaTeX$. */
export function DemoRichLabel({
text,
className,
lit,
}: {
text: string
className?: string
lit?: boolean
}) {
const lines = useMemo(() => {
return text.split(/\\n|\n/).map((line) => {
const parts: Array<{ type: 'text' | 'math'; value: string }> = []
const re = /\$([^$]+)\$/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
if (m.index > last) {
parts.push({ type: 'text', value: line.slice(last, m.index) })
}
parts.push({ type: 'math', value: m[1] || '' })
last = m.index + m[0].length
}
if (last < line.length) parts.push({ type: 'text', value: line.slice(last) })
if (parts.length === 0) parts.push({ type: 'text', value: line })
return parts
})
}, [text])
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-0.5 text-center leading-snug',
className
)}
>
{lines.map((parts, i) => (
<div
key={i}
className={cn(
'flex flex-wrap items-center justify-center gap-x-1',
i === 0
? lit
? 'text-[13px] font-semibold tracking-tight'
: 'text-[12.5px] font-semibold tracking-tight'
: 'text-[12px] font-medium opacity-90'
)}
>
{parts.map((p, j) => {
if (p.type === 'math') {
let html = p.value
try {
html = katex.renderToString(p.value, {
displayMode: false,
throwOnError: false,
})
} catch {
/* keep raw */
}
return (
<span
key={j}
className="katex-node inline-block [&_.katex]:text-[0.95em]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
return <span key={j}>{p.value}</span>
})}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,948 @@
'use client'
import { useId, useMemo } from 'react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
badgeGlyph,
dimOpacity,
intentColor,
spotlightColor,
} from '@/lib/interactive-demo/intent-colors'
import type { DemoScene, Panel } from '@/lib/interactive-demo/types'
import type {
ResolvedAnnotation,
StepResolvedState,
} from '@/lib/interactive-demo/resolve'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label'
import { cn } from '@/lib/utils'
type AnnKind = ResolvedAnnotation['kind']
export type DemoSceneViewProps = {
scene: DemoScene
state: StepResolvedState
reducedMotion?: boolean
className?: string
}
type NodePos = {
x: number
y: number
w: number
h: number
}
function elementOpacity(
id: string,
state: StepResolvedState,
dim: number
): number {
if (!(id in state.revealed)) return 0
if (state.overview || state.spotlight.length === 0) return 1
return state.spotlight.includes(id) ? 1 : dim
}
function estimateNodeSize(label: string): { w: number; h: number } {
const lines = label.split(/\\n|\n/)
const hasMath = /\$/.test(label)
const visualLen = (s: string) =>
s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45))))
.length
const longest = Math.max(...lines.map(visualLen), 6)
const w = Math.min(280, Math.max(128, longest * 7.4 + 40))
const h = Math.max(
hasMath ? 58 : 48,
30 + lines.length * (hasMath ? 24 : 18)
)
return { w, h }
}
/** Prefer circular layout when edges form a closed loop covering most nodes. */
function findCycleOrder(
ids: string[],
edges: { from: string; to: string }[]
): string[] | null {
if (ids.length < 3) return null
const idSet = new Set(ids)
const outs = new Map<string, string[]>()
for (const id of ids) outs.set(id, [])
for (const e of edges) {
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
outs.get(e.from)!.push(e.to)
}
for (const start of ids) {
const path = [start]
const seen = new Set([start])
let cur = start
while (path.length < ids.length) {
const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t))
if (nexts.length === 0) break
// Prefer continuing a simple cycle (single unused neighbor)
const n = nexts[0]!
path.push(n)
seen.add(n)
cur = n
}
const closes = (outs.get(cur) ?? []).includes(start)
if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) {
return path
}
}
return null
}
/**
* Layout strategies:
* 1. AttnRes trunk pattern (residualTrunk + layers)
* 2. Closed cycle → circular
* 3. DAG layered (top → bottom)
* 4. Fallback grid
*/
function layoutNodes(
nodes: { id: string; label?: string }[],
edges: { from: string; to: string }[]
): { positions: Map<string, NodePos>; width: number; height: number } {
const sizes = new Map(
nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
)
const positions = new Map<string, NodePos>()
const padX = 40
const padY = 36
const gapX = 52
const gapY = 32
const trunk = nodes.find((n) => n.id === 'residualTrunk')
const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk')
if (trunk && layersOnly.length >= 2) {
const layerSizes = layersOnly.map((n) => sizes.get(n.id)!)
const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96)
const trunkSize = sizes.get(trunk.id)!
const contentH =
padY * 2 +
layerSizes.reduce((acc, s) => acc + s.h, 0) +
gapY * Math.max(0, layersOnly.length - 1)
const height = Math.max(240, contentH)
const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24
let y = padY
layersOnly.forEach((n) => {
const s = sizes.get(n.id)!
positions.set(n.id, {
x: padX + maxLayerW / 2,
y: y + s.h / 2,
w: s.w,
h: s.h,
})
y += s.h + gapY
})
positions.set(trunk.id, {
x: padX + maxLayerW + gapX + trunkSize.w / 2,
y: height / 2,
w: trunkSize.w,
h: trunkSize.h,
})
return { positions, width, height }
}
const cycle = findCycleOrder(
nodes.map((n) => n.id),
edges
)
if (cycle) {
const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128)
const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48)
const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35)
const leftovers = nodes.filter((n) => !cycle.includes(n.id))
const leftoverRowH = leftovers.length ? maxH + gapY : 0
const width = Math.max(
420,
padX * 2 + radius * 2 + maxW,
padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1)
)
const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH)
const cx = width / 2
const cy = padY + radius + maxH / 2
cycle.forEach((id, i) => {
const s = sizes.get(id)!
const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length
positions.set(id, {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
w: s.w,
h: s.h,
})
})
// Leftover nodes: horizontal row BELOW the ring (never overlapping it)
if (leftovers.length) {
const totalW =
leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) +
gapX * (leftovers.length - 1)
let x = cx - totalW / 2
const y = cy + radius + maxH / 2 + gapY + maxH / 2
for (const n of leftovers) {
const s = sizes.get(n.id)!
positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h })
x += s.w + gapX
}
}
return { positions, width, height }
}
// Topological layers (sources at top)
const ids = nodes.map((n) => n.id)
const idSet = new Set(ids)
const indeg = new Map(ids.map((id) => [id, 0]))
const outs = new Map(ids.map((id) => [id, [] as string[]]))
for (const e of edges) {
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1)
outs.get(e.from)!.push(e.to)
}
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0)
const order: string[] = []
const depth = new Map<string, number>()
queue.forEach((id) => depth.set(id, 0))
const q = [...queue]
while (q.length) {
const u = q.shift()!
order.push(u)
for (const v of outs.get(u) ?? []) {
depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1))
indeg.set(v, (indeg.get(v) ?? 1) - 1)
if ((indeg.get(v) ?? 0) === 0) q.push(v)
}
}
const isDag = order.length === ids.length && edges.length > 0
if (isDag) {
const byDepth = new Map<number, string[]>()
for (const id of ids) {
const d = depth.get(id) ?? 0
if (!byDepth.has(d)) byDepth.set(d, [])
byDepth.get(d)!.push(id)
}
const maxDepth = Math.max(...byDepth.keys(), 0)
const rowHeights: number[] = []
let width = padX * 2
for (let d = 0; d <= maxDepth; d++) {
const row = byDepth.get(d) ?? []
const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36)
rowHeights.push(rowH)
const rowW =
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
gapX * Math.max(0, row.length - 1)
width = Math.max(width, padX * 2 + rowW)
}
const height =
padY * 2 +
rowHeights.reduce((a, b) => a + b, 0) +
gapY * Math.max(0, maxDepth)
let y = padY
for (let d = 0; d <= maxDepth; d++) {
const row = byDepth.get(d) ?? []
const rowH = rowHeights[d]!
const rowW =
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
gapX * Math.max(0, row.length - 1)
let x = (width - rowW) / 2
for (const id of row) {
const s = sizes.get(id)!
positions.set(id, {
x: x + s.w / 2,
y: y + rowH / 2,
w: s.w,
h: s.h,
})
x += s.w + gapX
}
y += rowH + gapY
}
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
}
// Grid fallback
const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length))))
const rows = Math.ceil(nodes.length / cols)
const colW: number[] = Array.from({ length: cols }, (_, c) => {
let max = 120
nodes.forEach((n, i) => {
if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w)
})
return max
})
const rowH: number[] = Array.from({ length: rows }, (_, r) => {
let max = 48
nodes.forEach((n, i) => {
if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h)
})
return max
})
const width =
padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1)
const height =
padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1)
nodes.forEach((n, i) => {
const c = i % cols
const r = Math.floor(i / cols)
const s = sizes.get(n.id)!
const xOff =
padX +
colW.slice(0, c).reduce((a, b) => a + b, 0) +
gapX * c +
colW[c]! / 2
const yOff =
padY +
rowH.slice(0, r).reduce((a, b) => a + b, 0) +
gapY * r +
rowH[r]! / 2
positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h })
})
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
}
/** Border intersection: line from center A → center B, clipped to rect A. */
function borderPoint(
from: NodePos,
to: NodePos
): { x: number; y: number } {
const dx = to.x - from.x
const dy = to.y - from.y
if (dx === 0 && dy === 0) return { x: from.x, y: from.y }
const hw = from.w / 2
const hh = from.h / 2
const ax = Math.abs(dx) / (hw || 1)
const ay = Math.abs(dy) / (hh || 1)
const t = 1 / Math.max(ax, ay)
return { x: from.x + dx * t, y: from.y + dy * t }
}
function edgePath(a: NodePos, b: NodePos, tipInset = 10): string {
const p0 = borderPoint(a, b)
const p1 = borderPoint(b, a)
const dx = p1.x - p0.x
const dy = p1.y - p0.y
const len = Math.hypot(dx, dy) || 1
// Stop short of the target so markerEnd tip lands on the node border, not under the card
const inset = Math.min(tipInset, len * 0.35)
const endX = p1.x - (dx / len) * inset
const endY = p1.y - (dy / len) * inset
const mx = (p0.x + endX) / 2
const my = (p0.y + endY) / 2
const bend = Math.min(36, len * 0.22)
const cx = mx - (dy / len) * bend
const cy = my + (dx / len) * bend
return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}`
}
function AnnotationsOverlay({
annotations,
getAnchor,
dark,
markerId,
shadowId,
}: {
annotations: ResolvedAnnotation[]
getAnchor: (
id: string,
kind: AnnKind
) => { x: number; y: number } | null
dark: boolean
markerId: string
shadowId?: string
}) {
const outline = spotlightColor(dark)
return (
<g className="demo-annotations" pointerEvents="none">
{annotations.map((ann, i) => {
const target = ann.targetIds[0]
if (!target) return null
const pos = getAnchor(target, ann.kind)
if (!pos) return null
const color = intentColor(ann.intent, dark)
const key = `${ann.kind}-${target}-${i}`
if (ann.kind === 'circle') {
return (
<circle
key={key}
cx={pos.x}
cy={pos.y}
r={22}
fill="none"
stroke={color}
strokeWidth={2.5}
opacity={0.9}
/>
)
}
if (ann.kind === 'badge') {
const n = ann.badgeIndex ?? i + 1
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<circle r={11} fill={outline} opacity={0.95} />
<text
textAnchor="middle"
dominantBaseline="central"
fill="#fff"
fontSize={11}
fontWeight={700}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{badgeGlyph(n)}
</text>
</g>
)
}
if (ann.kind === 'callout') {
const text = ann.text ?? ''
const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<rect
x={8}
y={-14}
width={w}
height={28}
rx={8}
fill={dark ? '#1a1a1a' : '#ffffff'}
stroke={color}
strokeWidth={1.5}
filter={shadowId ? `url(#${shadowId})` : undefined}
/>
<text
x={18}
y={5}
fontSize={12}
fontWeight={500}
fill={dark ? '#f0f0f0' : '#1a1a1a'}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{text}
</text>
</g>
)
}
if (ann.kind === 'arrow') {
return (
<path
key={key}
d={`M ${pos.x - 28} ${pos.y - 28} L ${pos.x - 6} ${pos.y - 6}`}
stroke={color}
strokeWidth={2.5}
strokeLinecap="round"
markerEnd={`url(#${markerId})`}
fill="none"
/>
)
}
return null
})}
</g>
)
}
function SvgScenePanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'svg-scene' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const uid = useId().replace(/:/g, '')
const nodes = panel.payload.nodes
const edges = panel.payload.edges ?? []
const dim = dimOpacity(dark)
const outline = spotlightColor(dark)
const markerId = `demo-arrow-${uid}`
const dotsId = `demo-dots-${uid}`
const { positions, width, height } = useMemo(
() => layoutNodes(nodes, edges),
[nodes, edges]
)
const transition = reducedMotion
? 'none'
: 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease'
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)'
const boardBg = dark ? '#0c0e12' : '#f7f5f0'
return (
<div
className="relative w-full overflow-hidden"
style={{
background: boardBg,
aspectRatio: `${width} / ${height}`,
}}
>
<svg
viewBox={`0 0 ${width} ${height}`}
className="absolute inset-0 h-full w-full"
aria-hidden
>
<defs>
<pattern
id={dotsId}
width="18"
height="18"
patternUnits="userSpaceOnUse"
>
<circle cx="1.2" cy="1.2" r="1" fill={gridStroke} />
</pattern>
<marker
id={markerId}
markerWidth="10"
markerHeight="8"
refX="9"
refY="4"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L10,4 L0,8 Z" fill={outline} />
</marker>
</defs>
<rect width={width} height={height} fill={`url(#${dotsId})`} />
{edges.map((e) => {
const a = positions.get(e.from)
const b = positions.get(e.to)
if (!a || !b) return null
const revealed = e.id in state.revealed || state.overview
if (!revealed) return null
const op = elementOpacity(e.id, state, dim)
if (op === 0) return null
const lit = state.overview || state.spotlight.includes(e.id)
const stroke = intentColor(e.intent, dark)
const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45
return (
<path
key={e.id}
d={edgePath(a, b)}
stroke={stroke}
strokeWidth={widthStroke}
strokeDasharray={e.style === 'dashed' ? '7 5' : undefined}
strokeLinecap="round"
fill="none"
opacity={op}
markerEnd={`url(#${markerId})`}
style={{ transition }}
/>
)
})}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId={markerId}
getAnchor={(id, kind) => {
const p = positions.get(id)
if (!p) return null
if (kind === 'badge') {
return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 }
}
if (kind === 'callout') {
return { x: p.x + p.w / 2 - 4, y: p.y }
}
return { x: p.x, y: p.y }
}}
/>
</svg>
{nodes.map((n) => {
const p = positions.get(n.id)
if (!p) return null
const op = elementOpacity(n.id, state, dim)
if (op === 0) return null
const lit = state.overview || state.spotlight.includes(n.id)
const accent = intentColor(n.intent, dark)
const label = n.label ?? n.id
return (
<div
key={n.id}
className={cn(
'absolute flex flex-col justify-center rounded-2xl px-3 py-2.5 backdrop-blur-[2px]',
dark ? 'bg-[#141820]/ee text-zinc-50' : 'bg-white/95 text-zinc-900'
)}
style={{
left: `${((p.x - p.w / 2) / width) * 100}%`,
top: `${((p.y - p.h / 2) / height) * 100}%`,
width: `${(p.w / width) * 100}%`,
minHeight: `${(p.h / height) * 100}%`,
opacity: op,
borderStyle: 'solid',
borderTopWidth: lit ? 2 : 1.5,
borderRightWidth: lit ? 2 : 1.5,
borderBottomWidth: lit ? 2 : 1.5,
borderLeftWidth: 5,
borderTopColor: lit ? outline : accent,
borderRightColor: lit ? outline : accent,
borderBottomColor: lit ? outline : accent,
borderLeftColor: accent,
boxShadow: lit
? `0 0 0 3px ${outline}33, 0 10px 28px -8px ${outline}66`
: dark
? '0 8px 24px -10px rgba(0,0,0,0.65)'
: '0 8px 22px -10px rgba(15,23,42,0.18)',
transition,
}}
>
<DemoRichLabel text={label} lit={lit} />
</div>
)
})}
</div>
)
}
function ChartPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'chart' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const series = panel.payload.series
const dim = dimOpacity(dark)
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
const data = useMemo(() => {
return Array.from({ length: maxLen }, (_, i) => {
const row: Record<string, number | string> = { i: String(i + 1) }
for (const s of series) {
if (!(s.id in state.revealed)) continue
row[s.id] = s.values[i] ?? 0
}
return row
})
}, [maxLen, series, state.revealed])
const Chart =
panel.payload.chartType === 'bar'
? BarChart
: panel.payload.chartType === 'area'
? AreaChart
: LineChart
return (
<div className="w-full h-52 min-h-[13rem] min-w-0">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 12, right: 16, left: 4, bottom: 8 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis
dataKey="i"
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
width={40}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
borderRadius: 10,
border: '1px solid var(--border)',
background: dark ? '#18181b' : '#fff',
fontSize: 12,
}}
/>
{series.map((s) => {
if (!(s.id in state.revealed)) return null
const op = elementOpacity(s.id, state, dim)
const stroke = intentColor(s.intent, dark)
if (panel.payload.chartType === 'bar') {
return (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
fillOpacity={op}
radius={[4, 4, 0, 0]}
isAnimationActive={!reducedMotion}
/>
)
}
if (panel.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={op * 0.22}
strokeOpacity={op}
strokeWidth={2}
isAnimationActive={!reducedMotion}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeOpacity={op}
strokeWidth={2.5}
dot={false}
isAnimationActive={!reducedMotion}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
)
}
function HeatmapPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'heatmap-matrix' }>
state: StepResolvedState
reducedMotion?: boolean
dark: boolean
}) {
const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload
const cell = 34
const labelW = 44
const labelH = 26
const w = labelW + cols * cell + 8
const h = labelH + rows * cell + 8
const transition = reducedMotion ? 'none' : 'opacity 220ms ease'
const dim = dimOpacity(dark)
const fillIntent = spotlightColor(dark)
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
const cellRect = (r: number, c: number) => ({
x: labelW + (c - 1) * cell,
y: labelH + (r - 1) * cell,
})
const positions = useMemo(() => {
const map = new Map<
string,
{ cx: number; cy: number; trX: number; trY: number }
>()
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
if (triangular === 'lower' && c > r) continue
if (triangular === 'upper' && c < r) continue
const id = `r${r}.c${c}`
const { x, y } = cellRect(r, c)
map.set(id, {
cx: x + cell / 2,
cy: y + cell / 2,
trX: x + cell - 2,
trY: y + 2,
})
}
}
return map
}, [rows, cols, triangular])
return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
{colLabels?.map((lab, i) => (
<text
key={`c-${i}`}
x={labelW + i * cell + cell / 2}
y={labelH / 2 + 1}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{rowLabels?.map((lab, i) => (
<text
key={`r-${i}`}
x={labelW / 2}
y={labelH + i * cell + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{Array.from({ length: rows }, (_, ri) =>
Array.from({ length: cols }, (_, ci) => {
const r = ri + 1
const c = ci + 1
if (triangular === 'lower' && c > r) return null
if (triangular === 'upper' && c < r) return null
const id = `r${r}.c${c}`
const { x, y } = cellRect(r, c)
const revealed = id in state.revealed
const v = values[ri]?.[ci] ?? 0
const lit = state.overview || state.spotlight.includes(id)
const op = revealed ? elementOpacity(id, state, dim) : 1
if (!revealed) {
return (
<rect
key={id}
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill="none"
stroke={ghostStroke}
strokeWidth={1}
strokeDasharray="3 2"
opacity={0.8}
/>
)
}
const fillOpacity = 0.14 + v * 0.78
const textFill =
v > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
return (
<g key={id} opacity={op} style={{ transition }}>
<rect
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill={fillIntent}
fillOpacity={fillOpacity}
stroke={lit ? fillIntent : 'transparent'}
strokeWidth={lit ? 2 : 0}
/>
<text
x={x + cell / 2}
y={y + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontWeight={600}
fill={textFill}
>
{v.toFixed(2)}
</text>
</g>
)
})
)}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId="demo-arrowhead-hm"
getAnchor={(id, kind) => {
const p = positions.get(id)
if (!p) return null
if (kind === 'badge') return { x: p.trX, y: p.trY }
if (kind === 'callout') return { x: p.trX + 4, y: p.cy }
return { x: p.cx, y: p.cy }
}}
/>
</svg>
)
}
export function DemoSceneView({
scene,
state,
reducedMotion,
className,
}: DemoSceneViewProps) {
const dark = useDarkMode()
return (
<div
className={cn(
'grid gap-4',
scene.panels.length === 2 ? 'md:grid-cols-2' : 'grid-cols-1',
className
)}
>
{scene.panels.map((panel) => (
<div
key={panel.id}
className={cn(
'overflow-hidden rounded-xl border shadow-sm',
dark
? 'border-white/10 bg-[#0f1115]'
: 'border-black/10 bg-[#faf9f6]'
)}
>
{panel.type === 'svg-scene' && (
<SvgScenePanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
)}
{panel.type === 'chart' && (
<div className="p-3">
<ChartPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
{panel.type === 'heatmap-matrix' && (
<div className="p-3">
<HeatmapPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import katex from 'katex'
import { marked } from 'marked'
import { sanitizeRichHtml } from '@/lib/sanitize-content'
import 'katex/dist/katex.min.css'
/**
* Render demo `speak`: light markdown + inline $KaTeX$ via the note pipeline pieces.
*/
export function DemoSpeak({ speak, className }: { speak: string; className?: string }) {
const html = useMemo(() => {
const placeholders: string[] = []
const withSlots = speak.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 md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
md = md.replace(`%%KATEX${i}%%`, frag)
})
return sanitizeRichHtml(md)
}, [speak])
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function useDarkMode(): boolean {
const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: false
)
useEffect(() => {
const check = () =>
setIsDark(document.documentElement.classList.contains('dark'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => obs.disconnect()
}, [])
return isDark
}

View File

@@ -0,0 +1,387 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Pause,
Play,
RotateCcw,
SkipBack,
SkipForward,
StepBack,
StepForward,
} from 'lucide-react'
import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
import {
resolveInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
const SPEEDS = [0.5, 1, 2, 4] as const
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const ACT_CHANGE_PAUSE_MS = 800
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 stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
return clamped / speed
}
export type InteractiveDemoPlayerProps = {
demo: InteractiveDemoV1
mode?: 'interactive' | 'static'
className?: string
}
export function InteractiveDemoPlayer({
demo,
mode = 'interactive',
className,
}: InteractiveDemoPlayerProps) {
const { t } = useLanguage()
const reducedMotion = usePrefersReducedMotion()
const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
const [actIndex, setActIndex] = useState(0)
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speedIdx, setSpeedIdx] = useState(1)
const speed = SPEEDS[speedIdx] ?? 1
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const actPauseRef = useRef(false)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const act = resolved.acts[actIndex] ?? resolved.acts[0]
const scenes = useMemo(() => {
let scene = demo.scene
const map: (typeof demo.scene)[] = []
for (const a of demo.acts) {
if (a.scene) scene = a.scene
map.push(scene)
}
return map
}, [demo])
const scene = scenes[actIndex] ?? demo.scene
const state =
mode === 'static'
? act?.final
: act?.steps[stepIndex] ?? act?.final
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const goStep = useCallback(
(next: number) => {
if (!act) return
if (next < 0) {
setStepIndex(0)
return
}
if (next >= act.steps.length) {
setPlaying(false)
setStepIndex(act.steps.length - 1)
return
}
setStepIndex(next)
},
[act]
)
const resetAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setStepIndex(0)
}, [clearTimer])
const resetDemo = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setActIndex(0)
setStepIndex(0)
}, [clearTimer])
const skipAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex >= resolved.acts.length - 1) {
setStepIndex((act?.steps.length ?? 1) - 1)
return
}
setActIndex((i) => i + 1)
setStepIndex(0)
}, [actIndex, act, resolved.acts.length, clearTimer])
const prevAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex <= 0) {
setStepIndex(0)
return
}
setActIndex((i) => i - 1)
setStepIndex(0)
}, [actIndex, clearTimer])
// Auto-play: duration recalculates immediately when speed/step changes
useEffect(() => {
clearTimer()
if (!playing || mode !== 'interactive' || !act || !state) return
const atLastStep = stepIndex >= act.steps.length - 1
const ms = stepDurationMs(state.speak, speed)
timerRef.current = setTimeout(() => {
if (atLastStep) {
// End of act while playing
if (actIndex < resolved.acts.length - 1) {
actPauseRef.current = true
timerRef.current = setTimeout(() => {
actPauseRef.current = false
setActIndex((i) => i + 1)
setStepIndex(0)
}, ACT_CHANGE_PAUSE_MS)
} else {
// End of demo — pause on final state
setPlaying(false)
}
return
}
setStepIndex((s) => s + 1)
}, ms)
return clearTimer
}, [
playing,
stepIndex,
speed,
act,
state,
mode,
actIndex,
resolved.acts.length,
clearTimer,
])
useEffect(() => {
if (mode !== 'interactive') return
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(e.target as HTMLElement)?.isContentEditable
) {
return
}
// Multi-player pages: only the hovered/focused player answers the keyboard
if (!activeRef.current) return
if (e.code === 'Space') {
e.preventDefault()
setPlaying((p) => !p)
} else if (e.code === 'ArrowRight' && e.shiftKey) {
e.preventDefault()
skipAct()
} else if (e.code === 'ArrowLeft' && e.shiftKey) {
e.preventDefault()
prevAct()
} else if (e.code === 'ArrowRight') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex + 1)
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex - 1)
} else if (e.code === 'KeyR' && e.shiftKey) {
e.preventDefault()
resetDemo()
} else if (e.code === 'KeyR') {
e.preventDefault()
resetAct()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
if (!act || !state || !scene) {
return (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
</div>
)
}
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
return (
<div
className={cn(
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
className
)}
data-demo-id={demo.id}
data-mode={mode}
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
{demo.disclaimer && (
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
{demo.disclaimer}
</p>
)}
<div className="p-3 sm:p-4">
<DemoSceneView
scene={scene}
state={state}
reducedMotion={reducedMotion || mode === 'static'}
/>
</div>
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
<DemoSpeak
speak={state.speak}
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
/>
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
{progressLabel}
</span>
</div>
{mode === 'interactive' && (
<div className="flex flex-wrap items-center gap-2 pb-2">
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex - 1)
}}
aria-label="Previous step"
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex + 1)
}}
aria-label="Next step"
>
<StepForward className="h-4 w-4" />
</button>
<div className="mx-0.5 h-5 w-px bg-border" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={prevAct}
aria-label="Previous act"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={skipAct}
aria-label="Next act"
>
<SkipForward className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={resetAct}
aria-label="Reset act"
title="R"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
role="group"
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
>
{SPEEDS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setSpeedIdx(i)}
aria-pressed={speedIdx === i}
className={cn(
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
speedIdx === i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{s}×
</button>
))}
</div>
</div>
)}
</div>
<noscript>
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
{act.steps.map((s) => (
<li key={s.stepId}>{s.speak}</li>
))}
</ol>
</noscript>
</div>
)
}

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>
)
}

View File

@@ -19,10 +19,11 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History, Clapperboard
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
@@ -53,6 +54,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [publishOpen, setPublishOpen] = useState(false)
const [publishLoading, setPublishLoading] = useState(false)
const [interactivePageOpen, setInteractivePageOpen] = useState(false)
const [interactivePageContent, setInteractivePageContent] = useState('')
const [publishMeta, setPublishMeta] = useState({
isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null,
@@ -343,6 +346,28 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
magazine: t('richTextEditor.publishTemplateMagazine'),
brief: t('richTextEditor.publishTemplateBrief'),
essay: t('richTextEditor.publishTemplateEssay'),
'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
}
const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
(tpl) => tpl !== 'interactive-page'
)
const handlePublishInteractivePage = async () => {
if (publishLoading) return
const consented = await requestAiConsent()
if (!consented) return
if (state.isDirty && !state.isSaving) {
await actions.handleSaveInPlace()
}
const html =
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
state.content ||
note.content ||
''
setInteractivePageContent(html)
setPublishOpen(false)
setInteractivePageOpen(true)
}
const handlePublishWithAi = async () => {
@@ -664,6 +689,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.publishSimpleHint')}
</p>
<button
type="button"
onClick={handlePublishInteractivePage}
disabled={publishLoading}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
>
{publishLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Clapperboard size={14} />
)}
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</button>
<p className="text-[10px] text-muted-foreground text-center -mt-1">
{t('richTextEditor.publishInteractivePageHint') ||
'Hero, sections, démos Play/Step — 20 crédits'}
</p>
<div className="border-t border-border/50 pt-3 space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles size={13} className="text-brand-accent shrink-0" />
@@ -679,7 +723,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</p>
{/* Sélection template */}
<div className="space-y-1">
{PUBLISH_TEMPLATES.map((tpl) => (
{classicPublishTemplates.map((tpl) => (
<label
key={tpl}
className={cn(
@@ -1009,6 +1053,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}}
/>
<InteractivePagePublishDialog
open={interactivePageOpen}
onOpenChange={setInteractivePageOpen}
noteId={note.id}
content={interactivePageContent}
language={language}
onPublished={(slug) => {
setPublishMeta({
isPublic: true,
slug,
template: 'interactive-page',
})
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
@@ -70,7 +71,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -298,6 +299,9 @@ const slashCommands: SlashItem[] = [
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) },
},
{
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
},
{
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
command: (e) => { insertToggleBlock(e) },
@@ -385,7 +389,7 @@ function useImageInsert() {
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
@@ -589,6 +593,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
InteractiveDemoExtension,
ToggleExtension,
CalloutExtension,
OutlineExtension,
@@ -951,6 +956,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
setChartSuggestionsOpen(true)
}, [editor, requestAiConsent])
const handleGenerateInteractiveDemo = useCallback(async () => {
if (!editor || !editor.isEditable) return
const consented = await requestAiConsent()
if (!consented) return
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
let content = ''
let selection: string | null = null
try {
content = editor.getHTML() || editor.state.doc.textContent || ''
const { from, to, empty } = editor.state.selection
if (!empty) {
const parts: string[] = []
editor.state.doc.nodesBetween(from, to, (node) => {
const latex = (node.attrs as { latex?: string } | undefined)?.latex
if (typeof latex === 'string' && latex.trim()) {
parts.push(
node.type.name === 'mathEquationBlock'
? `$$${latex}$$`
: `$${latex}$`
)
return false
}
if (node.isText && node.text) {
parts.push(node.text)
}
return true
})
selection = parts.join(' ').trim() || null
}
} catch (err) {
console.warn('[interactive-demo] content extract fallback', err)
content = editor.state.doc.textContent || ''
}
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
const words = sourceForWords.split(/\s+/).filter(Boolean).length
if (words < 20) {
toast.error(
t('interactiveDemo.needMoreText') ||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
)
return
}
const toastId = toast.loading(
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
)
try {
const { generateInteractiveDemo } = await import(
'@/lib/ai/services/interactive-demo-client.service'
)
const result = await generateInteractiveDemo({
content,
selection,
lang: language || 'fr',
noteId: noteId || undefined,
})
if (!result.ok) {
if (result.quotaExceeded) {
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
id: toastId,
})
} else {
toast.error(
typeof result.error === 'string'
? result.error
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
return
}
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
toast.error(
t('interactiveDemo.insertFailed') || 'Impossible dinsérer la démo',
{ id: toastId }
)
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
toast.success(
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
{ id: toastId }
)
} catch (err) {
console.error('[interactive-demo] generate error', err)
toast.error(
t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
}, [editor, requestAiConsent, t, language, noteId])
const insertCitationInEditor = useCallback((
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
@@ -1321,7 +1420,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
{editor && (
<SlashCommandMenu
editor={editor}
onInsertImage={imageInsert.requestInsert}
onSuggestCharts={handleOpenChartSuggestions}
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
/>
)}
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
@@ -1910,7 +2016,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
}
}
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [isOpen, setIsOpen] = useState(false)
@@ -1968,6 +2074,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
@@ -2083,6 +2190,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
) {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (
item.title === 'Interactive Demo'
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
) {
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
@@ -2092,7 +2204,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
// Charger les favoris fréquents lors de l'ouverture
useEffect(() => {

View File

@@ -32,6 +32,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts',
interactive_demo: 'usageMeter.featureInteractiveDemo',
interactive_page: 'usageMeter.featureInteractivePage',
publish_enhance: 'usageMeter.featurePublishEnhance',
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',

View File

@@ -0,0 +1,231 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
import { PageMd } from '@/components/interactive-page/page-md'
import type { AnimBeat, SimI18n } from '@/lib/simulators'
import { cn } from '@/lib/utils'
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const SPEEDS = [0.5, 1, 2, 4]
function stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
}
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
}
export type AnimPlayerShellProps = {
beats: AnimBeat[]
lang: string
disclaimer?: SimI18n
/** Scene renderer — receives the current beat index (0-based). */
children: (stepIndex: number) => React.ReactNode
}
/**
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
* + narration panel, same pacing rules as InteractiveDemoPlayer.
*/
export function AnimPlayerShell({
beats,
lang,
disclaimer,
children,
}: AnimPlayerShellProps) {
const fr = lang.startsWith('fr')
const reducedMotion = usePrefersReducedMotion()
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const atLast = stepIndex >= beats.length - 1
useEffect(() => {
clearTimer()
if (!playing) return
const beat = beats[stepIndex]
if (!beat) return
const speak = fr ? beat.speak.fr : beat.speak.en
timerRef.current = setTimeout(
() => {
if (stepIndex >= beats.length - 1) {
setPlaying(false)
return
}
setStepIndex((s) => s + 1)
},
reducedMotion ? 250 : stepDurationMs(speak, speed)
)
return clearTimer
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
// Multi-player pages: only the hovered/focused player answers the keyboard
if (!activeRef.current) return
if (e.code === 'Space') {
e.preventDefault()
setPlaying((p) => !p)
} else if (e.code === 'ArrowRight') {
e.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.max(0, s - 1))
} else if (e.code === 'KeyR') {
e.preventDefault()
setPlaying(false)
setStepIndex(0)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [beats.length])
const beat = beats[stepIndex]
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
return (
<div
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
{children(stepIndex)}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-plum)' }}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
}}
aria-label={fr ? 'Étape suivante' : 'Next step'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<StepForward className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex(0)
}}
aria-label="Reset"
title="R"
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<RotateCcw className="h-4 w-4" />
</button>
<span
className="ml-2 text-xs tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
</span>
<div
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
style={{ borderColor: 'var(--pp-line)' }}
role="group"
aria-label={fr ? 'Vitesse' : 'Speed'}
>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
)}
style={
speed === s
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{s}×
</button>
))}
</div>
</div>
<div
className="mt-2 rounded-lg border px-4 py-3"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
</div>
{disclaimer ? (
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
{fr ? disclaimer.fr : disclaimer.en}
</p>
) : null}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
{beats.map((b) => (
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
))}
</ol>
</noscript>
</div>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Carnot cycle animated scene — piston apparatus + live P-V diagram.
* Driven by AnimPlayerShell via `step` (0..4). Pure SVG, transitions on
* transform/opacity only (GPU-friendly, reduced-motion safe).
*/
const PISTON_Y = [105, 70, 135, 170, 110]
const GAS_HOT = [1, 0.55, 0.12, 0.6, 0.5]
const GAS_COLD = [0, 0.45, 0.9, 0.4, 0.4]
const T_LABEL = ['T_h', 'T_h → T_c', 'T_c', 'T_c → T_h', 'η']
// PV anchors (illustrative)
const A = { x: 405, y: 50 }
const B = { x: 495, y: 159 }
const C = { x: 630, y: 212 }
const D = { x: 465, y: 175 }
const ANCHORS = [A, B, C, D]
const CYCLE = `${A.x},${A.y} ${B.x},${B.y} ${C.x},${C.y} ${D.x},${D.y}`
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
export function CarnotCycleAnimView({ step, lang }: { step: number; lang: string }) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const s = Math.min(step, 4)
const ink = dark ? '#EDE7DB' : '#242422'
const muted = dark ? '#A39C8D' : '#686762'
const plum = dark ? '#D07AA6' : '#9F3F70'
const blue = dark ? '#7FA8CC' : '#3F6F9F'
const line = dark ? '#3B352A' : '#D6D0C6'
const card = dark ? '#221E17' : '#FFFDF8'
const paper = dark ? '#17140F' : '#F4F0E8'
const pistonY = PISTON_Y[s]
const showHotPlate = s === 0
const showColdPlate = s === 2
const showInsulation = s === 1 || s === 3
const showQh = s === 0
const showQc = s === 2
const showWout = s === 0 || s === 1
const showWin = s === 2 || s === 3
const marker = ANCHORS[Math.min(s, 3)]
const segDone = s // segments A→B (0), B→C (1), C→D (2), D→A (3)
return (
<svg
viewBox="0 0 680 300"
className="w-full"
role="img"
aria-label={fr ? 'Animation du cycle de Carnot' : 'Carnot cycle animation'}
style={{ background: paper, display: 'block' }}
>
{/* ══ Piston apparatus ══ */}
<g>
{/* cylinder walls */}
<rect x={70} y={40} width={110} height={195} fill={card} stroke={ink} strokeWidth={2} rx={4} />
{/* gas: cold + hot crossfade */}
<g style={{ transform: `translateY(${pistonY - 45}px)`, transition: EASE }}>
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={blue} opacity={GAS_COLD[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={plum} opacity={GAS_HOT[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
</g>
{/* piston */}
<g style={{ transform: `translateY(${pistonY - 95}px)`, transition: EASE }}>
<rect x={74} y={95} width={102} height={14} fill={ink} rx={3} opacity={0.9} />
<rect x={118} y={60} width={14} height={36} fill={ink} rx={3} opacity={0.9} />
<rect x={105} y={48} width={40} height={12} fill={ink} rx={4} opacity={0.9} />
</g>
{/* hot plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={plum} opacity={showHotPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showHotPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={plum}>
{fr ? 'Source chaude' : 'Hot'} T_h
</text>
) : null}
{/* cold plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={blue} opacity={showColdPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showColdPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={blue}>
{fr ? 'Source froide' : 'Cold'} T_c
</text>
) : null}
{/* insulation */}
<rect x={60} y={240} width={130} height={12} rx={4} fill="none" stroke={muted} strokeWidth={2} strokeDasharray="6 4" opacity={showInsulation ? 1 : 0} style={{ transition: 'opacity 500ms ease' }} />
{showInsulation ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={muted}>
{fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'}
</text>
) : null}
{/* Q_h arrow in */}
<g opacity={showQh ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={95} y1={238} x2={95} y2={196} stroke={plum} strokeWidth={5} strokeLinecap="round" />
<polygon points="95,190 89,202 101,202" fill={plum} />
<text x={70} y={210} fontSize={12} fontWeight={700} fill={plum}>Q_h</text>
</g>
{/* Q_c arrow out */}
<g opacity={showQc ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={155} y1={196} x2={155} y2={238} stroke={blue} strokeWidth={5} strokeLinecap="round" />
<polygon points="155,244 149,232 161,232" fill={blue} />
<text x={164} y={226} fontSize={12} fontWeight={700} fill={blue}>Q_c</text>
</g>
{/* W arrows */}
<g opacity={showWout ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={180} x2={210} y2={120} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,112 204,124 216,124" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
<g opacity={showWin ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={120} x2={210} y2={180} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,188 204,176 216,176" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
{/* temperature label inside gas */}
<text x={125} y={pistonY + 40} textAnchor="middle" fontSize={13} fontWeight={700} fill={ink} style={{ transition: EASE }}>
{T_LABEL[s]}
</text>
</g>
{/* ══ PV diagram ══ */}
<g>
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
PV
</text>
{/* axes */}
<line x1={330} y1={250} x2={660} y2={250} stroke={ink} strokeWidth={1.5} />
<line x1={330} y1={250} x2={330} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={655} y={266} fontSize={11} fill={muted} textAnchor="end">V</text>
<text x={322} y={40} fontSize={11} fill={muted} textAnchor="end">P</text>
{/* isotherms */}
<path d="M 395 56 Q 460 130 660 218" fill="none" stroke={plum} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={200} fontSize={10} fill={plum}>T_h</text>
<path d="M 400 175 Q 520 208 660 230" fill="none" stroke={blue} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={242} fontSize={10} fill={blue}>T_c</text>
{/* cycle area (final beat) */}
<polygon points={CYCLE} fill={plum} opacity={s === 4 ? 0.14 : 0} style={{ transition: 'opacity 600ms ease' }} />
{s === 4 ? (
<text x={505} y={150} fontSize={15} fontWeight={800} fill={plum} textAnchor="middle">W</text>
) : null}
{/* cycle segments, revealed progressively */}
<polyline points={`${A.x},${A.y} ${B.x},${B.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 0 ? 1 : 0.15} />
<polyline points={`${B.x},${B.y} ${C.x},${C.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${C.x},${C.y} ${D.x},${D.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${D.x},${D.y} ${A.x},${A.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* current state marker */}
<circle cx={marker.x} cy={marker.y} r={6} fill={plum} stroke={card} strokeWidth={2} style={{ transition: EASE }} />
</g>
</svg>
)
}

View File

@@ -0,0 +1,672 @@
'use client'
import { useMemo, useState } from 'react'
import {
carnotCycleSimulator as sim,
fridgeLoadFromModeLoad,
modeLoadFromFridgeLoad,
resolveCarnotPhysics,
type CarnotMode,
type CarnotQuantity,
} from '@/lib/simulators/carnot-cycle'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
import { cn } from '@/lib/utils'
const SVG_W = 360
const SVG_H = 270
type TempUnit = 'K' | 'C' | 'F'
const TEMP_UNITS: { id: TempUnit; label: string }[] = [
{ id: 'K', label: 'K' },
{ id: 'C', label: '°C' },
{ id: 'F', label: '°F' },
]
const MODES: { id: CarnotMode; fr: string; en: string }[] = [
{ id: 'fridge', fr: 'Frigo', en: 'Fridge' },
{ id: 'heat_pump', fr: 'PAC', en: 'Heat pump' },
{ id: 'engine', fr: 'Moteur', en: 'Engine' },
]
const QTY: { id: CarnotQuantity; fr: string; en: string }[] = [
{ id: 'energy', fr: 'Énergie (kJ)', en: 'Energy (kJ)' },
{ id: 'power', fr: 'Puissance (W)', en: 'Power (W)' },
]
function kelvinToDisplay(k: number, unit: TempUnit): number {
if (unit === 'C') return k - 273.15
if (unit === 'F') return (k * 9) / 5 - 459.67
return k
}
function displayToKelvin(v: number, unit: TempUnit): number {
if (unit === 'C') return v + 273.15
if (unit === 'F') return ((v + 459.67) * 5) / 9
return v
}
function formatTemp(k: number, unit: TempUnit): string {
const v = kelvinToDisplay(k, unit)
if (unit === 'K') return `${Math.round(v)} K`
if (unit === 'C') {
const r = Math.round(v * 10) / 10
return `${Number.isInteger(r) ? r : r.toFixed(1)} °C`
}
return `${Math.round(v)} °F`
}
function tempSliderMeta(
param: { min: number; max: number; step: number },
unit: TempUnit
): { min: number; max: number; step: number; unitLabel: string } {
if (unit === 'K') {
return { min: param.min, max: param.max, step: param.step, unitLabel: 'K' }
}
if (unit === 'C') {
return {
min: Math.round((param.min - 273.15) * 10) / 10,
max: Math.round((param.max - 273.15) * 10) / 10,
step: 0.5,
unitLabel: '°C',
}
}
return {
min: Math.round(((param.min * 9) / 5 - 459.67) * 10) / 10,
max: Math.round(((param.max * 9) / 5 - 459.67) * 10) / 10,
step: 1,
unitLabel: '°F',
}
}
function formatQty(v: number): string {
if (!Number.isFinite(v)) return '—'
if (Math.abs(v - Math.round(v)) < 0.05) return String(Math.round(v))
return v.toFixed(1)
}
function Segmented<T extends string>({
value,
onChange,
options,
ariaLabel,
}: {
value: T
onChange: (v: T) => void
options: { id: T; label: string }[]
ariaLabel: string
}) {
return (
<div
className="inline-flex max-w-full flex-wrap rounded-lg border border-border/60 bg-background/80 p-0.5"
role="group"
aria-label={ariaLabel}
>
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
className={cn(
'rounded-md px-2 py-1 text-[11px] font-semibold transition-colors',
value === o.id
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={value === o.id}
>
{o.label}
</button>
))}
</div>
)
}
/** Arrowhead + shaft, thickness ∝ |value|/maxRef. */
function FlowArrow({
x1,
y1,
x2,
y2,
value,
maxRef,
color,
label,
unit,
labelSide = 'right',
}: {
x1: number
y1: number
x2: number
y2: number
value: number
maxRef: number
color: string
label: string
unit?: string
labelSide?: 'left' | 'right' | 'above' | 'below'
}) {
const mag = Math.max(0, value)
const t = 2.2 + (mag / Math.max(1, maxRef)) * 10
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const headLen = Math.min(14, Math.max(9, 7 + t * 0.45))
const headHalf = Math.min(7, 3.2 + t * 0.35)
const bx = x2 - ux * headLen
const by = y2 - uy * headLen
const px = -uy
const py = ux
const mx = (x1 + bx) / 2
const my = (y1 + by) / 2
const labelGap = 11 + t * 0.45
let lx = mx
let ly = my
let textAnchor: 'start' | 'middle' | 'end' = 'middle'
if (labelSide === 'above') ly = my - labelGap
else if (labelSide === 'below') ly = my + labelGap
else if (labelSide === 'right') {
lx = mx + labelGap
textAnchor = 'start'
} else {
lx = mx - labelGap
textAnchor = 'end'
}
return (
<g>
<line
x1={x1}
y1={y1}
x2={bx}
y2={by}
stroke={color}
strokeWidth={t}
strokeLinecap="round"
opacity={0.9}
/>
<polygon
points={`${x2},${y2} ${bx + px * headHalf},${by + py * headHalf} ${bx - px * headHalf},${by - py * headHalf}`}
fill={color}
opacity={0.95}
/>
<text
x={lx}
y={ly}
fontSize={11}
fill={color}
fontWeight={600}
dominantBaseline="middle"
textAnchor={textAnchor}
>
{label} {formatQty(value)}
{unit ? ` ${unit}` : ''}
</text>
</g>
)
}
export function CarnotCycleView({
preset,
disclaimer,
lang,
}: {
preset?: Record<string, number>
title?: string
disclaimer?: string
lang: string
}) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const [tempUnit, setTempUnit] = useState<TempUnit>('K')
const [mode, setMode] = useState<CarnotMode>('fridge')
const [qty, setQty] = useState<CarnotQuantity>('energy')
const [values, setValues] = useState<Record<string, number>>(() => {
const env: Record<string, number> = {}
for (const p of sim.params) env[p.id] = preset?.[p.id] ?? p.defaultValue
return env
})
const phys = useMemo(
() =>
resolveCarnotPhysics(
values.t_cold,
values.t_hot,
modeLoadFromFridgeLoad(values.t_cold, values.t_hot, values.q_cold, mode),
mode
),
[values.t_cold, values.t_hot, values.q_cold, mode]
)
const unitE = qty === 'energy' ? 'kJ' : 'W'
const workName =
qty === 'energy'
? fr
? 'Travail'
: 'Work'
: fr
? 'Puissance'
: 'Power'
const maxRef = Math.max(phys.qh || 0, phys.qc || 0, phys.w || 0, 1)
const cHot = intentColor('warning', dark)
const cCold = intentColor('cache', dark)
const cWork = intentColor('compute', dark)
const text = dark ? '#e4e4e7' : '#27272a'
const cx = SVG_W / 2 - 20
const hotY = 28
const coldY = SVG_H - 42
const midY = SVG_H / 2 - 4
const r = 32
const qOffset = 42
const isEngine = mode === 'engine'
const loadMeta = useMemo(() => {
if (mode === 'fridge') {
return {
symbol: qty === 'energy' ? 'Q_c' : '\\dot{Q}_c',
label: fr ? 'Chaleur extraite (froid)' : 'Heat extracted (cold)',
hint: fr
? 'Charge utile du réfrigérateur'
: 'Useful fridge cooling load',
}
}
if (mode === 'heat_pump') {
return {
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
label: fr ? 'Chaleur fournie (chaud)' : 'Heat delivered (hot)',
hint: fr ? 'Charge utile de la PAC' : 'Useful heat-pump output',
}
}
return {
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
label: fr ? 'Chaleur absorbée (chaud)' : 'Heat absorbed (hot)',
hint: fr ? 'Entrée thermique du moteur' : 'Engine heat input',
}
}, [mode, qty, fr])
const modeLoad = modeLoadFromFridgeLoad(
values.t_cold,
values.t_hot,
values.q_cold,
mode
)
const workSym = qty === 'energy' ? 'W' : 'P'
const qLabel = (base: 'c' | 'h') => (qty === 'energy' ? `Q_${base}` : `Q̇_${base}`)
const lawLine = !phys.ok
? fr
? 'Il faut T_h > T_c (températures absolues).'
: 'Need T_h > T_c (absolute temperatures).'
: fr
? `1ᵉʳ principe : ${qLabel('h')} = ${qLabel('c')} + ${workSym}${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
: `1st law: ${qLabel('h')} = ${qLabel('c')} + ${workSym}${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Segmented
value={mode}
onChange={setMode}
ariaLabel={fr ? 'Mode machine' : 'Machine mode'}
options={MODES.map((m) => ({ id: m.id, label: fr ? m.fr : m.en }))}
/>
<Segmented
value={qty}
onChange={setQty}
ariaLabel={fr ? 'Énergie ou puissance' : 'Energy or power'}
options={QTY.map((q) => ({ id: q.id, label: fr ? q.fr : q.en }))}
/>
<Segmented
value={tempUnit}
onChange={setTempUnit}
ariaLabel={fr ? 'Unité de température' : 'Temperature unit'}
options={TEMP_UNITS.map((u) => ({ id: u.id, label: u.label }))}
/>
</div>
<div className="grid gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<div>
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="mx-auto w-full max-w-md"
role="img"
aria-label={fr ? sim.title.fr : sim.title.en}
>
<rect
x={cx - 105}
y={hotY - 18}
width={210}
height={34}
rx={10}
fill={`${cHot}14`}
stroke={cHot}
strokeWidth={1.5}
/>
<text
x={cx}
y={hotY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cHot}
dominantBaseline="middle"
>
{fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)}
</text>
<rect
x={cx - 105}
y={coldY - 18}
width={210}
height={34}
rx={10}
fill={`${cCold}14`}
stroke={cCold}
strokeWidth={1.5}
/>
<text
x={cx}
y={coldY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cCold}
dominantBaseline="middle"
>
{fr ? 'Source froide' : 'Cold'} · T_c ={' '}
{formatTemp(values.t_cold, tempUnit)}
</text>
<circle
cx={cx}
cy={midY}
r={r}
fill={dark ? '#141820' : '#ffffff'}
stroke={text}
strokeWidth={1.5}
/>
<text
x={cx}
y={midY}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fill={text}
dominantBaseline="middle"
>
{fr ? 'Machine' : 'Engine'}
</text>
{phys.ok && !isEngine ? (
<>
{/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */}
<FlowArrow
x1={cx - qOffset}
y1={coldY - 22}
x2={cx - qOffset}
y2={midY + r + 2}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + qOffset}
y1={midY - r - 2}
x2={cx + qOffset}
y2={hotY + 22}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx + r + 78}
y1={midY}
x2={cx + r + 4}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
{phys.ok && isEngine ? (
<>
{/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */}
<FlowArrow
x1={cx + qOffset}
y1={hotY + 22}
x2={cx + qOffset}
y2={midY - r - 2}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx - qOffset}
y1={midY + r + 2}
x2={cx - qOffset}
y2={coldY - 22}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + r + 4}
y1={midY}
x2={cx + r + 78}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
<text x={10} y={SVG_H - 10} fontSize={10} fill={dark ? '#8b8b93' : '#6b7280'}>
{fr
? qty === 'energy'
? 'Épaisseur ∝ énergie (kJ) — W = travail (pas le watt)'
: 'Épaisseur ∝ puissance — P et W (watt) = même unité ici'
: qty === 'energy'
? 'Thickness ∝ energy (kJ) — W = work (not the watt)'
: 'Thickness ∝ power — P uses watts'}
</text>
</svg>
<p className="mt-2 text-center text-[11px] text-muted-foreground">{lawLine}</p>
{phys.ok && phys.entropyOk ? (
<p className="mt-1 text-center text-[10px] text-muted-foreground/80">
{fr
? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h'
: '2nd law (reversible): Q_c/T_c = Q_h/T_h'}
</p>
) : null}
</div>
<div className="space-y-3">
<div>
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{sim.params
.filter((p) => p.id === 't_cold' || p.id === 't_hot')
.map((p) => {
const meta = tempSliderMeta(p, tempUnit)
const displayVal = kelvinToDisplay(values[p.id], tempUnit)
return (
<SimSlider
key={`${p.id}-${tempUnit}`}
symbol={p.symbol}
label={fr ? p.label.fr : p.label.en}
unit={meta.unitLabel}
min={meta.min}
max={meta.max}
step={meta.step}
value={
tempUnit === 'K'
? Math.round(displayVal)
: Math.round(displayVal * 10) / 10
}
intent={p.intent}
onChange={(v) => {
const k = displayToKelvin(v, tempUnit)
const clamped = Math.min(p.max, Math.max(p.min, k))
setValues((s) => ({ ...s, [p.id]: clamped }))
}}
/>
)
})}
<SimSlider
key={`load-${mode}-${qty}`}
symbol={loadMeta.symbol}
label={loadMeta.label}
unit={unitE}
min={10}
max={500}
step={5}
value={Math.round(modeLoad * 10) / 10}
intent="flow"
onChange={(v) => {
const fridgeQc = fridgeLoadFromModeLoad(
values.t_cold,
values.t_hot,
v,
mode
)
setValues((s) => ({
...s,
q_cold: Math.min(500, Math.max(10, fridgeQc)),
}))
}}
/>
<p className="px-1 text-[10px] text-muted-foreground">{loadMeta.hint}</p>
</div>
</div>
<div>
<SimHeading>
{fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'}
</SimHeading>
<div className="grid grid-cols-2 gap-2">
{mode === 'fridge' || mode === 'heat_pump' ? (
<>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{R}`}
label={fr ? 'COP frigo' : 'Fridge COP'}
value={phys.copR}
intent="output"
digits={2}
/>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{HP}`}
label={fr ? 'COP PAC' : 'Heat-pump COP'}
value={phys.copHP}
intent="output"
digits={2}
/>
</>
) : (
<SimOutputCard
symbol={String.raw`\eta`}
label={fr ? 'Rendement moteur' : 'Engine efficiency'}
value={phys.eta * 100}
unit="%"
intent="highlight"
digits={1}
/>
)}
<SimOutputCard
symbol={workSym}
label={
isEngine
? fr
? `${workName} fourni`
: `${workName} out`
: fr
? `${workName} minimale`
: `Minimum ${workName.toLowerCase()}`
}
value={phys.w}
unit={unitE}
intent="compute"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_h` : String.raw`\dot{Q}_h`
}
label={
isEngine
? fr
? 'Chaleur absorbée'
: 'Heat absorbed'
: fr
? 'Chaleur côté chaud'
: 'Hot-side heat'
}
value={phys.qh}
unit={unitE}
intent="flow"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_c` : String.raw`\dot{Q}_c`
}
label={
isEngine
? fr
? 'Chaleur rejetée (froid)'
: 'Heat rejected (cold)'
: fr
? 'Chaleur côté froid'
: 'Cold-side heat'
}
value={phys.qc}
unit={unitE}
intent="cache"
digits={1}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{qty === 'energy'
? fr
? 'W = travail (énergie en kJ), pas le watt. Passe en « Puissance (W) » pour raisonner en watts.'
: 'W = work (energy in kJ), not the watt. Switch to “Power (W)” to use watts.'
: fr
? 'Mode puissance : P, Q̇_c et Q̇_h sont en watts (W). Les COP / η restent sans unité.'
: 'Power mode: P, Q̇_c and Q̇_h are in watts (W). COP / η stay dimensionless.'}
</p>
</div>
</div>
</div>
{disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,205 @@
'use client'
import { useMemo, useState } from 'react'
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Bar,
BarChart,
} from 'recharts'
import type { GenericFormulaSim } from '@/lib/interactive-page'
import { parseSimExpr } from '@/lib/interactive-page/sim-eval'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
const CURVE_SAMPLES = 60
/** Generic slider-driven formula simulator (safe exprs, no eval). */
export function GenericFormulaView({
sim,
lang,
}: {
sim: GenericFormulaSim
lang: string
}) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const [values, setValues] = useState<Record<string, number>>(() => {
const env: Record<string, number> = {}
for (const p of sim.params) env[p.id] = p.defaultValue
return env
})
const compiled = useMemo(
() =>
sim.computed.map((c) => ({
def: c,
parsed: parseSimExpr(c.expr),
})),
[sim.computed]
)
const results = useMemo(() => {
const env = { ...values }
const out: Record<string, number> = {}
for (const c of compiled) {
const v = 'message' in c.parsed ? NaN : c.parsed.evaluate(env)
env[c.def.id] = v
out[c.def.id] = v
}
return out
}, [compiled, values])
const visual = sim.visual
const curve = useMemo(() => {
if (visual.kind !== 'curve') return null
const xParam = sim.params.find((p) => p.id === visual.xParamId)
const parsed = parseSimExpr(visual.expr)
if (!xParam || 'message' in parsed) return null
const pts: { x: number; y: number }[] = []
for (let i = 0; i <= CURVE_SAMPLES; i++) {
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
const y = parsed.evaluate({ ...values, [xParam.id]: x })
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
}
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) }
}, [visual, sim.params, values])
const accent = intentColor('highlight', dark)
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
return (
<div>
{sim.intro ? (
<p className="mb-4 text-[15px] leading-relaxed text-muted-foreground">
{sim.intro}
</p>
) : null}
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{sim.params.map((p) => (
<SimSlider
key={p.id}
symbol={p.symbol}
label={p.label}
unit={p.unit}
min={p.min}
max={p.max}
step={p.step}
value={values[p.id]}
intent={p.intent}
onChange={(v) => setValues((s) => ({ ...s, [p.id]: v }))}
/>
))}
</div>
</div>
<div className="space-y-3">
<SimHeading>{fr ? 'Résultats' : 'Results'}</SimHeading>
{sim.visual.kind === 'gauges' ? (
<div className="space-y-2">
{sim.computed.map((c) => {
const v = results[c.id]
const maxRef = Math.max(
...sim.computed.map((o) => Math.abs(results[o.id] || 0)),
1
)
const color = intentColor(c.intent, dark)
return (
<div key={c.id} className="rounded-xl border border-border/50 bg-background/70 px-3 py-2">
<div className="flex items-baseline justify-between text-xs">
<span className="text-muted-foreground">{c.label}</span>
<span className="font-semibold tabular-nums" style={{ color }}>
{Number.isFinite(v) ? v.toFixed(2) : '—'}
{c.unit ? ` ${c.unit}` : ''}
</span>
</div>
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full transition-[width] duration-150"
style={{
width: `${Math.min(100, (Math.abs(v || 0) / maxRef) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</div>
)
})}
</div>
) : null}
{sim.visual.kind === 'bars' ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sim.computed.map((c) => ({
name: c.label,
value: Number.isFinite(results[c.id]) ? results[c.id] : 0,
}))}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="name" tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Bar dataKey="value" fill={accent} radius={[4, 4, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
) : null}
{sim.visual.kind === 'curve' && curve ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={curve.pts} margin={{ top: 8, right: 8, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis
dataKey="x"
type="number"
domain={[curve.xParam.min, curve.xParam.max]}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => String(Math.round(v))}
/>
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Line type="monotone" dataKey="y" stroke={accent} strokeWidth={2.5} dot={false} isAnimationActive={false} />
{Number.isFinite(curve.currentY) ? (
<ReferenceDot x={curve.currentX} y={curve.currentY} r={5} fill={accent} stroke="none" />
) : null}
</LineChart>
</ResponsiveContainer>
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
{sim.computed.map((c) => (
<SimOutputCard
key={c.id}
symbol={c.symbol}
label={c.label}
value={results[c.id]}
unit={c.unit}
intent={c.intent}
/>
))}
</div>
</div>
</div>
{sim.disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{sim.disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,27 @@
import type { ComponentType } from 'react'
import { CarnotCycleView } from './carnot-cycle-view'
import { CarnotCycleAnimView } from './carnot-cycle-anim-view'
import { TsDiagramView } from './ts-diagram-view'
export type CatalogSimViewProps = {
preset?: Record<string, number>
title?: string
disclaimer?: string
lang: string
}
export type CatalogAnimViewProps = {
step: number
lang: string
}
/** Registry: catalog simId → bespoke slider-simulation view. */
export const SIMULATOR_VIEWS: Record<string, ComponentType<CatalogSimViewProps>> = {
'carnot-cycle': CarnotCycleView,
}
/** Registry: catalog simId → bespoke animated scene (step-driven). */
export const ANIM_VIEWS: Record<string, ComponentType<CatalogAnimViewProps>> = {
'carnot-cycle-anim': CarnotCycleAnimView,
'ts-diagram': TsDiagramView,
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
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'
export function SimKaTeX({ tex, className }: { tex: string; className?: string }) {
const html = useMemo(() => {
try {
// output:'html' avoids MathML annotation leaking as visible "mathrm{…}" in flex layouts
return katex.renderToString(tex, { throwOnError: false, output: 'html' })
} catch {
return tex
}
}, [tex])
return (
<span
className={cn('inline-block max-w-full [&_.katex]:text-[1em]', className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function SimSlider({
symbol,
label,
unit,
min,
max,
step,
value,
intent,
onChange,
}: {
symbol: string
label: string
unit?: string
min: number
max: number
step: number
value: number
intent?: IntentId
onChange: (v: number) => void
}) {
const dark = useDarkMode()
const accent = intentColor(intent, dark)
return (
<label className="block rounded-xl border border-border/50 bg-background/70 px-3 py-2.5">
<span className="flex items-baseline justify-between gap-2">
<span className="flex items-baseline gap-2 text-sm font-medium">
<SimKaTeX tex={symbol} />
<span className="text-xs text-muted-foreground">{label}</span>
</span>
<span
className="rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums"
style={{ color: accent, backgroundColor: `${accent}18` }}
>
{value}
{unit ? ` ${unit}` : ''}
</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sim-slider mt-2 w-full"
style={{ accentColor: accent }}
aria-label={label}
/>
<span className="mt-0.5 flex justify-between text-[10px] tabular-nums text-muted-foreground/70">
<span>
{min}
{unit ? ` ${unit}` : ''}
</span>
<span>
{max}
{unit ? ` ${unit}` : ''}
</span>
</span>
</label>
)
}
export function SimOutputCard({
symbol,
label,
value,
unit,
intent,
digits = 2,
}: {
symbol: string
label: string
value: number
unit?: string
intent?: IntentId
digits?: number
}) {
const dark = useDarkMode()
const accent = intentColor(intent, dark)
const finite = Number.isFinite(value)
return (
<div className="rounded-xl border border-border/50 bg-background/70 px-3 py-2.5 text-center">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-1 flex items-baseline justify-center gap-1.5">
<SimKaTeX tex={symbol} className="text-sm" />
<span
className={cn('text-lg font-semibold tabular-nums', !finite && 'text-muted-foreground')}
style={finite ? { color: accent } : undefined}
>
{finite ? value.toFixed(digits) : '—'}
{finite && unit ? ` ${unit}` : ''}
</span>
</div>
</div>
)
}
/** Small section heading inside a simulator card. */
export function SimHeading({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<p
className={cn(
'mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground',
className
)}
>
{children}
</p>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Ts diagram of the Carnot cycle — canonical 2nd-law diagram.
* Driven by AnimPlayerShell via `step` (0..4). SVG, transform/opacity only.
* Cycle = rectangle: horizontal isotherms (Th, Tc), vertical adiabatics.
*/
const X0 = 110 // y-axis x
const Y0 = 280 // x-axis y
const S1 = 190 // entropy left
const S2 = 520 // entropy right
const YH = 80 // T_h line y
const YC = 220 // T_c line y
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
// marker position per beat (end of each phase)
const MARKER = [
{ x: S2, y: YH }, // b1 end: right-top
{ x: S2, y: YC }, // b2 end: right-bottom
{ x: S1, y: YC }, // b3 end: left-bottom
{ x: S1, y: YH }, // b4 end: left-top
{ x: S1, y: YH }, // b5: stay
]
export function TsDiagramView({ step, lang }: { step: number; lang: string }) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const s = Math.min(step, 4)
const ink = dark ? '#EDE7DB' : '#242422'
const muted = dark ? '#A39C8D' : '#686762'
const plum = dark ? '#D07AA6' : '#9F3F70'
const blue = dark ? '#7FA8CC' : '#3F6F9F'
const paper = dark ? '#17140F' : '#F4F0E8'
const m = MARKER[s]
const qhW = S2 - S1
return (
<svg
viewBox="0 0 680 320"
className="w-full"
role="img"
aria-label={fr ? 'Diagramme Ts du cycle de Carnot' : 'Ts diagram of the Carnot cycle'}
style={{ background: paper, display: 'block' }}
>
{/* axes */}
<line x1={X0} y1={Y0} x2={650} y2={Y0} stroke={ink} strokeWidth={1.5} />
<line x1={X0} y1={Y0} x2={X0} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={648} y={Y0 + 18} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">s</text>
<text x={X0 - 8} y={40} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">T</text>
{/* isotherm T_h */}
<line x1={X0} y1={YH} x2={650} y2={YH} stroke={plum} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YH + 4} fontSize={11} fill={plum} textAnchor="end" fontWeight={600}>T_h</text>
{/* isotherm T_c */}
<line x1={X0} y1={YC} x2={650} y2={YC} stroke={blue} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YC + 4} fontSize={11} fill={blue} textAnchor="end" fontWeight={600}>T_c</text>
{/* Q_h area (beat ≥ 0, shown from beat 0) */}
<rect
x={S1} y={YH} width={qhW} height={Y0 - YH}
fill={plum} opacity={s >= 0 ? 0.10 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 0 ? (
<text x={(S1 + S2) / 2} y={(YH + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={plum}>Q_h</text>
) : null}
{/* Q_c area (from beat 2) */}
<rect
x={S1} y={YC} width={qhW} height={Y0 - YC}
fill={blue} opacity={s >= 2 ? 0.16 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s >= 2 && s < 4 ? (
<text x={(S1 + S2) / 2} y={(YC + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={blue}>Q_c</text>
) : null}
{/* W = cycle area (beat 4) */}
<rect
x={S1} y={YH} width={qhW} height={YC - YH}
fill={plum} opacity={s === 4 ? 0.18 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 4 ? (
<text x={(S1 + S2) / 2} y={(YH + YC) / 2} textAnchor="middle" fontSize={17} fontWeight={800} fill={plum}>W</text>
) : null}
{/* cycle edges, drawn progressively */}
{/* top: b1 (isothermal expansion) */}
<line x1={S1} y1={YH} x2={S2} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 0 ? 1 : 0.15} />
<polygon points={`${S2 - 14},${YH - 5} ${S2 - 4},${YH} ${S2 - 14},${YH + 5}`} fill={ink} opacity={s >= 0 ? 1 : 0.15} />
{/* right: b2 (adiabatic expansion) */}
<line x1={S2} y1={YH} x2={S2} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S2 - 5},${YC - 14} ${S2},${YC - 4} ${S2 + 5},${YC - 14}`} fill={ink} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* bottom: b3 (isothermal compression) */}
<line x1={S2} y1={YC} x2={S1} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 + 14},${YC - 5} ${S1 + 4},${YC} ${S1 + 14},${YC + 5}`} fill={ink} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* left: b4 (adiabatic compression) */}
<line x1={S1} y1={YC} x2={S1} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 - 5},${YH + 14} ${S1},${YH + 4} ${S1 + 5},${YH + 14}`} fill={ink} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* entropy ticks */}
<text x={S1} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
<text x={S2} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
{/* state marker */}
<circle cx={m.x} cy={m.y} r={6} fill={plum} stroke={paper} strokeWidth={2} style={{ transition: EASE }} />
</svg>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import {
ReactNodeViewRenderer,
NodeViewWrapper,
type NodeViewProps,
} from '@tiptap/react'
import type { Editor } from '@tiptap/core'
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
import {
validateInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json'
import { AlertCircle } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useMemo } from 'react'
function InteractiveDemoView(props: NodeViewProps) {
const { t } = useLanguage()
const raw = props.node.attrs.demoJson as string
const parsed = useMemo(() => {
try {
const data = JSON.parse(raw || '{}')
return validateInteractiveDemo(data)
} catch {
return {
ok: false as const,
issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }],
}
}
}, [raw])
if (!parsed.ok) {
return (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="font-medium">
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
</p>
<ul className="mt-1 text-muted-foreground list-disc list-inside">
{parsed.issues.slice(0, 5).map((iss, i) => (
<li key={i}>
{iss.path ? `${iss.path}: ` : ''}
{iss.message}
</li>
))}
</ul>
</div>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
</NodeViewWrapper>
)
}
export const InteractiveDemoExtension = Node.create({
name: 'interactiveDemo',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
demoJson: {
default: '{}',
parseHTML: (el) => el.getAttribute('data-demo-json') || '{}',
renderHTML: (attrs) => ({
'data-demo-json': attrs.demoJson || '{}',
}),
},
}
},
parseHTML() {
return [{ tag: 'div[data-interactive-demo]' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }),
]
},
addNodeView() {
return ReactNodeViewRenderer(InteractiveDemoView)
},
})
export function insertInteractiveDemoAtSelection(
editor: Editor,
demo?: InteractiveDemoV1
): boolean {
const type = editor.schema.nodes.interactiveDemo
if (!type) return false
const payload = demo ?? (attnresFixture as InteractiveDemoV1)
const check = validateInteractiveDemo(payload)
// Prefer validated shape; if already-server-validated payload fails client Zod
// (HMR drift), still insert so the block appears — NodeView shows issues if needed.
const attrs = {
demoJson: JSON.stringify(check.ok ? check.demo : payload),
}
const { empty, $from } = editor.state.selection
const pos = empty ? $from.pos : editor.state.selection.from
return editor
.chain()
.focus()
.insertContentAt(pos, { type: 'interactiveDemo', attrs })
.run()
}

View File

@@ -40,6 +40,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts',
interactive_demo: 'usageMeter.featureInteractiveDemo',
interactive_page: 'usageMeter.featureInteractivePage',
publish_enhance: 'usageMeter.featurePublishEnhance',
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',

View File

@@ -0,0 +1,29 @@
# Publication — point de branchement `interactive-page`
## Canal existant (ne pas dupliquer)
| Élément | Emplacement |
|---|---|
| API | `app/api/notes/publish/route.ts``action: publish \| unpublish`, `mode?: simple \| ai \| interactive-page` |
| UI | `components/note-editor/note-editor-toolbar.tsx` (menu Globe) + `InteractivePagePublishDialog` |
| Page publique | `app/(public)/p/[slug]/page.tsx``/p/{publicSlug}` |
| Fetch | `getPublishedNote(slug)` dans `app/actions/notes-publishing.ts` |
| Templates | `lib/publish/types.ts``PUBLISH_TEMPLATES` = `magazine \| brief \| essay \| interactive-page` |
### Champs `Note` (Prisma)
- `isPublic`, `publicSlug`, `publishedAt`
- `publishedContent` — snapshot opaque (`String?`) : HTML aujourdhui, **JSON PageSpecV1** pour `interactive-page`
- `publishedTemplate` — discriminant de rendu
- `publishedSourceHash` — détection stale (pas de régénération silencieuse)
## Branchement `interactive-page`
1. Étendre `PUBLISH_TEMPLATES` avec `'interactive-page'`.
2. Publish : générer/valider via `lib/interactive-page/` → stocker `JSON.stringify(PageSpecV1)` dans `publishedContent`, `publishedTemplate = 'interactive-page'`.
3. Rendu public : dans `PublishedNotePage`, si `publishedTemplate === 'interactive-page'` → parser → `<PageView spec={…} />` (SSR + hydrate démos).
4. Unpublish / stale : **même** cycle de vie que magazine/brief/essay (`publishedSourceHash` → badge « à régénérer »).
## Hors scope
- `NotebookSite` `/c/[slug]`, `NoteShare` (collab privée), server actions legacy `publishNote` incomplets.

View File

@@ -0,0 +1,74 @@
import type { InteractiveDemoV1, ValidationIssue } from '@/lib/interactive-demo'
export type GenerateInteractiveDemoResponse =
| { ok: true; demo: InteractiveDemoV1; attempts: number }
| {
ok: false
error: string
issues?: ValidationIssue[]
quotaExceeded?: boolean
status?: number
}
export async function generateInteractiveDemo(params: {
content: string
selection?: string | null
lang?: string
noteId?: string
}): Promise<GenerateInteractiveDemoResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 170_000)
let res: Response
try {
res = await fetch('/api/ai/interactive-demo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: params.content,
selection: params.selection,
lang: params.lang,
noteId: params.noteId,
}),
signal: controller.signal,
})
} catch (err) {
clearTimeout(timeout)
const aborted = err instanceof Error && err.name === 'AbortError'
return {
ok: false,
error: aborted
? 'Génération trop longue — réessaie (ou raccourcis la note)'
: err instanceof Error
? err.message
: 'Network error',
}
} finally {
clearTimeout(timeout)
}
const data = await res.json().catch(() => ({}))
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
issues: data.issues,
status: res.status,
}
}
return {
ok: true,
demo: data.demo,
attempts: data.attempts ?? 1,
}
}

View File

@@ -0,0 +1,291 @@
import { generateText } from 'ai'
import type { AIProvider } from '@/lib/ai/types'
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
PANEL_TYPES,
INTENT_IDS,
ANNOTATION_KINDS,
SPEAK_WRITING_RULE,
validateInteractiveDemo,
normalizeInteractiveDemoCandidate,
type InteractiveDemoV1,
type ValidationIssue,
} from '@/lib/interactive-demo'
const MAX_ATTEMPTS = 2
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function extractJsonObject(raw: string): unknown | null {
if (!raw) return null
const cleaned = cleanAIJsonResponse(raw)
const tryParse = (s: string): unknown | null => {
try {
return JSON.parse(s)
} catch {
return null
}
}
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
if (parsed) return parsed
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fence?.[1]?.trim() ?? cleaned
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
if (parsed) return parsed
const start = candidate.indexOf('{')
const end = candidate.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = candidate.slice(start, end + 1)
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
if (parsed) return parsed
}
return null
}
/** STEM cycle example — equations in speak + node labels via $...$ */
const STEM_CYCLE_EXAMPLE = `{
"schemaVersion": 1,
"id": "demo.refrigeration-cycle",
"lang": "fr",
"disclaimer": "Schéma pédagogique — grandeurs illustratives.",
"scene": {
"id": "scene.cycle",
"panels": [{
"id": "panel.cycle",
"type": "svg-scene",
"payload": {
"nodes": [
{ "id": "compressor", "label": "1 · Compresseur\\n$W = h_2 - h_1$", "intent": "compute" },
{ "id": "condenser", "label": "2 · Condenseur\\n$Q_c = h_2 - h_3$", "intent": "output" },
{ "id": "expansion", "label": "3 · Détente\\n$h_3 = h_4$", "intent": "flow" },
{ "id": "evaporator", "label": "4 · Évaporateur\\n$Q_e = h_1 - h_4$", "intent": "cache" }
],
"edges": [
{ "id": "e12", "from": "compressor", "to": "condenser", "style": "solid", "intent": "flow" },
{ "id": "e23", "from": "condenser", "to": "expansion", "style": "solid", "intent": "flow" },
{ "id": "e34", "from": "expansion", "to": "evaporator", "style": "solid", "intent": "flow" },
{ "id": "e41", "from": "evaporator", "to": "compressor", "style": "solid", "intent": "flow" }
]
}
}]
},
"acts": [{
"id": "a1",
"title": "Cycle frigorifique",
"pattern": "flowTrace",
"steps": [
{ "id": "a1.s1", "speak": "Compression : le travail fourni est $W = h_2 - h_1$.", "pattern": "spotlightTour", "pointTo": ["compressor"], "reveal": [{"ids":["compressor"],"scope":"act"}] },
{ "id": "a1.s2", "speak": "Au condenseur, la chaleur rejetée : $Q_c = h_2 - h_3$.", "pattern": "spotlightTour", "pointTo": ["condenser"], "reveal": [{"ids":["condenser","e12"],"scope":"act"}] },
{ "id": "a1.s3", "speak": "Détente isenthalpique : $h_3 = h_4$.", "pattern": "spotlightTour", "pointTo": ["expansion"], "reveal": [{"ids":["expansion","e23"],"scope":"act"}] },
{ "id": "a1.s4", "speak": "Évaporateur : $Q_e = h_1 - h_4$. COP $= Q_e / W$.", "pattern": "overview", "pointTo": ["evaporator"], "reveal": [{"ids":["evaporator","e34","e41"],"scope":"act"}] }
]
}]
}`
function buildSystemPrompt(lang: string, hasMath: boolean): string {
return `You generate Interactive Demo JSON for Memento notes (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}).
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
MISSION: teach the ACTUAL content of the note — concepts, quantities, AND equations.
You are NOT allowed to invent a toy 2-node graph ("A" → "B") that ignores the note.
CONTENT FIDELITY (critical):
- Read EVERY extracted formula. Put the important ones in speak as inline KaTeX: $...$
- Put key equations on the matching node labels too (use \\n then $latex$).
- Use the note's real names (Compresseur, Condenseur, COP, …) — never vague placeholders.
- For a physical CYCLE / loop (frigo, Carnot, Rankine, feedback…): 4+ stage nodes + cycle edges closing the loop.
- For a derivation: nodes = successive expressions / steps, speak cites the formula at each step.
- Min 3 nodes for svg-scene (4+ for cycles). Min 3 steps. Prefer 46 steps.
- NEVER output only 2 boxes with plain words and zero equations when the note has math.
PANEL CHOICE:
- Process / cycle / architecture → svg-scene (rich graph)
- Time series / comparison of measured values → chart
- Matrix / attention / correlation → heatmap-matrix
- Max 2 panels. May combine svg-scene + chart if useful.
SCHEMA:
{
"schemaVersion": 1,
"id": "demo.<slug>",
"lang": "${lang}",
"disclaimer?": string,
"scene": { "id?": string, "panels": [Panel] },
"acts": [ { "id": "a1", "title": string, "pattern?", "steps": [Step] } ]
}
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?", "reveal?":[{"ids":[],"scope":"transient|act|scene"}], "annotate?":[{"kind","targetIds","scope","text?","intent?"}] }
Panel types: ${PANEL_TYPES.join(', ')}
Patterns: ${PATTERN_IDS.join(', ')}
Intents: ${INTENT_IDS.join(', ')}
Annotation kinds: ${ANNOTATION_KINDS.join(', ')}
chartType: line | bar | area
Heatmap cells: r{row}.c{col}; triangular:"lower" when appropriate.
svg-scene: nodes[{id,label?,intent?}], edges[{id,from,to,style?,weight?,intent?}]
chart: series[{id,label?,values:number[],intent?}]
${hasMath ? `STEM MODE ON: formulas were extracted from the note. You MUST:
- Include at least 2 distinct equations from FORMULES_EXTRAITES in speak and/or node labels as $latex$
- Prefer flowTrace / spotlightTour over vague overview-only demos
- Example shape to imitate (adapt to THIS note's real formulas/stages):
${STEM_CYCLE_EXAMPLE}` : `If the note has little math, still build a faithful conceptual diagram (≥3 nodes) from the real vocabulary of the note.`}
RULES:
- ${SPEAK_WRITING_RULE}
- speak may include light markdown + inline $KaTeX$ (required for STEM).
- Never put hex/rgb colors — intents only.
- Never annotate an id that is also in pointTo on the same step.
- Step ids = a1.s1, a1.s2… matching act id.
- All pointTo/reveal/annotate ids must exist in the active scene.
- Wildcard "*" reveal ONLY for heatmap/chart panels — not svg-scene alone.
- Narration language = lang ("${lang}").
- disclaimer when values are pedagogical/illustrative.`
}
function buildUserPrompt(
content: string,
assets: ReturnType<typeof extractSourceAssets>,
opts?: {
issues?: ValidationIssue[]
previousJson?: string
}
): string {
const plain = stripToPlain(content).slice(0, 7000)
let msg = `Create an Interactive Demo that teaches THIS note faithfully (equations included).
EXCERPT_START
${plain}
EXCERPT_END
`
if (assets.formulas.length) {
msg += `\nFORMULES_EXTRAITES (OBLIGATOIRE — réutilise telles quelles en $...$ dans speak et labels):\n`
msg += assets.formulas
.slice(0, 16)
.map((f, i) => `${i + 1}. ${f}`)
.join('\n')
msg += '\n'
}
if (assets.keySentences.length) {
msg += `\nPHRASES_CLES:\n`
msg += assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')
msg += '\n'
}
if (assets.numbers.length) {
msg += `\nDONNEES_NUMERIQUES:\n${JSON.stringify(assets.numbers.slice(0, 8))}\n`
}
msg += `\nCONTRAINTES: ≥3 nœuds svg (cycle → 4+ et boucle fermée). speak avec $équations$ si FORMULES_EXTRAITES non vide. Interdit: schéma jouet à 2 boîtes sans maths.\n`
if (opts?.issues?.length) {
msg += `\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
msg += opts.issues
.slice(0, 12)
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
.join('\n')
}
if (opts?.previousJson) {
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 12000)}\nPREVIOUS_JSON_END`
}
return msg
}
export type GenerateInteractiveDemoInput = {
content: string
lang?: string
provider: AIProvider
}
export type GenerateInteractiveDemoResult =
| { ok: true; demo: InteractiveDemoV1; attempts: number }
| { ok: false; issues: ValidationIssue[]; raw?: string; attempts: number }
/**
* LLM → JSON → normalize → validateInteractiveDemo, with repair passes.
* Harvests formulas like slide generation before prompting.
*/
export async function generateInteractiveDemoFromContent(
input: GenerateInteractiveDemoInput
): Promise<GenerateInteractiveDemoResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const system = buildSystemPrompt(lang, assets.hasMath || assets.formulas.length > 0)
const model = input.provider.getModel()
let lastIssues: ValidationIssue[] = []
let lastRaw = ''
let lastNormalizedJson = ''
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const user = buildUserPrompt(input.content, assets, {
issues: attempt > 1 ? lastIssues : undefined,
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
})
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.25 : 0.1,
})
lastRaw = raw
const parsed = extractJsonObject(raw)
if (!parsed) {
lastIssues = [
{
code: 'invalid_json',
path: '',
message: 'Model did not return parseable JSON',
},
]
console.warn(
`[interactive-demo] attempt ${attempt}: unparseable JSON`,
raw.slice(0, 400)
)
continue
}
const normalized = normalizeInteractiveDemoCandidate(parsed, lang)
lastNormalizedJson = JSON.stringify(normalized)
const result = validateInteractiveDemo(normalized)
if (result.ok) {
return { ok: true, demo: result.demo, attempts: attempt }
}
lastIssues = result.issues
console.warn(
`[interactive-demo] attempt ${attempt}: validation failed`,
result.issues.slice(0, 8)
)
}
return {
ok: false,
issues: lastIssues,
raw: lastRaw,
attempts: MAX_ATTEMPTS,
}
}

View File

@@ -0,0 +1,211 @@
import type {
PageSection,
PageSpecV1,
PageValidationIssue,
} from '@/lib/interactive-page'
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
export type PagePlanSection = {
title: string
goal: string
demoKind: PagePlanDemoKind
demoGoal?: string
}
export type PagePlan = {
heroTitle: string
heroSubtitle?: string
overviewLead: string
overviewCards: {
badge: string
title: string
body: string
intent?: string
}[]
sections: PagePlanSection[]
}
export type GenerateInteractivePageResponse =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
error: string
reason?: string
issues?: PageValidationIssue[]
quotaExceeded?: boolean
status?: number
}
export type GeneratePlanResponse =
| { ok: true; plan: PagePlan; attempts: number }
| {
ok: false
error: string
reason?: string
quotaExceeded?: boolean
status?: number
}
export type GenerateSectionResponse =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; error: string; status?: number }
async function postJson(
body: Record<string, unknown>,
timeoutMs = 55_000
): Promise<{ res: Response; data: Record<string, never> } | { abortError: true }> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
const data = await res.json().catch(() => ({}))
return { res, data }
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { abortError: true }
}
throw err
} finally {
clearTimeout(timeout)
}
}
/** LLM plan (billed — 20 crédits). */
export async function generateInteractivePagePlan(params: {
content: string
lang?: string
noteId?: string
}): Promise<GeneratePlanResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'plan' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'Génération trop longue — réessaie' }
}
const { res, data } = out as {
res: Response
data: any
}
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
status: res.status,
}
}
return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 }
}
/** LLM single section (not billed — page billed at plan time). */
export async function generateInteractivePageSection(params: {
content: string
lang?: string
noteId?: string
pageTitle: string
sectionId: string
section: PagePlanSection
}): Promise<GenerateSectionResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'section' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'timeout' }
}
const { res, data } = out as {
res: Response
data: any
}
if (!res.ok) {
return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status }
}
return { ok: true, section: data.section, attempts: data.attempts ?? 1 }
}
/** Legacy deterministic full page (fallback, no quota). */
export async function generateInteractivePage(params: {
content: string
lang?: string
noteId?: string
notebookId?: string
}): Promise<GenerateInteractivePageResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 75_000)
let res: Response
try {
res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: controller.signal,
})
} catch (err) {
clearTimeout(timeout)
const aborted = err instanceof Error && err.name === 'AbortError'
return {
ok: false,
error: aborted
? 'Génération trop longue — réessaie'
: err instanceof Error
? err.message
: 'Network error',
}
} finally {
clearTimeout(timeout)
}
const data = await res.json().catch(() => ({}))
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
issues: data.issues,
status: res.status,
}
}
return {
ok: true,
page: data.page,
attempts: data.attempts ?? 1,
}
}

View File

@@ -0,0 +1,398 @@
import type { AIProvider } from '@/lib/ai/types'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
validateInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import {
validateInteractivePage,
type PageSpecV1,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize'
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
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'
}
function firstSentence(text: string, max = 100): string {
const s = text.split(/[.!?。]/)[0]?.trim() || text.trim()
return s.slice(0, max) || 'Page interactive'
}
function chunkSentences(text: string): string[] {
return text
.split(/(?<=[.!?。])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 20)
}
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
/**
* Instant Play/Step demo from note vocabulary — no LLM.
* 34 nodes + spotlight steps; formulas in speak when available.
*/
export function buildDeterministicDemo(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): InteractiveDemoV1 | null {
const fr = lang.startsWith('fr')
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, a) => a.indexOf(s) === i)
// Prefer short phrase labels from key sentences — never raw formula fragments
const labels: string[] = []
for (const s of sentences.slice(0, 8)) {
const words = s
.replace(/\$[^$]*\$/g, ' ')
.replace(/[\\{}]/g, ' ')
.split(/\s+/)
.filter(Boolean)
.slice(0, 4)
.join(' ')
.trim()
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
labels.push(words)
}
if (labels.length >= 4) break
}
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
if (labels.length < 3) {
const fallbacks = fr
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
: ['Input', 'Transform', 'Output', 'Loop']
for (const fb of fallbacks) {
if (labels.length >= 4) break
if (!labels.includes(fb)) labels.push(fb)
}
}
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
const nodeCount = Math.min(4, Math.max(3, labels.length))
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
id: `n${i + 1}`,
label:
cleanFormulas[i] && i < 2
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
: `${i + 1} · ${label}`,
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
}))
const edges = nodes.map((n, i) => {
const next = nodes[(i + 1) % nodes.length]
return {
id: `e${i + 1}`,
from: n.id,
to: next.id,
style: 'solid' as const,
intent: 'flow' as const,
}
})
const steps = nodes.map((n, i) => {
const formula = cleanFormulas[i]
const speakBase =
sentences[i]?.slice(0, 100) ||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
const speak = formula
? `${speakBase.split('.')[0]}. $${formula}$`
: speakBase
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
if (i > 0) revealed.push(edges[i - 1].id)
const isLast = i === nodes.length - 1
return {
id: `a1.s${i + 1}`,
speak: speak.slice(0, 160),
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
pointTo: [n.id],
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
}
})
const raw = {
schemaVersion: 1 as const,
id: 'demo.page-auto',
lang,
disclaimer: fr
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
: 'Pedagogical diagram from your note — illustrative values.',
scene: {
id: 'scene.main',
panels: [
{
id: 'panel.main',
type: 'svg-scene' as const,
payload: { nodes, edges },
},
],
},
acts: [
{
id: 'a1',
title: fr ? 'Parcours' : 'Walkthrough',
pattern: 'flowTrace' as const,
steps,
},
],
}
const validated = validateInteractiveDemo(raw)
if (!validated.ok) {
console.warn(
'[interactive-page] deterministic demo invalid',
validated.issues.slice(0, 5)
)
return null
}
return validated.demo
}
export function buildPageFromNote(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> {
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, arr) => arr.indexOf(s) === i)
const title = firstSentence(sentences[0] || plain, 90)
const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title
const fr = lang.startsWith('fr')
// Displayable formulas only (KaTeX eats spaces — no prose, bounded length)
const displayFormulas = assets.formulas.filter((f) => f.length <= 120)
const formula = displayFormulas[0]
const formula2 = displayFormulas[1]
const cards = [
{
badge: 'PROBLEM',
title: fr ? 'Contexte' : 'Context',
body: (sentences[0] || lead).slice(0, 160),
intent: 'warning' as const,
},
{
badge: 'APPROACH',
title: fr ? 'Approche' : 'Approach',
body: (sentences[1] || sentences[0] || lead).slice(0, 160),
intent: 'flow' as const,
},
{
badge: 'RESULT',
title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea',
body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160),
intent: 'output' as const,
},
]
const sections: Record<string, unknown>[] = [
{
id: 's1',
title: fr ? 'Le problème' : 'The problem',
blocks: [
{ type: 'prose', md: sentences[0] || lead },
{
type: 'callout',
kind: 'definition',
title: fr ? 'En bref' : 'In short',
md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220),
},
],
},
{
id: 's2',
title: fr ? 'Mécanisme' : 'Mechanism',
blocks: [
{ type: 'prose', md: sentences[1] || sentences[0] || lead },
...(formula ? [{ type: 'formula', tex: formula }] : []),
...(formula2
? [
{
type: 'callout',
kind: 'tip',
title: fr ? 'Aussi' : 'Also',
md: `$${formula2}$`,
},
]
: []),
],
},
{
id: 's3',
title: fr ? 'Synthèse' : 'Synthesis',
blocks: [
{
type: 'prose',
md:
sentences[2] ||
(fr
? 'Retenez le mécanisme — la démo interactive en retrace le flux.'
: 'Keep the mechanism — the interactive demo traces the flow.'),
},
{
type: 'stats',
items: [
{
value: String(Math.max(assets.formulas.length, 1)),
label: fr ? 'Formules' : 'Formulas',
},
{
value: String(Math.min(Math.max(sentences.length, 3), 8)),
label: fr ? 'Idées' : 'Ideas',
},
assets.numbers[0]
? {
value: String(assets.numbers[0].value),
label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'),
}
: { value: '→', label: fr ? 'Suite' : 'Next' },
],
},
],
},
]
return {
schemaVersion: 1,
id: slugId(title),
lang,
hero: {
kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER',
title,
subtitle: (sentences[1] || plain).slice(0, 140),
meta: fr ? 'Généré depuis votre note' : 'Generated from your note',
},
overview: { lead, cards },
sections,
}
}
function injectDemo(
page: Record<string, unknown>,
demo: unknown
): Record<string, unknown> {
const sections = Array.isArray(page.sections)
? ([...page.sections] as Record<string, unknown>[])
: []
if (!sections.length) return page
const targetIdx = Math.min(1, sections.length - 1)
const target = { ...sections[targetIdx] }
const blocks = Array.isArray(target.blocks)
? [...(target.blocks as Record<string, unknown>[])]
: []
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
target.blocks = blocks
sections[targetIdx] = target
return { ...page, sections }
}
export type GenerateInteractivePageInput = {
content: string
lang?: string
/** Kept for API compatibility — page skeleton no longer depends on LLM. */
provider: AIProvider
}
export type GenerateInteractivePageResult =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
issues?: PageValidationIssue[]
error?: string
reason?: string
raw?: string
attempts: number
}
/**
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
*/
export async function generateInteractivePageFromContent(
input: GenerateInteractivePageInput
): Promise<GenerateInteractivePageResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content)
if (plain.split(/\s+/).filter(Boolean).length < 30) {
return {
ok: false,
error: 'unsuitable_content',
reason: 'Contenu trop court pour une page interactive',
attempts: 0,
}
}
let pageObj = buildPageFromNote(input.content, lang, assets)
const demo = buildDeterministicDemo(input.content, lang, assets)
if (demo) {
pageObj = injectDemo(pageObj, demo)
}
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
if (!normalized) {
return {
ok: false,
error: 'normalize_failed',
reason: 'Impossible de normaliser la page',
attempts: 1,
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) {
// Last resort: page without demo
const sections = Array.isArray(normalized.sections)
? (normalized.sections as Record<string, unknown>[]).map((sec) => ({
...sec,
blocks: Array.isArray(sec.blocks)
? (sec.blocks as Record<string, unknown>[]).filter(
(b) => b.type !== 'demo'
)
: [],
}))
: []
const stripped = validateInteractivePage({ ...normalized, sections })
if (stripped.ok) {
return { ok: true, page: stripped.page, attempts: 1 }
}
return {
ok: false,
issues: result.issues,
error: 'validation_failed',
reason: result.issues[0]
? `${result.issues[0].path}: ${result.issues[0].message}`
: 'Page invalide',
attempts: 1,
}
}
return { ok: true, page: result.page, attempts: 1 }
}

View File

@@ -0,0 +1,547 @@
/**
* LLM generation for interactive pages (spec §7) — split into short calls:
* 1. generatePagePlan → hero + overview + section list (one fast call)
* 2. generatePageSection → blocks of ONE section, incl. a content-matched demo
*
* Splitting keeps every LLM round-trip well under the client abort (~75 s),
* unlike the original single-shot page generation that timed out (~150 s).
* Validation failures are fed back verbatim (max 2 attempts per call).
*/
import { generateText } from 'ai'
import { z } from 'zod'
import type { AIProvider } from '@/lib/ai/types'
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
PANEL_TYPES,
INTENT_IDS,
SPEAK_WRITING_RULE,
} from '@/lib/interactive-demo'
import {
CALLOUT_KINDS,
INTERACTIVE_PAGE_CAPS,
validateInteractivePage,
normalizeInteractivePageCandidate,
type PageSection,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { catalogForPrompt } from '@/lib/simulators'
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
const MAX_ATTEMPTS = 2
// ── Shared helpers ───────────────────────────────────────────────────────────
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function extractJsonObject(raw: string): unknown | null {
if (!raw) return null
const cleaned = cleanAIJsonResponse(raw)
const tryParse = (s: string): unknown | null => {
try {
return JSON.parse(s)
} catch {
return null
}
}
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
if (parsed) return parsed
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fence?.[1]?.trim() ?? cleaned
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
if (parsed) return parsed
const start = candidate.indexOf('{')
const end = candidate.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = candidate.slice(start, end + 1)
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
if (parsed) return parsed
}
return null
}
function assetsBlock(assets: ReturnType<typeof extractSourceAssets>): string {
let msg = ''
if (assets.formulas.length) {
msg += `\nFORMULES_EXTRAITES (réutilise telles quelles en $...$ — KaTeX inline):\n`
msg += assets.formulas
.slice(0, 14)
.map((f, i) => `${i + 1}. ${f}`)
.join('\n')
msg += '\n'
}
if (assets.keySentences.length) {
msg += `\nPHRASES_CLES:\n`
msg += assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')
msg += '\n'
}
if (assets.numbers.length) {
msg += `\nDONNEES_NUMERIQUES (source des blocs "stats"/"table" — ne pas inventer d'autres chiffres):\n${JSON.stringify(assets.numbers.slice(0, 10))}\n`
}
return msg
}
// ── Golden demo examples (from the hand-crafted thermo fixture) ─────────────
type FixtureDemoBlock = { type: string; demo: unknown }
function fixtureDemo(panelType: string): string | null {
for (const section of thermoFixture.sections) {
for (const block of section.blocks as FixtureDemoBlock[]) {
if (block.type !== 'demo') continue
const demo = block.demo as {
scene?: { panels?: { type: string }[] }
}
const panels = demo?.scene?.panels ?? []
if (panels.some((p) => p.type === panelType)) {
return JSON.stringify(block.demo)
}
}
}
return null
}
// ── 1. Page plan ─────────────────────────────────────────────────────────────
const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
const planSectionSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(DEMO_KINDS),
demoGoal: z.string().optional(),
})
const pagePlanSchema = z.object({
heroTitle: z.string().min(1),
heroSubtitle: z.string().optional(),
overviewLead: z.string().min(1),
overviewCards: z
.array(
z.object({
badge: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
intent: z.enum(INTENT_IDS).optional(),
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
sections: z.array(planSectionSchema).min(2).max(5),
})
export type PagePlanSection = z.infer<typeof planSectionSchema>
export type PagePlan = z.infer<typeof pagePlanSchema>
export type GeneratePagePlanResult =
| { ok: true; plan: PagePlan; attempts: number }
| { ok: false; error: string; reason?: string; attempts: number }
function buildPlanSystemPrompt(lang: string): string {
return `You plan an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
You output ONLY the PLAN as one JSON object — sections content is generated later, one call per section.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"heroTitle": string, // the SUBJECT of the note, never a generic title
"heroSubtitle": string, // one sentence, autoportant
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 24 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 25
}
COUVERTURE (règle n°1):
- The page covers the CORE of the source: its 25 major ideas, one section each.
- NEVER build the page on the note's simplest example.
- First section = the problem / context; last section = synthesis / results.
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
- Loi / relation / courbe / évolution chiffrée → "chart"
- Processus / flux / cycle / architecture → "svg-scene"
- Avant/après, comparaison d'états → "svg-scene" (2 groupes de nœuds) et précise-le dans demoGoal
- Loi chiffrée avec paramètres manipulables (η = 1 Tc/Th, COP, loi physique, modèle) → "simulation" (l'apprenant manipule des curseurs)
- Une démo seulement si elle ENSEIGNE mieux que le texte. Max 3 sections avec démo/simulation.
RULES:
- Language of ALL strings = "${lang}" (the note's language).
- demoGoal: one sentence stating what the demo must show (used by the next LLM call).
- No colors anywhere. Intents only.`
}
export async function generatePagePlan(input: {
content: string
lang?: string
provider: AIProvider
}): Promise<GeneratePagePlanResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content).slice(0, 6000)
const system = buildPlanSystemPrompt(lang)
const model = input.provider.getModel()
let lastError = 'unknown'
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let user = `Plan the interactive page for THIS note.
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (attempt > 1) {
user += `\nPREVIOUS_ANSWER_INVALID: ${lastError}\nReturn a corrected FULL JSON object matching the schema exactly.`
}
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
const parsed = extractJsonObject(raw)
if (!parsed) {
lastError = 'Model did not return parseable JSON'
console.warn('[interactive-page/plan] unparseable JSON', raw.slice(0, 300))
continue
}
const refusal = parsed as { error?: string; reason?: string }
if (refusal?.error === 'unsuitable_content') {
return {
ok: false,
error: 'unsuitable_content',
reason: refusal.reason || 'Contenu inadapté à une page interactive',
attempts: attempt,
}
}
const result = pagePlanSchema.safeParse(parsed)
if (result.success) {
return { ok: true, plan: result.data, attempts: attempt }
}
lastError = result.error.issues
.slice(0, 6)
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join(' | ')
console.warn('[interactive-page/plan] schema failed', lastError)
}
return { ok: false, error: 'plan_failed', reason: lastError, attempts: MAX_ATTEMPTS }
}
// ── 2. Section generation ────────────────────────────────────────────────────
export type GeneratePageSectionInput = {
content: string
lang?: string
provider: AIProvider
pageTitle: string
sectionId: string
section: PagePlanSection
}
export type GeneratePageSectionResult =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; issues?: PageValidationIssue[]; error?: string; attempts: number }
const SECTION_NARRATION_RULES = `NARRATION des démos (champ "speak"):
- 12 phrases, 25 mots max, une seule idée par étape, gras sur le concept clé, KaTeX inline ($...$) pour les formules.
- Voix off de prof, jamais de description mécanique.
- Jamais annoter ce qu'on pointTo dans la même étape.
- Chaque élément d'une scène est désigné au moins une fois dans l'acte.
- Scènes denses : svg-scene ≥ 5 nœuds ; heatmap avec valeurs réalistes et variées (intensité ∝ valeur, valeurs affichées).
- Étape finale d'acte : reveal ["*"] + pattern overview.
- Valeurs illustratives → "disclaimer" obligatoire.`
function buildSectionSystemPrompt(lang: string, hasMath: boolean): string {
return `You generate ONE section of an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"title": string,
"blocks": [ Block, ... ] // 26 blocks
}
Block types (discriminated by "type"):
- { "type": "prose", "md": string } // light markdown + $KaTeX$ inline
- { "type": "formula", "tex": string, "caption?": string } // KaTeX block for key relations
- { "type": "callout", "kind": ${JSON.stringify(CALLOUT_KINDS)}, "title": string, "md": string }
- { "type": "chart", "payload": { "chartType": "line"|"bar"|"area", "series": [{ "id": string, "label?": string, "values": number[], "intent?": IntentId }] }, "caption?": string }
- { "type": "stats", "items": [{ "value": string, "label": string, "intent?": IntentId }] } // 25, REAL figures from the note only
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
IntentId: ${JSON.stringify(INTENT_IDS)}
SimRef — TWO forms:
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
(B) GENERIC formula simulator: { "simId": "generic-formula", "title": string, "params": [{ "id", "symbol", "label", "min", "max", "step", "defaultValue", "unit?", "intent?" }] (14), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (16), "visual": { "kind": "gauges" } | { "kind": "bars" } | { "kind": "curve", "xParamId", "expr" }, "disclaimer?": string }
→ expr = plain math over param ids: + - * / ^ % parentheses, functions sqrt abs exp ln log round min max, constants pi e. NO other identifiers.
SIMULATOR CATALOG (pick from this, else generic-formula):
${catalogForPrompt(lang)}
InteractiveDemoV1 (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}):
{
"schemaVersion": 1, "id": "demo.<slug>", "lang": "${lang}", "disclaimer?": string,
"scene": { "id": string, "panels": [Panel] },
"acts": [ { "id": "a1", "title": string, "pattern?": Pattern, "steps": [Step] } ]
}
Panel types: ${PANEL_TYPES.join(', ')} (max 2 panels; heatmap cells ids: r{row}.c{col}, triangular:"lower" when appropriate)
Patterns: ${PATTERN_IDS.join(', ')}
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?": string[], "reveal?": [{"ids": string[], "scope": "transient|act|scene"}], "annotate?": [...] }
svg-scene: nodes[{id,label?,intent?}] (labels may hold $KaTeX$), edges[{id,from,to,style?,weight?,intent?}]
${SECTION_NARRATION_RULES}
MÉCANIQUE (the validator rejects otherwise):
- Unique semantic ids; NO colors (hex/rgb) anywhere — intents only.
- Blocks per section ≤ ${INTERACTIVE_PAGE_CAPS.maxBlocksPerSection}.
- Every pointTo/reveal/annotate reference must be an id of the active scene.
- "stats"/"table" ONLY with figures actually present in the source.
- All human-facing strings in "${lang}".
RÈGLE D'OR — AUCUN VISUEL DÉCORATIF (checked after generation, violations are rejected):
- Every visual block must TEACH something the text alone cannot. Ask: "what does the learner understand after, that they didn't before?" No answer → no visual block.
- "heatmap-matrix" ONLY when the note contains genuinely matrix-shaped data (table of values, correlations, confusion matrix). Otherwise FORBIDDEN.
- "chart" ONLY with numeric series actually present in the source note. Otherwise FORBIDDEN.
- svg-scene: ≥ 5 nodes, dense, real vocabulary of the note — never 3 generic boxes.
- When in doubt: prose/formula/callout only.
${hasMath ? '- STEM: reuse formulas from FORMULES_EXTRAITES verbatim in formula blocks, prose and demo speak ($...$).' : ''}`
}
function buildSectionUserPrompt(
input: GeneratePageSectionInput,
assets: ReturnType<typeof extractSourceAssets>,
opts?: { issues?: PageValidationIssue[]; previousJson?: string }
): string {
const plain = stripToPlain(input.content).slice(0, 6000)
const { section } = input
let msg = `Generate the section "${section.title}" of the interactive page "${input.pageTitle}".
SECTION_GOAL: ${section.goal}
${
section.demoKind === 'simulation'
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
SIM_GOAL: ${section.demoGoal || section.goal}`
: section.demoKind !== 'none'
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
DEMO_GOAL: ${section.demoGoal || section.goal}`
: `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.`
}
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (section.demoKind === 'heatmap-matrix' || section.demoKind === 'svg-scene') {
const golden = fixtureDemo(section.demoKind)
if (golden) {
msg += `\nEXEMPLE_D_OR (imite sa densité et sa qualité de narration — jamais moins ; adapte au contenu de CETTE note):\n${golden}\n`
}
}
msg += `\nCONTRAINTES: section autoportante, fidèle au contenu réel de la note (jamais un exemple jouet). 26 blocks.`
if (opts?.issues?.length) {
msg += `\n\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
msg += opts.issues
.slice(0, 12)
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
.join('\n')
}
if (opts?.previousJson) {
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 10000)}\nPREVIOUS_JSON_END`
}
return msg
}
/** Wrap a section candidate in a minimal page so the shared validator runs (incl. demo delegation). */
function validateSectionCandidate(
candidate: unknown,
sectionId: string,
lang: string
): { ok: true; section: PageSection } | { ok: false; issues: PageValidationIssue[] } {
const wrapped = {
schemaVersion: 1,
id: 'page.section-check',
lang,
hero: { kicker: 'CHECK', title: 'Section check' },
sections: [
typeof candidate === 'object' && candidate !== null
? { id: sectionId, ...(candidate as Record<string, unknown>) }
: candidate,
],
}
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
if (!normalized) {
return {
ok: false,
issues: [
{ code: 'normalize_failed', path: '', message: 'Section not normalizable' },
],
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) return { ok: false, issues: result.issues }
const section = result.page.sections.find((s) => s.id === sectionId)
if (!section) {
return {
ok: false,
issues: [{ code: 'section_missing', path: 'sections', message: 'Section lost in normalization' }],
}
}
return { ok: true, section }
}
/**
* Quality gate (règle d'or): reject decorative visuals AFTER schema validation —
* heatmap/chart whose values don't come from the note, svg-scene too sparse.
* Issues are fed back to the LLM for a repair attempt.
*/
function qualityGateSection(
section: PageSection,
assets: ReturnType<typeof extractSourceAssets>
): PageValidationIssue[] {
const out: PageValidationIssue[] = []
const sourceValues = assets.numbers.map((n) => n.value)
const valueInSource = (v: number) =>
sourceValues.some((sv) => sv !== 0 && Math.abs(sv - v) / Math.max(1, Math.abs(sv)) < 0.06)
for (const [bi, block] of section.blocks.entries()) {
const path = `blocks[${bi}]`
if (block.type === 'chart') {
const values = block.payload.series.flatMap((s) => s.values)
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Chart values are not from the note (decorative data). Use real series from the source or remove the chart.',
})
}
}
if (block.type === 'demo') {
for (const panel of block.demo.scene.panels) {
if (panel.type === 'heatmap-matrix') {
const values = panel.payload.values.flat()
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Heatmap values are not from the note (decorative data). Only use heatmap-matrix for real matrix-shaped source data.',
})
}
}
if (panel.type === 'svg-scene' && panel.payload.nodes.length < 5) {
out.push({
code: 'scene_too_poor',
path,
message:
'svg-scene has fewer than 5 nodes (too poor pedagogically). Densify: ≥5 nodes with real vocabulary and formulas from the note.',
})
}
}
}
}
return out
}
export async function generatePageSection(
input: GeneratePageSectionInput
): Promise<GeneratePageSectionResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const system = buildSectionSystemPrompt(
lang,
assets.hasMath || assets.formulas.length > 0
)
const model = input.provider.getModel()
let lastIssues: PageValidationIssue[] = []
let lastRaw = ''
let lastNormalizedJson = ''
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const user = buildSectionUserPrompt(input, assets, {
issues: attempt > 1 ? lastIssues : undefined,
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
})
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
lastRaw = raw
const parsed = extractJsonObject(raw)
if (!parsed) {
lastIssues = [
{
code: 'invalid_json',
path: '',
message: 'Model did not return parseable JSON',
},
]
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: unparseable JSON`,
raw.slice(0, 300)
)
continue
}
lastNormalizedJson = JSON.stringify(parsed)
const result = validateSectionCandidate(parsed, input.sectionId, lang)
if (result.ok) {
const qualityIssues = qualityGateSection(result.section, assets)
if (!qualityIssues.length) {
return { ok: true, section: result.section, attempts: attempt }
}
lastIssues = qualityIssues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: quality gate`,
qualityIssues.slice(0, 6)
)
continue
}
lastIssues = result.issues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: validation failed`,
result.issues.slice(0, 8)
)
}
return { ok: false, issues: lastIssues, attempts: MAX_ATTEMPTS }
}

View File

@@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
'interactive-page': `Page immersive avec hero, sections et démos Play/Step (canal dédié — ne pas utiliser ici).`,
}
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */

View File

@@ -56,7 +56,9 @@ export const CREDIT_COSTS: Record<string, number> = {
ai_flashcard: 3,
voice_transcribe: 1,
excalidraw_generate: 4,
slide_generate: 7, // défaut si pas de slideCount (1+6)
slide_generate: 7, // défaut si pas de slideCount (1+N)
interactive_demo: 10, // génération initiale (brainstorm P12)
interactive_page: 20, // page immersive style Kimi
}
export function slideGenerateCreditCost(slideCount?: number | null): number {

View File

@@ -0,0 +1,76 @@
/** Interactive Demo schema v1 — caps & allowlists (brainstorm 2026-07-22). */
export const INTERACTIVE_DEMO_SCHEMA_VERSION = 1 as const
export const INTERACTIVE_DEMO_CAPS = {
maxScenes: 5,
maxActs: 8,
maxStepsPerAct: 12,
maxAnnotationsPerStep: 5,
maxPanelsPerScene: 2,
maxJsonBytes: 64 * 1024,
} as const
export const PATTERN_IDS = [
'progressiveReveal',
'spotlightTour',
'accumulate',
'compareBeforeAfter',
'chartBuild',
'heatmapFill',
'overview',
'flowTrace',
] as const
export const INTENT_IDS = [
'highlight',
'flow',
'cache',
'compute',
'output',
'warning',
] as const
export const SCOPE_IDS = ['transient', 'act', 'scene'] as const
export const ANNOTATION_KINDS = ['circle', 'arrow', 'badge', 'callout'] as const
export const PANEL_TYPES = ['svg-scene', 'chart', 'heatmap-matrix'] as const
export const TRANSITIONS = ['fade', 'cut'] as const
export const CHART_TYPES = ['line', 'bar', 'area'] as const
/**
* Human / locale strings — may contain hex/rgb in prose; changeable by translateDemo.
* Must stay in sync between color-scan skip, translate strip, and docs.
*/
export const HUMAN_STRING_KEYS = [
'lang',
'speak',
'title',
'text',
'disclaimer',
'label',
'rowLabels',
'colLabels',
] as const
export type HumanStringKey = (typeof HUMAN_STRING_KEYS)[number]
const HUMAN_STRING_KEY_SET = new Set<string>(HUMAN_STRING_KEYS)
export function isHumanStringKey(key: string): boolean {
return HUMAN_STRING_KEY_SET.has(key)
}
/** Hex / rgb color literals forbidden in non-human JSON fields (P11). */
export const FORBIDDEN_COLOR_RE =
/#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|\brgba?\s*\(/i
/**
* Future generation-prompt rule (not a validator cap):
* speak ≈ 12 sentences, ~25 words max — tempo is decided at writing time.
*/
export const SPEAK_WRITING_RULE =
'speak ≈ 12 phrases, ~25 mots max — le tempo de la démo se décide à lécriture, pas au rendu.'

View File

@@ -0,0 +1,469 @@
{
"schemaVersion": 1,
"id": "demo.attnres",
"lang": "fr",
"disclaimer": "Poids d'enseignement — schéma illustratif, pas les mesures de la Figure 8 du papier.",
"scene": {
"id": "scene.problem",
"panels": [
{
"id": "panel.trunk",
"type": "svg-scene",
"payload": {
"nodes": [
{
"id": "residualTrunk",
"label": "Tronc résiduel h",
"intent": "flow"
},
{
"id": "L1",
"label": "L1 · Attention",
"intent": "compute"
},
{
"id": "L2",
"label": "L2 · MLP",
"intent": "cache"
},
{
"id": "L3",
"label": "L3 · Attention",
"intent": "compute"
},
{
"id": "L4",
"label": "L4 · MLP",
"intent": "cache"
},
{
"id": "L5",
"label": "L5 · Attention",
"intent": "compute"
},
{
"id": "L6",
"label": "L6 · MLP",
"intent": "cache"
},
{
"id": "L7",
"label": "L7 · Attention",
"intent": "compute"
},
{
"id": "L8",
"label": "L8 · MLP",
"intent": "cache"
}
],
"edges": [
{
"id": "e.L1.h",
"from": "L1",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L2.h",
"from": "L2",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L3.h",
"from": "L3",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L4.h",
"from": "L4",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L5.h",
"from": "L5",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L6.h",
"from": "L6",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L7.h",
"from": "L7",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L8.h",
"from": "L8",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
}
]
}
},
{
"id": "panel.magShare",
"type": "chart",
"payload": {
"chartType": "line",
"series": [
{
"id": "series.magnitude",
"label": "‖h‖",
"values": [
2,
3,
4,
5,
6,
7,
8,
9
],
"intent": "compute"
},
{
"id": "series.embedShare",
"label": "part embedding",
"values": [
0.5,
0.33,
0.25,
0.2,
0.17,
0.14,
0.12,
0.11
],
"intent": "output"
}
]
}
}
]
},
"acts": [
{
"id": "a1",
"title": "Le problème — dilution en profondeur",
"pattern": "accumulate",
"steps": [
{
"id": "a1.s1",
"speak": "Chaque couche entre dans le tronc résiduel avec un **coefficient fixe ×1** — aucune sélection.",
"pattern": "spotlightTour",
"pointTo": [
"residualTrunk",
"L1",
"e.L1.h"
],
"reveal": [
{
"ids": [
"residualTrunk",
"L1",
"e.L1.h"
],
"scope": "act"
}
]
},
{
"id": "a1.s2",
"speak": "La **magnitude** croît avec la profondeur ; la part de l'embedding **dilue** $\\approx 1/(l+1)$.",
"pattern": "chartBuild",
"pointTo": [
"series.magnitude",
"series.embedShare"
],
"reveal": [
{
"ids": [
"series.magnitude"
],
"scope": "act"
},
{
"ids": [
"series.embedShare"
],
"scope": "act"
}
]
}
]
},
{
"id": "a2",
"title": "Full Attention Residuals",
"pattern": "heatmapFill",
"transition": "fade",
"scene": {
"id": "scene.heatmap",
"panels": [
{
"id": "panel.attnMatrix",
"type": "heatmap-matrix",
"payload": {
"rows": 8,
"cols": 8,
"triangular": "lower",
"rowLabels": [
"L1",
"L2",
"L3",
"L4",
"L5",
"L6",
"L7",
"L8"
],
"colLabels": [
"h₁",
"f₁",
"f₂",
"f₃",
"f₄",
"f₅",
"f₆",
"f₇"
],
"values": [
[
1.0,
0,
0,
0,
0,
0,
0,
0
],
[
0.3,
0.7,
0,
0,
0,
0,
0,
0
],
[
0.2,
0.65,
0.15,
0,
0,
0,
0,
0
],
[
0.25,
0.15,
0.45,
0.15,
0,
0,
0,
0
],
[
0.1,
0.1,
0.15,
0.6,
0.05,
0,
0,
0
],
[
0.1,
0.08,
0.12,
0.2,
0.45,
0.05,
0,
0
],
[
0.18,
0.07,
0.1,
0.12,
0.18,
0.3,
0.05,
0
],
[
0.12,
0.06,
0.08,
0.1,
0.14,
0.2,
0.25,
0.05
]
]
}
}
]
},
"steps": [
{
"id": "a2.s1",
"speak": "Matrice **lower-triangular** : chaque ligne = une couche ; chaque ligne **somme à 1**.",
"pattern": "overview"
},
{
"id": "a2.s2",
"speak": "On remplit progressivement : la couche 1 ne voit que **$h_1$**.",
"pattern": "heatmapFill",
"pointTo": [
"r1.c1"
],
"reveal": [
{
"ids": [
"r1.c1"
],
"scope": "act"
}
]
},
{
"id": "a2.s3",
"speak": "**Dominance diagonale** — chaque couche favorise son prédécesseur immédiat.",
"pointTo": [
"r3.c3",
"r4.c4"
],
"reveal": [
{
"ids": [
"r2.c1",
"r2.c2",
"r3.c1",
"r3.c2",
"r3.c3"
],
"scope": "act"
}
],
"annotate": [
{
"kind": "badge",
"targetIds": [
"r2.c2"
],
"scope": "act",
"intent": "highlight"
}
]
},
{
"id": "a2.s4",
"speak": "L'**embedding** garde un poids non trivial en profondeur.",
"annotate": [
{
"kind": "circle",
"targetIds": [
"r8.c1"
],
"scope": "transient",
"intent": "highlight"
},
{
"kind": "badge",
"targetIds": [
"r8.c1"
],
"scope": "act",
"intent": "highlight"
}
]
},
{
"id": "a2.s5",
"speak": "Certaines couches profondes **récupèrent** des sources très antérieures — skip connections apprises.",
"annotate": [
{
"kind": "badge",
"targetIds": [
"r7.c1"
],
"scope": "act",
"intent": "highlight"
},
{
"kind": "callout",
"targetIds": [
"r8.c3"
],
"scope": "transient",
"text": "récupération précoce",
"intent": "warning"
}
]
},
{
"id": "a2.s6",
"speak": "Pre-Attn et Pre-MLP se **spécialisent** différemment (patterns mesurés, ici illustratifs).",
"pattern": "overview",
"annotate": [
{
"kind": "badge",
"targetIds": [
"r5.c4"
],
"scope": "scene",
"intent": "highlight"
}
]
},
{
"id": "a2.s7",
"speak": "Matrice assemblée — triangulaire inférieure ; **chaque ligne somme à 1**.",
"pattern": "overview",
"reveal": [
{
"ids": [
"*"
],
"scope": "act"
}
]
}
]
}
]
}

View File

@@ -0,0 +1,40 @@
export {
INTERACTIVE_DEMO_CAPS,
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
INTENT_IDS,
SCOPE_IDS,
ANNOTATION_KINDS,
PANEL_TYPES,
CHART_TYPES,
HUMAN_STRING_KEYS,
SPEAK_WRITING_RULE,
} from './constants'
export {
SPEAK_WRITING_GUIDE,
intentColor,
dimOpacity,
spotlightColor,
} from './intent-colors'
export { interactiveDemoV1Schema } from './schema'
export {
validateInteractiveDemo,
assertTranslatePreservesStructure,
collectSceneElementIds,
heatmapCellIds,
} from './validate'
export { normalizeInteractiveDemoCandidate } from './normalize'
export {
resolveInteractiveDemo,
resolveActFinalState,
} from './resolve'
export type {
InteractiveDemoV1,
ValidationResult,
ValidationIssue,
DemoAct,
DemoStep,
DemoScene,
Panel,
} from './types'
export type { StepResolvedState, ActResolvedState, ResolvedAnnotation } from './resolve'

View File

@@ -0,0 +1,62 @@
import type { IntentId } from '@/lib/interactive-demo/types'
/**
* Desaturated palette — one set for light, one for dark (P11).
* No free hex in demo JSON; app maps intents here.
*/
export const INTENT_PALETTE_LIGHT: Record<IntentId | 'neutral', string> = {
neutral: '#5c5c5c',
highlight: '#5a7a9a', // prune / slate-blue
flow: '#4a7d68',
cache: '#7a6a8a',
compute: '#8a6b4a',
output: '#4a7a8a',
warning: '#9a6548',
}
export const INTENT_PALETTE_DARK: Record<IntentId | 'neutral', string> = {
neutral: '#a8a8a8',
highlight: '#8aabca',
flow: '#7ab89a',
cache: '#a898b8',
compute: '#c0a080',
output: '#7ab0c0',
warning: '#d09070',
}
/** Light: ~35% readable on white; dark: ~20% as spec. */
export const DIM_OPACITY_LIGHT = 0.35
export const DIM_OPACITY_DARK = 0.2
export function intentColor(
intent: IntentId | null | undefined,
dark: boolean
): string {
const palette = dark ? INTENT_PALETTE_DARK : INTENT_PALETTE_LIGHT
if (!intent) return palette.neutral
return palette[intent] ?? palette.neutral
}
export function spotlightColor(dark: boolean): string {
return intentColor('highlight', dark)
}
export function dimOpacity(dark: boolean): number {
return dark ? DIM_OPACITY_DARK : DIM_OPACITY_LIGHT
}
export const CIRCLED_NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'] as const
export function badgeGlyph(index: number): string {
if (index >= 1 && index <= CIRCLED_NUMBERS.length) {
return CIRCLED_NUMBERS[index - 1]!
}
return String(index)
}
/** Writing rule for generation prompts (not enforced in renderer). */
export const SPEAK_WRITING_GUIDE = {
maxWordsApprox: 25,
sentences: '12',
note: 'Demo tempo is decided at writing time, not at render.',
} as const

View File

@@ -0,0 +1,561 @@
/**
* Deterministic repairs for LLM-produced Interactive Demo JSON
* before Zod/semantic validation (common shape/id mistakes).
*/
import {
ANNOTATION_KINDS,
CHART_TYPES,
FORBIDDEN_COLOR_RE,
INTENT_IDS,
PATTERN_IDS,
SCOPE_IDS,
TRANSITIONS,
} from './constants'
const INTENT_SET = new Set<string>(INTENT_IDS)
const PATTERN_SET = new Set<string>(PATTERN_IDS)
const SCOPE_SET = new Set<string>(SCOPE_IDS)
const ANN_KIND_SET = new Set<string>(ANNOTATION_KINDS)
const CHART_TYPE_SET = new Set<string>(CHART_TYPES)
const TRANSITION_SET = new Set<string>(TRANSITIONS)
const PANEL_TYPE_ALIASES: Record<string, string> = {
svg: 'svg-scene',
svg_scene: 'svg-scene',
'svg-scene': 'svg-scene',
graph: 'svg-scene',
diagram: 'svg-scene',
nodes: 'svg-scene',
chart: 'chart',
charts: 'chart',
line: 'chart',
bar: 'chart',
area: 'chart',
heatmap: 'heatmap-matrix',
heatmap_matrix: 'heatmap-matrix',
'heatmap-matrix': 'heatmap-matrix',
matrix: 'heatmap-matrix',
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: null
}
function stripForbiddenColorsDeep(value: unknown): unknown {
if (typeof value === 'string') {
return FORBIDDEN_COLOR_RE.test(value) ? undefined : value
}
if (Array.isArray(value)) {
return value.map(stripForbiddenColorsDeep).filter((x) => x !== undefined)
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
// Keep human strings even if they mention a color in prose
if (
k === 'speak' ||
k === 'title' ||
k === 'text' ||
k === 'disclaimer' ||
k === 'label' ||
k === 'rowLabels' ||
k === 'colLabels' ||
k === 'lang'
) {
out[k] = v
continue
}
const cleaned = stripForbiddenColorsDeep(v)
if (cleaned !== undefined) out[k] = cleaned
}
return out
}
return value
}
function normalizeIntent(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined
if (INTENT_SET.has(v)) return v
return 'highlight'
}
function normalizePattern(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined
if (PATTERN_SET.has(v)) return v
const camel = v.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase())
if (PATTERN_SET.has(camel)) return camel
return 'spotlightTour'
}
function normalizeScope(v: unknown): string {
if (typeof v === 'string' && SCOPE_SET.has(v)) return v
return 'act'
}
function normalizePanel(panel: unknown, index: number): Record<string, unknown> | null {
const p = asRecord(panel)
if (!p) return null
const rawType = String(p.type ?? '')
const type = PANEL_TYPE_ALIASES[rawType] || PANEL_TYPE_ALIASES[rawType.toLowerCase()]
if (!type) return null
const id =
typeof p.id === 'string' && p.id.trim()
? p.id.trim()
: `panel.${index + 1}`
let payload = asRecord(p.payload) ?? {}
if (type === 'svg-scene') {
let nodes = Array.isArray(payload.nodes) ? payload.nodes : []
if (nodes.length === 0 && Array.isArray(p.nodes)) nodes = p.nodes
nodes = nodes
.map((n, ni) => {
const node = asRecord(n)
if (!node) return null
const nid =
typeof node.id === 'string' && node.id.trim()
? node.id.trim()
: `n${ni + 1}`
return {
id: nid,
...(typeof node.label === 'string' ? { label: node.label } : {}),
...(normalizeIntent(node.intent)
? { intent: normalizeIntent(node.intent) }
: {}),
}
})
.filter(Boolean)
if (nodes.length === 0) {
nodes = [{ id: 'n1', label: 'Concept', intent: 'highlight' }]
}
const nodeIds = new Set(
(nodes as { id: string }[]).map((n) => n.id)
)
let edges = Array.isArray(payload.edges) ? payload.edges : []
edges = edges
.map((e, ei) => {
const edge = asRecord(e)
if (!edge) return null
const from = String(edge.from ?? '')
const to = String(edge.to ?? '')
if (!nodeIds.has(from) || !nodeIds.has(to)) return null
return {
id:
typeof edge.id === 'string' && edge.id.trim()
? edge.id.trim()
: `e${ei + 1}`,
from,
to,
...(edge.style === 'dashed' || edge.style === 'solid'
? { style: edge.style }
: {}),
...(typeof edge.weight === 'number' ? { weight: edge.weight } : {}),
...(normalizeIntent(edge.intent)
? { intent: normalizeIntent(edge.intent) }
: {}),
}
})
.filter(Boolean)
payload = { nodes, ...(edges.length ? { edges } : {}) }
} else if (type === 'chart') {
let series = Array.isArray(payload.series) ? payload.series : []
series = series
.map((s, si) => {
const ser = asRecord(s)
if (!ser) return null
const values = Array.isArray(ser.values)
? ser.values.map((n) => Number(n)).filter((n) => Number.isFinite(n))
: []
if (values.length === 0) return null
return {
id:
typeof ser.id === 'string' && ser.id.trim()
? ser.id.trim()
: `s${si + 1}`,
...(typeof ser.label === 'string' ? { label: ser.label } : {}),
values,
...(normalizeIntent(ser.intent)
? { intent: normalizeIntent(ser.intent) }
: {}),
}
})
.filter(Boolean)
if (series.length === 0) {
series = [{ id: 's1', values: [1, 2, 3], intent: 'highlight' }]
}
const chartType =
typeof payload.chartType === 'string' &&
CHART_TYPE_SET.has(payload.chartType)
? payload.chartType
: 'bar'
payload = { chartType, series }
} else if (type === 'heatmap-matrix') {
let rows = Number(payload.rows)
let cols = Number(payload.cols)
let values = Array.isArray(payload.values) ? payload.values : []
if (!Number.isFinite(rows) || rows < 1) rows = values.length || 3
if (!Number.isFinite(cols) || cols < 1) {
cols = Array.isArray(values[0]) ? (values[0] as unknown[]).length : 3
}
// Pad / trim to declared shape
const matrix: number[][] = []
for (let r = 0; r < rows; r++) {
const row = Array.isArray(values[r]) ? (values[r] as unknown[]) : []
const outRow: number[] = []
for (let c = 0; c < cols; c++) {
const n = Number(row[c])
outRow.push(Number.isFinite(n) ? n : 0)
}
matrix.push(outRow)
}
const triangular =
payload.triangular === 'lower' ||
payload.triangular === 'upper' ||
payload.triangular === 'none'
? payload.triangular
: undefined
payload = {
rows,
cols,
values: matrix,
...(triangular ? { triangular } : {}),
...(Array.isArray(payload.rowLabels)
? { rowLabels: payload.rowLabels.map(String).slice(0, rows) }
: {}),
...(Array.isArray(payload.colLabels)
? { colLabels: payload.colLabels.map(String).slice(0, cols) }
: {}),
}
}
return { id, type, payload }
}
function normalizeScene(scene: unknown): Record<string, unknown> {
const s = asRecord(scene) ?? {}
const rawPanels = Array.isArray(s.panels) ? s.panels : []
const panels = rawPanels
.map((p, i) => normalizePanel(p, i))
.filter(Boolean)
.slice(0, 2) as Record<string, unknown>[]
if (panels.length === 0) {
panels.push({
id: 'panel.main',
type: 'svg-scene',
payload: {
nodes: [
{ id: 'concept', label: 'Idée', intent: 'highlight' },
{ id: 'detail', label: 'Détail', intent: 'compute' },
],
edges: [
{
id: 'e1',
from: 'concept',
to: 'detail',
style: 'solid',
intent: 'flow',
},
],
},
})
}
return {
...(typeof s.id === 'string' && s.id.trim() ? { id: s.id.trim() } : {}),
panels,
}
}
function normalizeStep(
step: unknown,
actId: string,
stepIndex: number
): Record<string, unknown> | null {
const st = asRecord(step)
if (!st) return null
const speak =
typeof st.speak === 'string' && st.speak.trim()
? st.speak.trim()
: 'Regardons cet élément.'
const id = `${actId}.s${stepIndex + 1}`
const pointTo = Array.isArray(st.pointTo)
? st.pointTo.map(String).filter(Boolean)
: undefined
const reveal = Array.isArray(st.reveal)
? st.reveal
.map((r) => {
const rev = asRecord(r)
if (!rev || !Array.isArray(rev.ids) || rev.ids.length === 0) return null
return {
ids: rev.ids.map(String).filter(Boolean),
scope: normalizeScope(rev.scope),
}
})
.filter(Boolean)
: undefined
let annotate = Array.isArray(st.annotate)
? st.annotate
.map((a) => {
const ann = asRecord(a)
if (!ann || !Array.isArray(ann.targetIds) || ann.targetIds.length === 0)
return null
const kind =
typeof ann.kind === 'string' && ANN_KIND_SET.has(ann.kind)
? ann.kind
: 'callout'
return {
kind,
targetIds: ann.targetIds.map(String).filter(Boolean),
scope: normalizeScope(ann.scope),
...(typeof ann.text === 'string' ? { text: ann.text } : {}),
...(normalizeIntent(ann.intent)
? { intent: normalizeIntent(ann.intent) }
: {}),
}
})
.filter(Boolean)
: undefined
// Drop annotate targets that also appear in pointTo (semantic hard reject)
if (annotate && pointTo?.length) {
const pt = new Set(pointTo)
const filtered = annotate
.map((a) => {
if (!a) return null
const ann = a as {
targetIds: string[]
kind: string
scope: string
text?: string
intent?: string
}
const targetIds = ann.targetIds.filter((id) => !pt.has(id))
if (targetIds.length === 0) return null
return { ...ann, targetIds }
})
.filter(Boolean) as NonNullable<(typeof annotate)[number]>[]
annotate = filtered.length ? filtered : undefined
}
return {
id,
speak,
...(st.pattern !== undefined
? { pattern: normalizePattern(st.pattern) }
: {}),
...(pointTo?.length ? { pointTo } : {}),
...(reveal?.length ? { reveal } : {}),
...(annotate?.length ? { annotate } : {}),
}
}
function collectElementIdsFromScene(scene: Record<string, unknown>): {
ids: Set<string>
wildcardAllowed: boolean
firstId?: string
} {
const ids = new Set<string>()
let wildcardAllowed = false
const panels = Array.isArray(scene.panels) ? scene.panels : []
for (const panel of panels) {
const p = asRecord(panel)
if (!p) continue
const payload = asRecord(p.payload) ?? {}
if (p.type === 'svg-scene') {
for (const n of Array.isArray(payload.nodes) ? payload.nodes : []) {
const node = asRecord(n)
if (node && typeof node.id === 'string') ids.add(node.id)
}
for (const e of Array.isArray(payload.edges) ? payload.edges : []) {
const edge = asRecord(e)
if (edge && typeof edge.id === 'string') ids.add(edge.id)
}
} else if (p.type === 'chart') {
wildcardAllowed = true
for (const s of Array.isArray(payload.series) ? payload.series : []) {
const ser = asRecord(s)
if (ser && typeof ser.id === 'string') ids.add(ser.id)
}
} else if (p.type === 'heatmap-matrix') {
wildcardAllowed = true
const rows = Number(payload.rows) || 0
const cols = Number(payload.cols) || 0
const triangular = payload.triangular
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
if (triangular === 'lower' && c > r) continue
if (triangular === 'upper' && c < r) continue
ids.add(`r${r}.c${c}`)
}
}
}
}
return { ids, wildcardAllowed, firstId: ids.values().next().value }
}
function filterStepRefs(
step: Record<string, unknown>,
elementIds: Set<string>,
wildcardAllowed: boolean
): Record<string, unknown> {
const filterId = (id: string) =>
id === '*' ? wildcardAllowed : elementIds.has(id)
if (Array.isArray(step.pointTo)) {
const pointTo = (step.pointTo as string[]).filter((id) => elementIds.has(id))
if (pointTo.length) step.pointTo = pointTo
else delete step.pointTo
}
if (Array.isArray(step.reveal)) {
const reveal = (step.reveal as { ids: string[]; scope: string }[])
.map((r) => ({
...r,
ids: r.ids.filter(filterId),
}))
.filter((r) => r.ids.length > 0)
if (reveal.length) step.reveal = reveal
else delete step.reveal
}
if (Array.isArray(step.annotate)) {
const annotate = (
step.annotate as { targetIds: string[]; [k: string]: unknown }[]
)
.map((a) => ({
...a,
targetIds: a.targetIds.filter((id) => elementIds.has(id)),
}))
.filter((a) => a.targetIds.length > 0)
if (annotate.length) step.annotate = annotate
else delete step.annotate
}
return step
}
function normalizeAct(
act: unknown,
actIndex: number,
defaultScene: Record<string, unknown>
): Record<string, unknown> | null {
const a = asRecord(act)
if (!a) return null
const actId = `a${actIndex + 1}`
const title =
typeof a.title === 'string' && a.title.trim()
? a.title.trim()
: `Acte ${actIndex + 1}`
const scene = a.scene ? normalizeScene(a.scene) : undefined
const activeScene = scene ?? defaultScene
const { ids, wildcardAllowed, firstId } =
collectElementIdsFromScene(activeScene)
const rawSteps = Array.isArray(a.steps) ? a.steps : []
let steps = rawSteps
.map((s, si) => normalizeStep(s, actId, si))
.filter(Boolean)
.slice(0, 12) as Record<string, unknown>[]
steps = steps.map((s) => filterStepRefs(s, ids, wildcardAllowed))
if (steps.length === 0) {
steps.push({
id: `${actId}.s1`,
speak: 'Voici le point clé.',
pattern: 'overview',
...(firstId ? { pointTo: [firstId] } : {}),
})
}
return {
id: actId,
title,
...(a.pattern !== undefined ? { pattern: normalizePattern(a.pattern) } : {}),
...(typeof a.transition === 'string' && TRANSITION_SET.has(a.transition)
? { transition: a.transition }
: {}),
...(scene ? { scene } : {}),
steps,
}
}
/**
* Best-effort shape fix so Zod + semantic validate can succeed on near-valid LLM output.
*/
export function normalizeInteractiveDemoCandidate(
input: unknown,
lang = 'fr'
): unknown {
const root = asRecord(input)
if (!root) return input
let demo = stripForbiddenColorsDeep(root) as Record<string, unknown>
demo = asRecord(demo) ?? root
const safeLang =
typeof lang === 'string' && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)
? lang
: 'fr'
demo.schemaVersion = 1
demo.lang =
typeof demo.lang === 'string' &&
/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(demo.lang)
? demo.lang
: safeLang
if (typeof demo.id !== 'string' || !demo.id.trim()) {
demo.id = 'demo.generated'
}
if (typeof demo.disclaimer !== 'string') {
delete demo.disclaimer
}
const scene = normalizeScene(demo.scene)
demo.scene = scene
const { firstId } = collectElementIdsFromScene(scene)
const rawActs = Array.isArray(demo.acts) ? demo.acts : []
let acts = rawActs
.map((a, i) => normalizeAct(a, i, scene))
.filter(Boolean)
.slice(0, 8) as Record<string, unknown>[]
if (acts.length === 0) {
acts = [
{
id: 'a1',
title: 'Introduction',
pattern: 'spotlightTour',
steps: [
{
id: 'a1.s1',
speak: 'Voici le point clé.',
pattern: 'overview',
...(firstId ? { pointTo: [firstId] } : {}),
},
],
},
]
}
demo.acts = acts
return demo
}

View File

@@ -0,0 +1,227 @@
import { collectSceneElementIds } from './validate'
import type {
Annotation,
DemoAct,
DemoScene,
DemoStep,
InteractiveDemoV1,
ScopeId,
} from './types'
const SCOPE_RANK: Record<ScopeId, number> = {
transient: 1,
act: 2,
scene: 3,
}
function longestScope(a: ScopeId, b: ScopeId): ScopeId {
return SCOPE_RANK[a] >= SCOPE_RANK[b] ? a : b
}
export type ResolvedAnnotation = Annotation & { badgeIndex?: number }
export type StepResolvedState = {
actId: string
stepId: string
stepIndex: number
speak: string
/** Elements revealed and their effective scope */
revealed: Record<string, ScopeId>
/** Spotlight targets this step (empty ⇒ overview / full brightness) */
spotlight: string[]
/** Active annotations after applying this step (transient of prior steps purged) */
annotations: ResolvedAnnotation[]
overview: boolean
}
export type ActResolvedState = {
actId: string
title: string
steps: StepResolvedState[]
/** State after the last step — static/SSR/print/export default */
final: StepResolvedState
}
function purgeScope(
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
scopes: ScopeId[]
): { revealed: Map<string, ScopeId>; annotations: ResolvedAnnotation[] } {
const drop = new Set(scopes)
const nextRevealed = new Map<string, ScopeId>()
for (const [id, scope] of revealed) {
if (!drop.has(scope)) nextRevealed.set(id, scope)
}
const nextAnn = annotations.filter((a) => !drop.has(a.scope))
return { revealed: nextRevealed, annotations: nextAnn }
}
function mergeReveal(
revealed: Map<string, ScopeId>,
id: string,
scope: ScopeId
): void {
const prev = revealed.get(id)
revealed.set(id, prev ? longestScope(prev, scope) : scope)
}
function applyStep(
step: DemoStep,
elementIds: Set<string>,
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
badgeCounter: { n: number }
): {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
spotlight: string[]
overview: boolean
} {
// Drop previous step's transient
;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient']))
const spotlightSet = new Set<string>()
const revealId = (id: string, scope: ScopeId) => {
if (id === '*') {
for (const eid of elementIds) {
if (!revealed.has(eid)) mergeReveal(revealed, eid, scope)
}
return
}
mergeReveal(revealed, id, scope)
}
for (const rev of step.reveal ?? []) {
for (const id of rev.ids) {
revealId(id, rev.scope)
if (id !== '*') spotlightSet.add(id)
}
}
// Designation ⇒ reveal (pointTo default act)
for (const id of step.pointTo ?? []) {
revealId(id, 'act')
spotlightSet.add(id)
}
for (const ann of step.annotate ?? []) {
for (const id of ann.targetIds) {
revealId(id, ann.scope)
spotlightSet.add(id)
}
const resolved: ResolvedAnnotation = { ...ann }
if (ann.kind === 'badge') {
badgeCounter.n += 1
resolved.badgeIndex = badgeCounter.n
}
annotations.push(resolved)
}
const overview = spotlightSet.size === 0
return {
revealed,
annotations,
spotlight: [...spotlightSet],
overview,
}
}
function resolveAct(
act: DemoAct,
scene: DemoScene,
sceneChanged: boolean,
carried: {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
}
): ActResolvedState {
let { revealed, annotations } = carried
if (sceneChanged) {
// New scene: purge everything from old scene (refs would be dead)
revealed = new Map()
annotations = []
} else {
// Soft reset: purge transient + act, keep scene-scoped
;({ revealed, annotations } = purgeScope(revealed, annotations, [
'transient',
'act',
]))
}
const { ids: elementIds } = collectSceneElementIds(scene)
const badgeCounter = { n: 0 }
const steps: StepResolvedState[] = []
for (const [stepIndex, step] of act.steps.entries()) {
const applied = applyStep(
step,
elementIds,
revealed,
annotations,
badgeCounter
)
revealed = applied.revealed
annotations = applied.annotations
const snapshot: StepResolvedState = {
actId: act.id,
stepId: step.id,
stepIndex,
speak: step.speak,
revealed: Object.fromEntries(revealed),
spotlight: applied.spotlight,
annotations: annotations.map((a) => ({ ...a })),
overview: applied.overview,
}
steps.push(snapshot)
}
const last = steps[steps.length - 1]!
return {
actId: act.id,
title: act.title,
steps,
final: last,
}
}
/**
* Pure resolver: auto-reveal, wildcard, longest-scope-wins, spotlight.
* Shared by player / SSR / noscript / print / export — no React.
*/
export function resolveInteractiveDemo(demo: InteractiveDemoV1): {
acts: ActResolvedState[]
} {
let scene = demo.scene
let revealed = new Map<string, ScopeId>()
let annotations: ResolvedAnnotation[] = []
const acts: ActResolvedState[] = []
for (const act of demo.acts) {
const sceneChanged = Boolean(act.scene)
if (act.scene) scene = act.scene
const resolved = resolveAct(act, scene, sceneChanged, {
revealed,
annotations,
})
acts.push(resolved)
// Carry state into next act (may be purged on soft reset / scene change)
const last = resolved.final
revealed = new Map(Object.entries(last.revealed) as [string, ScopeId][])
annotations = last.annotations.map((a) => ({ ...a }))
}
return { acts }
}
/** Convenience: final static state for a given act (P10 default). */
export function resolveActFinalState(
demo: InteractiveDemoV1,
actId: string
): StepResolvedState | undefined {
return resolveInteractiveDemo(demo).acts.find((a) => a.actId === actId)?.final
}

View File

@@ -0,0 +1,136 @@
import { z } from 'zod'
import {
ANNOTATION_KINDS,
CHART_TYPES,
INTENT_IDS,
INTERACTIVE_DEMO_CAPS,
INTERACTIVE_DEMO_SCHEMA_VERSION,
PANEL_TYPES,
PATTERN_IDS,
SCOPE_IDS,
TRANSITIONS,
} from './constants'
const intentSchema = z.enum(INTENT_IDS).optional()
const patternSchema = z.enum(PATTERN_IDS)
const scopeSchema = z.enum(SCOPE_IDS)
const revealSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
scope: scopeSchema,
})
const annotationSchema = z.object({
kind: z.enum(ANNOTATION_KINDS),
targetIds: z.array(z.string().min(1)).min(1),
scope: scopeSchema,
text: z.string().optional(),
intent: intentSchema,
})
const stepSchema = z.object({
id: z.string().min(1),
speak: z.string().min(1),
pattern: patternSchema.optional(),
pointTo: z.array(z.string().min(1)).optional(),
reveal: z.array(revealSchema).optional(),
annotate: z
.array(annotationSchema)
.max(INTERACTIVE_DEMO_CAPS.maxAnnotationsPerStep)
.optional(),
})
const svgNodeSchema = z.object({
id: z.string().min(1),
label: z.string().optional(),
intent: intentSchema,
})
const svgEdgeSchema = z.object({
id: z.string().min(1),
from: z.string().min(1),
to: z.string().min(1),
style: z.enum(['solid', 'dashed']).optional(),
weight: z.number().optional(),
intent: intentSchema,
})
const svgScenePayloadSchema = z.object({
nodes: z.array(svgNodeSchema).min(1),
edges: z.array(svgEdgeSchema).optional(),
})
const chartSeriesSchema = z.object({
id: z.string().min(1),
label: z.string().optional(),
values: z.array(z.number()),
intent: intentSchema,
})
const chartPayloadSchema = z.object({
chartType: z.enum(CHART_TYPES),
series: z.array(chartSeriesSchema).min(1),
})
const heatmapPayloadSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
values: z.array(z.array(z.number())),
triangular: z.enum(['lower', 'upper', 'none']).optional(),
rowLabels: z.array(z.string()).optional(),
colLabels: z.array(z.string()).optional(),
})
const panelSchema = z.discriminatedUnion('type', [
z.object({
id: z.string().min(1),
type: z.literal('svg-scene'),
payload: svgScenePayloadSchema,
}),
z.object({
id: z.string().min(1),
type: z.literal('chart'),
payload: chartPayloadSchema,
}),
z.object({
id: z.string().min(1),
type: z.literal('heatmap-matrix'),
payload: heatmapPayloadSchema,
}),
])
const sceneSchema = z.object({
id: z.string().min(1).optional(),
panels: z
.array(panelSchema)
.min(1)
.max(INTERACTIVE_DEMO_CAPS.maxPanelsPerScene),
})
const actSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
scene: sceneSchema.optional(),
transition: z.enum(TRANSITIONS).optional(),
pattern: patternSchema.optional(),
steps: z.array(stepSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxStepsPerAct),
})
/** Loose BCP-47: primary tag + optional subtags (fr, en, zh-Hans, pt-BR). */
const langSchema = z
.string()
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
export const interactiveDemoV1Schema = z.object({
schemaVersion: z.literal(INTERACTIVE_DEMO_SCHEMA_VERSION),
id: z.string().min(1),
lang: langSchema,
disclaimer: z.string().optional(),
scene: sceneSchema,
acts: z.array(actSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxActs),
})
export type InteractiveDemoV1Parsed = z.infer<typeof interactiveDemoV1Schema>
/** Re-export allowlists for callers that need them at runtime. */
export { PANEL_TYPES, PATTERN_IDS }

View File

@@ -0,0 +1,116 @@
import type {
ANNOTATION_KINDS,
INTENT_IDS,
PANEL_TYPES,
PATTERN_IDS,
SCOPE_IDS,
TRANSITIONS,
} from './constants'
export type PatternId = (typeof PATTERN_IDS)[number]
export type IntentId = (typeof INTENT_IDS)[number]
export type ScopeId = (typeof SCOPE_IDS)[number]
export type AnnotationKind = (typeof ANNOTATION_KINDS)[number]
export type PanelType = (typeof PANEL_TYPES)[number]
export type TransitionId = (typeof TRANSITIONS)[number]
export type RevealSpec = {
ids: string[]
scope: ScopeId
}
export type Annotation = {
kind: AnnotationKind
targetIds: string[]
scope: ScopeId
text?: string
intent?: IntentId
}
export type DemoStep = {
id: string
speak: string
pattern?: PatternId
pointTo?: string[]
reveal?: RevealSpec[]
annotate?: Annotation[]
}
export type SvgNode = {
id: string
label?: string
intent?: IntentId
}
export type SvgEdge = {
id: string
from: string
to: string
style?: 'solid' | 'dashed'
weight?: number
intent?: IntentId
}
export type SvgScenePayload = {
nodes: SvgNode[]
edges?: SvgEdge[]
}
export type ChartSeries = {
id: string
label?: string
values: number[]
intent?: IntentId
}
export type ChartPayload = {
chartType: 'line' | 'bar' | 'area'
series: ChartSeries[]
}
export type HeatmapPayload = {
rows: number
cols: number
values: number[][]
triangular?: 'lower' | 'upper' | 'none'
rowLabels?: string[]
colLabels?: string[]
}
export type Panel =
| { id: string; type: 'svg-scene'; payload: SvgScenePayload }
| { id: string; type: 'chart'; payload: ChartPayload }
| { id: string; type: 'heatmap-matrix'; payload: HeatmapPayload }
export type DemoScene = {
id?: string
panels: Panel[]
}
export type DemoAct = {
id: string
title: string
scene?: DemoScene
transition?: TransitionId
pattern?: PatternId
steps: DemoStep[]
}
export type InteractiveDemoV1 = {
schemaVersion: 1
id: string
lang: string
disclaimer?: string
scene: DemoScene
acts: DemoAct[]
}
export type ValidationIssue = {
code: string
path: string
message: string
}
export type ValidationResult =
| { ok: true; demo: InteractiveDemoV1 }
| { ok: false; issues: ValidationIssue[] }

View File

@@ -0,0 +1,452 @@
import {
FORBIDDEN_COLOR_RE,
INTERACTIVE_DEMO_CAPS,
isHumanStringKey,
} from './constants'
import { interactiveDemoV1Schema } from './schema'
import type {
DemoAct,
DemoScene,
InteractiveDemoV1,
Panel,
ValidationIssue,
ValidationResult,
} from './types'
function issue(code: string, path: string, message: string): ValidationIssue {
return { code, path, message }
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Heatmap cell ids — respect triangular mode (lower → 36 cells for 8×8). */
export function heatmapCellIds(
rows: number,
cols: number,
triangular?: 'lower' | 'upper' | 'none'
): string[] {
const ids: string[] = []
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
if (triangular === 'lower' && c > r) continue
if (triangular === 'upper' && c < r) continue
ids.push(`r${r}.c${c}`)
}
}
return ids
}
/** Collect every addressable element id in a scene (nodes, edges, series, heatmap cells). */
export function collectSceneElementIds(scene: DemoScene): {
ids: Set<string>
wildcardAllowed: boolean
} {
const ids = new Set<string>()
let wildcardAllowed = false
for (const panel of scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) ids.add(n.id)
for (const e of panel.payload.edges ?? []) {
ids.add(e.id)
}
} else if (panel.type === 'chart') {
wildcardAllowed = true
for (const s of panel.payload.series) ids.add(s.id)
} else if (panel.type === 'heatmap-matrix') {
wildcardAllowed = true
const { rows, cols, triangular } = panel.payload
for (const id of heatmapCellIds(rows, cols, triangular)) {
ids.add(id)
}
}
}
return { ids, wildcardAllowed }
}
function countDeclaredScenes(demo: InteractiveDemoV1): number {
let n = 1 // demo.scene
for (const act of demo.acts) {
if (act.scene) n += 1
}
return n
}
/**
* Scan for free color literals — skip human/translatable string fields
* so prose like « couleur #FF0000 » does not hard-reject.
*/
function scanForbiddenColors(
value: unknown,
path: string,
out: ValidationIssue[]
): void {
if (typeof value === 'string') {
if (FORBIDDEN_COLOR_RE.test(value)) {
out.push(
issue(
'forbidden_color',
path,
'Free color literals (hex/rgb) are forbidden — use intent enums'
)
)
}
return
}
if (Array.isArray(value)) {
value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out))
return
}
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (isHumanStringKey(k)) continue
scanForbiddenColors(v, path ? `${path}.${k}` : k, out)
}
}
}
function validateSvgEdges(panel: Panel, path: string, out: ValidationIssue[]): void {
if (panel.type !== 'svg-scene') return
const nodeIds = new Set(panel.payload.nodes.map((n) => n.id))
for (const [i, edge] of (panel.payload.edges ?? []).entries()) {
if (!nodeIds.has(edge.from)) {
out.push(
issue(
'unknown_edge_endpoint',
`${path}.edges[${i}].from`,
`Edge from "${edge.from}" is not a node id in this panel`
)
)
}
if (!nodeIds.has(edge.to)) {
out.push(
issue(
'unknown_edge_endpoint',
`${path}.edges[${i}].to`,
`Edge to "${edge.to}" is not a node id in this panel`
)
)
}
}
}
function validateHeatmapShape(panel: Panel, path: string, out: ValidationIssue[]): void {
if (panel.type !== 'heatmap-matrix') return
const { rows, cols, values, rowLabels, colLabels } = panel.payload
if (values.length !== rows) {
out.push(
issue(
'heatmap_shape',
`${path}.values`,
`Expected ${rows} rows, got ${values.length}`
)
)
}
for (const [ri, row] of values.entries()) {
if (row.length !== cols) {
out.push(
issue(
'heatmap_shape',
`${path}.values[${ri}]`,
`Expected ${cols} cols, got ${row.length}`
)
)
}
}
if (rowLabels && rowLabels.length !== rows) {
out.push(
issue(
'heatmap_labels',
`${path}.rowLabels`,
`rowLabels length must equal rows (${rows})`
)
)
}
if (colLabels && colLabels.length !== cols) {
out.push(
issue(
'heatmap_labels',
`${path}.colLabels`,
`colLabels length must equal cols (${cols})`
)
)
}
}
function validateScene(
scene: DemoScene,
path: string,
out: ValidationIssue[]
): Set<string> {
const panelIds = new Set<string>()
for (const [pi, panel] of scene.panels.entries()) {
const pPath = `${path}.panels[${pi}]`
if (panelIds.has(panel.id)) {
out.push(issue('duplicate_panel_id', `${pPath}.id`, `Duplicate panel id "${panel.id}"`))
}
panelIds.add(panel.id)
validateSvgEdges(panel, pPath, out)
validateHeatmapShape(panel, pPath, out)
}
const seen = new Set<string>()
const dups = new Set<string>()
for (const panel of scene.panels) {
const local: string[] = []
if (panel.type === 'svg-scene') {
local.push(...panel.payload.nodes.map((n) => n.id))
local.push(...(panel.payload.edges ?? []).map((e) => e.id))
} else if (panel.type === 'chart') {
local.push(...panel.payload.series.map((s) => s.id))
}
for (const id of local) {
if (seen.has(id)) dups.add(id)
seen.add(id)
}
}
for (const id of dups) {
out.push(
issue('duplicate_element_id', path, `Duplicate element id "${id}" in scene`)
)
}
return collectSceneElementIds(scene).ids
}
function validateActRefs(
act: DemoAct,
actIndex: number,
elementIds: Set<string>,
wildcardAllowed: boolean,
out: ValidationIssue[]
): void {
const actPath = `acts[${actIndex}]`
const stepIdRe = new RegExp(`^${escapeRegExp(act.id)}\\.s\\d+$`)
for (const [si, step] of act.steps.entries()) {
const stepPath = `${actPath}.steps[${si}]`
if (!stepIdRe.test(step.id)) {
out.push(
issue(
'step_id_format',
`${stepPath}.id`,
`Step id must match /^${act.id}\\.s\\d+$/ (got "${step.id}")`
)
)
}
for (const id of step.pointTo ?? []) {
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.pointTo`,
`pointTo references unknown element "${id}" in active scene`
)
)
}
}
for (const [ri, rev] of (step.reveal ?? []).entries()) {
for (const id of rev.ids) {
if (id === '*') {
if (!wildcardAllowed) {
out.push(
issue(
'wildcard_not_allowed',
`${stepPath}.reveal[${ri}]`,
'"*" reveal is only allowed when the active scene has heatmap or chart panels'
)
)
}
continue
}
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.reveal[${ri}].ids`,
`reveal references unknown element "${id}"`
)
)
}
}
}
for (const [ai, ann] of (step.annotate ?? []).entries()) {
for (const id of ann.targetIds) {
if ((step.pointTo ?? []).includes(id)) {
out.push(
issue(
'annotate_pointto_overlap',
`${stepPath}.annotate[${ai}]`,
`Do not annotate "${id}" in the same step as pointTo — spotlight is enough`
)
)
}
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.annotate[${ai}].targetIds`,
`annotate references unknown element "${id}"`
)
)
}
}
}
}
}
function semanticValidate(demo: InteractiveDemoV1): ValidationIssue[] {
const out: ValidationIssue[] = []
const jsonBytes = new TextEncoder().encode(JSON.stringify(demo)).length
if (jsonBytes > INTERACTIVE_DEMO_CAPS.maxJsonBytes) {
out.push(
issue(
'json_too_large',
'',
`JSON exceeds ${INTERACTIVE_DEMO_CAPS.maxJsonBytes} bytes (${jsonBytes})`
)
)
}
const sceneCount = countDeclaredScenes(demo)
if (sceneCount > INTERACTIVE_DEMO_CAPS.maxScenes) {
out.push(
issue(
'too_many_scenes',
'scene',
`At most ${INTERACTIVE_DEMO_CAPS.maxScenes} scenes allowed (found ${sceneCount})`
)
)
}
scanForbiddenColors(demo, '', out)
validateScene(demo.scene, 'scene', out)
let activeScene = demo.scene
let { ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene)
const actIds = new Set<string>()
for (const [ai, act] of demo.acts.entries()) {
if (actIds.has(act.id)) {
out.push(issue('duplicate_act_id', `acts[${ai}].id`, `Duplicate act id "${act.id}"`))
}
actIds.add(act.id)
if (act.scene) {
validateScene(act.scene, `acts[${ai}].scene`, out)
activeScene = act.scene
;({ ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene))
}
const stepIds = new Set<string>()
for (const [si, step] of act.steps.entries()) {
if (stepIds.has(step.id)) {
out.push(
issue(
'duplicate_step_id',
`acts[${ai}].steps[${si}].id`,
`Duplicate step id "${step.id}"`
)
)
}
stepIds.add(step.id)
}
validateActRefs(act, ai, elementIds, wildcardAllowed, out)
}
return out
}
/**
* Validate an Interactive Demo document.
* Structural (Zod allowlist) then semantic (caps, refs, no hex, wildcards).
*/
export function validateInteractiveDemo(input: unknown): ValidationResult {
const parsed = interactiveDemoV1Schema.safeParse(input)
if (!parsed.success) {
const issues: ValidationIssue[] = parsed.error.issues.map((e) => ({
code: e.code,
path: e.path.join('.'),
message: e.message,
}))
return { ok: false, issues }
}
const demo = parsed.data as InteractiveDemoV1
const semantic = semanticValidate(demo)
if (semantic.length > 0) {
return { ok: false, issues: semantic }
}
return { ok: true, demo }
}
/**
* Translate must preserve geometry. Default: strings are structural (kept).
* Only HUMAN_STRING_KEYS are nullified — adding a structural key is protected by default.
*/
export function assertTranslatePreservesStructure(
source: InteractiveDemoV1,
translated: InteractiveDemoV1
): ValidationResult {
const stripHumanStrings = (value: unknown): unknown => {
if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
return value
}
if (typeof value === 'string') {
// Context-free string: structural until proven otherwise.
return value
}
if (Array.isArray(value)) return value.map(stripHumanStrings)
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
if (isHumanStringKey(k)) {
out[k] = null
continue
}
out[k] = stripHumanStrings(v)
}
return out
}
return value
}
const a = JSON.stringify(stripHumanStrings(source))
const b = JSON.stringify(stripHumanStrings(translated))
if (a !== b) {
return {
ok: false,
issues: [
issue(
'translate_structure_drift',
'',
'Translated demo must preserve structure and ids; only human strings may change'
),
],
}
}
if (source.id !== translated.id) {
return {
ok: false,
issues: [
issue(
'translate_id_mismatch',
'id',
'Translated variant must keep the same demo.id'
),
],
}
}
return { ok: true, demo: translated }
}

View File

@@ -0,0 +1,86 @@
/** Interactive Page schema v1 — caps & allowlists (spec Kimi / AttnRes). */
export const INTERACTIVE_PAGE_SCHEMA_VERSION = 1 as const
export const INTERACTIVE_PAGE_CAPS = {
maxSections: 8,
maxBlocksPerSection: 12,
maxDemosPerPage: 5,
maxSimsPerPage: 3,
maxOverviewCards: 4,
minOverviewCards: 2,
maxStatsItems: 5,
minStatsItems: 2,
maxJsonBytes: 128 * 1024,
} as const
export const SIM_CAPS = {
maxParams: 4,
maxComputed: 6,
maxExprChars: 200,
} as const
export const PAGE_BLOCK_TYPES = [
'prose',
'formula',
'callout',
'demo',
'chart',
'stats',
'table',
'image',
'sim',
] as const
export const CALLOUT_KINDS = [
'definition',
'warning',
'tip',
'note',
] as const
/**
* Human / locale strings — may contain hex in prose; skipped by color scan.
* Must stay in sync with validate + translate strip.
*/
export const PAGE_HUMAN_STRING_KEYS = [
// page-level
'lang',
'kicker',
'title',
'subtitle',
'meta',
'lead',
'badge',
'body',
'footer',
// blocks
'md',
'tex',
'caption',
'alt',
'value',
'label',
'columns',
'rows',
// sim blocks
'symbol',
'expr',
'intro',
'xLabel',
'yLabel',
// inherited from demos (speak etc. scanned via demo validator)
'speak',
'text',
'disclaimer',
'rowLabels',
'colLabels',
] as const
export type PageHumanStringKey = (typeof PAGE_HUMAN_STRING_KEYS)[number]
const PAGE_HUMAN_SET = new Set<string>(PAGE_HUMAN_STRING_KEYS)
export function isPageHumanStringKey(key: string): boolean {
return PAGE_HUMAN_SET.has(key)
}

View File

@@ -0,0 +1,354 @@
{
"schemaVersion": 1,
"id": "page.thermo-test",
"lang": "fr",
"hero": {
"kicker": "EXPLAINER INTERACTIF",
"title": "Cycle frigorifique",
"subtitle": "Compression, condensation, détente, évaporation",
"meta": "Note de cours · valeurs illustratives"
},
"overview": {
"lead": "Le cycle frigorifique déplace de la chaleur du froid vers le chaud grâce à un travail $W$.",
"cards": [
{
"badge": "PROBLEM",
"title": "Objectif",
"body": "Extraire $Q_e$ à basse température.",
"intent": "warning"
},
{
"badge": "APPROACH",
"title": "Cycle",
"body": "Quatre organes en boucle fermée.",
"intent": "flow"
},
{
"badge": "RESULT",
"title": "COP",
"body": "$\\mathrm{COP}=Q_e/W$",
"intent": "output"
}
]
},
"sections": [
{
"id": "s1",
"title": "Le problème",
"blocks": [
{
"type": "prose",
"md": "On veut **refroidir** un volume en rejetant la chaleur à lextérieur."
},
{
"type": "callout",
"kind": "definition",
"title": "Travail",
"md": "Le compresseur fournit $W=h_2-h_1$."
}
]
},
{
"id": "s2",
"title": "Échanges",
"blocks": [
{
"type": "formula",
"tex": "\\mathrm{COP}=\\frac{Q_e}{W}",
"caption": "Coefficient de performance"
},
{
"type": "sim",
"sim": {
"simId": "ts-diagram"
},
"caption": "Le même cycle sur le diagramme Ts : les aires sont les chaleurs échangées."
}
]
},
{
"id": "s3",
"title": "Isotherme",
"blocks": [
{
"type": "chart",
"payload": {
"chartType": "line",
"series": [
{
"id": "p",
"label": "P(V)",
"values": [
4,
2.5,
1.8,
1.4,
1.2
],
"intent": "flow"
}
]
},
"caption": "Pression vs volume (illustratif)"
},
{
"type": "demo",
"caption": "Le cycle à compression de vapeur — 4 organes en boucle fermée.",
"demo": {
"schemaVersion": 1,
"id": "demo.vapor-cycle",
"lang": "fr",
"disclaimer": "Schéma pédagogique — valeurs illustratives.",
"scene": {
"id": "scene.cycle",
"panels": [
{
"id": "panel.cycle",
"type": "svg-scene",
"payload": {
"nodes": [
{
"id": "comp",
"label": "1 · Compresseur\n$W = h_2 - h_1$",
"intent": "compute"
},
{
"id": "cond",
"label": "2 · Condenseur\n$Q_c = h_2 - h_3$",
"intent": "output"
},
{
"id": "exp",
"label": "3 · Détente\n$h_3 = h_4$",
"intent": "flow"
},
{
"id": "evap",
"label": "4 · Évaporateur\n$Q_e = h_1 - h_4$",
"intent": "cache"
},
{
"id": "work",
"label": "Travail fourni $W$",
"intent": "highlight"
}
],
"edges": [
{
"id": "e12",
"from": "comp",
"to": "cond",
"style": "solid",
"intent": "flow"
},
{
"id": "e23",
"from": "cond",
"to": "exp",
"style": "solid",
"intent": "flow"
},
{
"id": "e34",
"from": "exp",
"to": "evap",
"style": "solid",
"intent": "flow"
},
{
"id": "e41",
"from": "evap",
"to": "comp",
"style": "solid",
"intent": "flow"
},
{
"id": "ew",
"from": "work",
"to": "comp",
"style": "dashed",
"intent": "highlight"
}
]
}
}
]
},
"acts": [
{
"id": "a1",
"title": "Cycle",
"pattern": "flowTrace",
"steps": [
{
"id": "a1.s1",
"speak": "Le compresseur fournit le travail $W$ au fluide.",
"pattern": "spotlightTour",
"pointTo": [
"comp"
],
"reveal": [
{
"ids": [
"comp",
"work",
"ew"
],
"scope": "act"
}
]
},
{
"id": "a1.s2",
"speak": "Au condenseur, la chaleur $Q_c$ est rejetée vers l'extérieur.",
"pattern": "spotlightTour",
"pointTo": [
"cond"
],
"reveal": [
{
"ids": [
"cond",
"e12"
],
"scope": "act"
}
]
},
{
"id": "a1.s3",
"speak": "La détente est isenthalpique : $h_3 = h_4$.",
"pattern": "spotlightTour",
"pointTo": [
"exp"
],
"reveal": [
{
"ids": [
"exp",
"e23"
],
"scope": "act"
}
]
},
{
"id": "a1.s4",
"speak": "À l'évaporateur, le fluide absorbe $Q_e$ : c'est le froid utile.",
"pattern": "spotlightTour",
"pointTo": [
"evap"
],
"reveal": [
{
"ids": [
"evap",
"e34"
],
"scope": "act"
}
]
},
{
"id": "a1.s5",
"speak": "Le cycle se boucle : le fluide retourne au compresseur.",
"pattern": "overview",
"reveal": [
{
"ids": [
"comp",
"cond",
"exp",
"evap",
"work",
"e12",
"e23",
"e34",
"e41",
"ew"
],
"scope": "act"
}
]
}
]
}
]
}
},
{
"type": "sim",
"sim": {
"simId": "carnot-cycle",
"preset": {
"t_cold": 260,
"t_hot": 300,
"q_cold": 100
},
"disclaimer": "Valeurs illustratives — les COP réels sont inférieurs au COP de Carnot."
},
"caption": "Manipulez les températures des sources : le COP maximal suit le 2ᵉ principe."
},
{
"type": "sim",
"sim": {
"simId": "carnot-cycle-anim"
},
"caption": "Le cycle de Carnot battement par battement — piston et diagramme PV en direct."
},
{
"type": "stats",
"items": [
{
"value": "3.2",
"label": "COP typique",
"intent": "output"
},
{
"value": "1.25×",
"label": "Gain relatif",
"intent": "highlight"
},
{
"value": "<2%",
"label": "Pertes",
"intent": "warning"
}
]
}
]
},
{
"id": "s4",
"title": "Synthèse",
"blocks": [
{
"type": "table",
"columns": [
"Organe",
"Échange"
],
"rows": [
[
"Compresseur",
"$W$"
],
[
"Condenseur",
"$Q_c$"
],
[
"Évaporateur",
"$Q_e$"
]
]
},
{
"type": "prose",
"md": "Le COP mesure lefficacité du cycle."
}
]
}
],
"footer": "Valeurs pédagogiques — pas des mesures expérimentales."
}

View File

@@ -0,0 +1,25 @@
export {
INTERACTIVE_PAGE_CAPS,
INTERACTIVE_PAGE_SCHEMA_VERSION,
PAGE_BLOCK_TYPES,
CALLOUT_KINDS,
PAGE_HUMAN_STRING_KEYS,
isPageHumanStringKey,
} from './constants'
export { pageSpecV1Schema, pageBlockSchema } from './schema'
export { validateInteractivePage } from './validate'
export { normalizeInteractivePageCandidate } from './normalize'
export type {
PageSpecV1,
PageSection,
PageBlock,
PageHero,
PageOverview,
PageValidationIssue,
PageValidationResult,
IntentId,
SimRef,
CatalogSimRef,
GenericFormulaSim,
SimBlock,
} from './types'

View File

@@ -0,0 +1,335 @@
/**
* Deterministic repairs for LLM PageSpecV1 before Zod validation.
* Mirrors interactive-demo/normalize — fix common shape/field aliases.
*/
import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize'
import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants'
const CALLOUT_SET = new Set<string>(CALLOUT_KINDS)
const CALLOUT_ALIASES: Record<string, string> = {
definition: 'definition',
def: 'definition',
warning: 'warning',
warn: 'warning',
danger: 'warning',
alert: 'warning',
tip: 'tip',
hint: 'tip',
advice: 'tip',
note: 'note',
info: 'note',
remark: 'note',
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: null
}
function asString(v: unknown): string | undefined {
if (typeof v === 'string' && v.trim()) return v.trim()
if (typeof v === 'number' && Number.isFinite(v)) return String(v)
return undefined
}
function slugId(title: string, fallback: 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}` : fallback
}
function pickMd(obj: Record<string, unknown>): string | undefined {
return (
asString(obj.md) ||
asString(obj.content) ||
asString(obj.text) ||
asString(obj.body) ||
asString(obj.html) ||
asString(obj.markdown)
)
}
function pickTex(obj: Record<string, unknown>): string | undefined {
return (
asString(obj.tex) ||
asString(obj.latex) ||
asString(obj.formula) ||
asString(obj.math) ||
asString(obj.equation)
)
}
function normalizeCalloutKind(v: unknown): string {
if (typeof v !== 'string') return 'note'
const key = v.trim().toLowerCase()
const mapped = CALLOUT_ALIASES[key] || key
return CALLOUT_SET.has(mapped) ? mapped : 'note'
}
function normalizeBlock(
raw: unknown,
index: number
): Record<string, unknown> | null {
const obj = asRecord(raw)
if (!obj) return null
const type = asString(obj.type)?.toLowerCase()
if (!type) return null
if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') {
const md = pickMd(obj)
if (!md) return null
return { type: 'prose', md }
}
if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') {
const tex = pickTex(obj)
if (!tex) return null
const out: Record<string, unknown> = { type: 'formula', tex }
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
if (type === 'callout' || type === 'aside' || type === 'box') {
const md = pickMd(obj) || '—'
const title = asString(obj.title) || asString(obj.heading) || 'Note'
return {
type: 'callout',
kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style),
title,
md,
}
}
if (type === 'demo' || type === 'interactive' || type === 'animation') {
const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo
if (!nested) return null
const normalized = normalizeInteractiveDemoCandidate(nested)
const out: Record<string, unknown> = { type: 'demo', demo: normalized }
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
if (type === 'chart' || type === 'graph') {
const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj
const series = Array.isArray((payload as Record<string, unknown>).series)
? (payload as Record<string, unknown>).series
: null
if (!series) return null
const chartType =
asString((payload as Record<string, unknown>).chartType) ||
asString(obj.chartType) ||
'line'
const out: Record<string, unknown> = {
type: 'chart',
payload: {
chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line',
series,
},
}
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
if (type === 'stats' || type === 'metrics' || type === 'kpis') {
const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null
if (!items || items.length < 2) return null
return {
type: 'stats',
items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => {
const r = asRecord(it) || {}
return {
value: asString(r.value) || asString(r.v) || '—',
label: asString(r.label) || asString(r.name) || '—',
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
}
}),
}
}
if (type === 'table') {
const columns = Array.isArray(obj.columns)
? obj.columns.map((c) => asString(c) || '—')
: []
const rows = Array.isArray(obj.rows) ? obj.rows : []
if (!columns.length) return null
const out: Record<string, unknown> = { type: 'table', columns, rows }
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
if (type === 'image' || type === 'img' || type === 'figure') {
const src = asString(obj.src) || asString(obj.url)
const alt = asString(obj.alt) || asString(obj.title) || 'Image'
if (!src) return null
const out: Record<string, unknown> = { type: 'image', src, alt }
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') {
const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj
const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id)
if (!simId) return null
const out: Record<string, unknown> = { type: 'sim', sim: { ...sim, simId } }
const caption = asString(obj.caption)
if (caption) out.caption = caption
return out
}
return null
}
function normalizeSection(
raw: unknown,
index: number
): Record<string, unknown> | null {
const obj = asRecord(raw)
if (!obj) return null
const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}`
const id = asString(obj.id) || `s${index + 1}`
const blocksRaw = Array.isArray(obj.blocks)
? obj.blocks
: Array.isArray(obj.content)
? obj.content
: []
const blocks = blocksRaw
.map((b, i) => normalizeBlock(b, i))
.filter(Boolean)
.slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record<string, unknown>[]
if (!blocks.length) {
blocks.push({
type: 'prose',
md: asString(obj.summary) || asString(obj.lead) || title,
})
}
return { id, title, blocks }
}
/**
* Best-effort normalize of an LLM page candidate.
* Returns a plain object ready for validateInteractivePage.
*/
export function normalizeInteractivePageCandidate(
input: unknown,
lang = 'fr'
): Record<string, unknown> | null {
const root = asRecord(input)
if (!root) return null
const heroIn = asRecord(root.hero) || {}
const title =
asString(heroIn.title) ||
asString(root.title) ||
'Page interactive'
const kicker =
asString(heroIn.kicker) ||
asString(heroIn.eyebrow) ||
asString(heroIn.label) ||
(lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER')
const hero: Record<string, unknown> = {
kicker,
title,
}
const subtitle = asString(heroIn.subtitle) || asString(root.subtitle)
const meta = asString(heroIn.meta) || asString(root.meta)
if (subtitle) hero.subtitle = subtitle
if (meta) hero.meta = meta
let overview: Record<string, unknown> | undefined
const overviewIn = asRecord(root.overview)
if (overviewIn) {
const lead =
asString(overviewIn.lead) ||
asString(overviewIn.summary) ||
asString(overviewIn.text)
const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : []
const cards = cardsRaw
.map((c) => {
const r = asRecord(c)
if (!r) return null
const badge = asString(r.badge) || asString(r.label) || 'IDEA'
const cTitle = asString(r.title) || badge
const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle
return {
badge,
title: cTitle,
body,
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
}
})
.filter(Boolean)
if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) {
overview = {
lead,
cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
}
} else if (lead) {
// Pad to min cards so validation can pass
const padded = [...cards]
while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) {
padded.push({
badge: `C${padded.length + 1}`,
title: title,
body: lead.slice(0, 120),
})
}
overview = {
lead,
cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
}
}
}
const sectionsRaw = Array.isArray(root.sections) ? root.sections : []
let sections = sectionsRaw
.map((s, i) => normalizeSection(s, i))
.filter(Boolean) as Record<string, unknown>[]
// Cap demos (speed + reliability): keep first 2
let demoCount = 0
sections = sections.map((sec) => {
const blocks = (sec.blocks as Record<string, unknown>[]).filter((b) => {
if (b.type !== 'demo') return true
demoCount += 1
return demoCount <= 2
})
return { ...sec, blocks }
})
sections = sections.slice(0, 5)
if (!sections.length) {
sections = [
{
id: 's1',
title: title,
blocks: [{ type: 'prose', md: asString(root.summary) || title }],
},
]
}
const out: Record<string, unknown> = {
schemaVersion: 1,
id: asString(root.id) || slugId(title, 'page.generated'),
lang: asString(root.lang) || lang,
hero,
sections,
}
if (overview) out.overview = overview
const footer = asString(root.footer)
if (footer) out.footer = footer
return out
}

View File

@@ -0,0 +1,210 @@
import { z } from 'zod'
import { INTENT_IDS } from '@/lib/interactive-demo/constants'
import { interactiveDemoV1Schema } from '@/lib/interactive-demo/schema'
import {
CALLOUT_KINDS,
INTERACTIVE_PAGE_CAPS,
INTERACTIVE_PAGE_SCHEMA_VERSION,
PAGE_BLOCK_TYPES,
SIM_CAPS,
} from './constants'
const intentSchema = z.enum(INTENT_IDS).optional()
const langSchema = z
.string()
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
const chartPayloadSchema = z.object({
chartType: z.enum(['line', 'bar', 'area']),
series: z
.array(
z.object({
id: z.string().min(1),
label: z.string().optional(),
values: z.array(z.number()),
intent: intentSchema,
})
)
.min(1),
})
const proseBlock = z.object({
type: z.literal('prose'),
md: z.string().min(1),
})
const formulaBlock = z.object({
type: z.literal('formula'),
tex: z.string().min(1),
caption: z.string().optional(),
})
const calloutBlock = z.object({
type: z.literal('callout'),
kind: z.enum(CALLOUT_KINDS),
title: z.string().min(1),
md: z.string().min(1),
})
const demoBlock = z.object({
type: z.literal('demo'),
demo: interactiveDemoV1Schema,
caption: z.string().optional(),
})
const chartBlock = z.object({
type: z.literal('chart'),
payload: chartPayloadSchema,
caption: z.string().optional(),
})
const statsBlock = z.object({
type: z.literal('stats'),
items: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
intent: intentSchema,
})
)
.min(INTERACTIVE_PAGE_CAPS.minStatsItems)
.max(INTERACTIVE_PAGE_CAPS.maxStatsItems),
})
const tableBlock = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())),
caption: z.string().optional(),
})
const imageBlock = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().min(1),
caption: z.string().optional(),
})
const simParamIdSchema = z
.string()
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
const genericSimParamSchema = z.object({
id: simParamIdSchema,
symbol: z.string().min(1),
label: z.string().min(1),
min: z.number(),
max: z.number(),
step: z.number().positive(),
defaultValue: z.number(),
unit: z.string().optional(),
intent: intentSchema,
})
const genericSimComputedSchema = z.object({
id: simParamIdSchema,
symbol: z.string().min(1),
label: z.string().min(1),
expr: z.string().min(1).max(SIM_CAPS.maxExprChars),
unit: z.string().optional(),
intent: intentSchema,
})
const genericSimSchema = z.object({
simId: z.literal('generic-formula'),
title: z.string().min(1),
intro: z.string().optional(),
params: z.array(genericSimParamSchema).min(1).max(SIM_CAPS.maxParams),
computed: z.array(genericSimComputedSchema).min(1).max(SIM_CAPS.maxComputed),
visual: z.union([
z.object({ kind: z.literal('gauges') }),
z.object({ kind: z.literal('bars') }),
z.object({
kind: z.literal('curve'),
xParamId: simParamIdSchema,
expr: z.string().min(1),
xLabel: z.string().optional(),
yLabel: z.string().optional(),
}),
]),
disclaimer: z.string().optional(),
})
const catalogSimSchema = z.object({
simId: z
.string()
.min(1)
.refine((s) => s !== 'generic-formula', {
message: 'generic-formula must use the full inline schema',
}),
title: z.string().optional(),
preset: z.record(z.string(), z.number()).optional(),
disclaimer: z.string().optional(),
})
const simBlock = z.object({
type: z.literal('sim'),
sim: z.union([genericSimSchema, catalogSimSchema]),
caption: z.string().optional(),
})
export const pageBlockSchema = z.discriminatedUnion('type', [
proseBlock,
formulaBlock,
calloutBlock,
demoBlock,
chartBlock,
statsBlock,
tableBlock,
imageBlock,
simBlock,
])
const sectionSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
blocks: z
.array(pageBlockSchema)
.min(1)
.max(INTERACTIVE_PAGE_CAPS.maxBlocksPerSection),
})
const overviewSchema = z.object({
lead: z.string().min(1),
cards: z
.array(
z.object({
badge: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
intent: intentSchema,
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
})
export const pageSpecV1Schema = z.object({
schemaVersion: z.literal(INTERACTIVE_PAGE_SCHEMA_VERSION),
id: z.string().min(1),
lang: langSchema,
hero: z.object({
kicker: z.string().min(1),
title: z.string().min(1),
subtitle: z.string().optional(),
meta: z.string().optional(),
}),
overview: overviewSchema.optional(),
sections: z
.array(sectionSchema)
.min(1)
.max(INTERACTIVE_PAGE_CAPS.maxSections),
footer: z.string().optional(),
})
export type PageSpecV1Parsed = z.infer<typeof pageSpecV1Schema>
/** Re-export for callers. */
export { PAGE_BLOCK_TYPES }

View File

@@ -0,0 +1,355 @@
/**
* Safe math-expression evaluator for the generic-formula simulator.
* Tokenizer + recursive-descent parser — NO eval/Function, no object access,
* no loops. Identifiers resolve only against an explicit environment;
* functions come from a fixed allowlist.
*/
type Token =
| { t: 'num'; v: number }
| { t: 'id'; v: string }
| { t: 'op'; v: string }
| { t: 'lparen' }
| { t: 'rparen' }
| { t: 'comma' }
const CONSTANTS: Record<string, number> = {
pi: Math.PI,
e: Math.E,
}
type Fn = (...args: number[]) => number
const FUNCTIONS: Record<string, { fn: Fn; minArgs: number; maxArgs: number }> = {
sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 },
abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 },
exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 },
ln: { fn: Math.log, minArgs: 1, maxArgs: 1 },
log: { fn: Math.log10, minArgs: 1, maxArgs: 1 },
round: { fn: Math.round, minArgs: 1, maxArgs: 1 },
floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 },
ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 },
min: { fn: Math.min, minArgs: 1, maxArgs: 8 },
max: { fn: Math.max, minArgs: 1, maxArgs: 8 },
}
const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
export type SimExprError = { message: string }
type Ast =
| { k: 'num'; v: number }
| { k: 'id'; v: string }
| { k: 'call'; name: string; args: Ast[] }
| { k: 'un'; op: '-'; a: Ast }
| { k: 'bin'; op: string; a: Ast; b: Ast }
function tokenize(src: string): Token[] | SimExprError {
const tokens: Token[] = []
let i = 0
while (i < src.length) {
const ch = src[i]
if (ch === ' ' || ch === '\t' || ch === '\n') {
i++
continue
}
if (ch >= '0' && ch <= '9') {
let j = i
while (j < src.length && /[0-9.]/.test(src[j])) j++
const raw = src.slice(i, j)
const v = Number(raw)
if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` }
tokens.push({ t: 'num', v })
i = j
continue
}
if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') {
let j = i + 1
while (j < src.length && /[0-9]/.test(src[j])) j++
tokens.push({ t: 'num', v: Number(src.slice(i, j)) })
i = j
continue
}
if (/[A-Za-z_]/.test(ch)) {
let j = i
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++
tokens.push({ t: 'id', v: src.slice(i, j) })
i = j
continue
}
if ('+-*/^%'.includes(ch)) {
tokens.push({ t: 'op', v: ch })
i++
continue
}
if (ch === '(') {
tokens.push({ t: 'lparen' })
i++
continue
}
if (ch === ')') {
tokens.push({ t: 'rparen' })
i++
continue
}
if (ch === ',') {
tokens.push({ t: 'comma' })
i++
continue
}
return { message: `unexpected character "${ch}"` }
}
return tokens
}
class Parser {
private pos = 0
constructor(private tokens: Token[]) {}
private peek(): Token | undefined {
return this.tokens[this.pos]
}
private next(): Token | undefined {
return this.tokens[this.pos++]
}
private expectOp(): string | null {
const t = this.peek()
return t?.t === 'op' ? t.v : null
}
parseExpr(): Ast | SimExprError {
let left = this.parseTerm()
if ('message' in left) return left
for (;;) {
const op = this.expectOp()
if (op !== '+' && op !== '-') break
this.next()
const right = this.parseTerm()
if ('message' in right) return right
left = { k: 'bin', op, a: left, b: right }
}
return left
}
private parseTerm(): Ast | SimExprError {
let left = this.parseUnary()
if ('message' in left) return left
for (;;) {
const op = this.expectOp()
if (op !== '*' && op !== '/' && op !== '%') break
this.next()
const right = this.parseUnary()
if ('message' in right) return right
left = { k: 'bin', op, a: left, b: right }
}
return left
}
private parseUnary(): Ast | SimExprError {
if (this.expectOp() === '-') {
this.next()
const a = this.parseUnary()
if ('message' in a) return a
return { k: 'un', op: '-', a }
}
if (this.expectOp() === '+') {
this.next()
return this.parseUnary()
}
return this.parsePower()
}
private parsePower(): Ast | SimExprError {
const base = this.parseAtom()
if ('message' in base) return base
if (this.expectOp() === '^') {
this.next()
const exp = this.parseUnary() // right-assoc
if ('message' in exp) return exp
return { k: 'bin', op: '^', a: base, b: exp }
}
return base
}
private parseAtom(): Ast | SimExprError {
const t = this.next()
if (!t) return { message: 'unexpected end of expression' }
if (t.t === 'num') return { k: 'num', v: t.v }
if (t.t === 'lparen') {
const inner = this.parseExpr()
if ('message' in inner) return inner
const close = this.next()
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
return inner
}
if (t.t === 'id') {
if (this.peek()?.t === 'lparen') {
this.next() // consume (
const args: Ast[] = []
if (this.peek()?.t !== 'rparen') {
for (;;) {
const arg = this.parseExpr()
if ('message' in arg) return arg
args.push(arg)
if (this.peek()?.t === 'comma') {
this.next()
continue
}
break
}
}
const close = this.next()
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
return { k: 'call', name: t.v, args }
}
return { k: 'id', v: t.v }
}
return { message: `unexpected token "${'v' in t ? t.v : t.t}"` }
}
parseTop(): Ast | SimExprError {
const ast = this.parseExpr()
if ('message' in ast) return ast
if (this.pos < this.tokens.length) {
return { message: 'trailing tokens after expression' }
}
return ast
}
}
function evalAst(ast: Ast, env: Record<string, number>): number {
switch (ast.k) {
case 'num':
return ast.v
case 'id': {
if (ast.v in CONSTANTS) return CONSTANTS[ast.v]
const v = env[ast.v]
return typeof v === 'number' ? v : NaN
}
case 'un':
return -evalAst(ast.a, env)
case 'bin': {
const a = evalAst(ast.a, env)
const b = evalAst(ast.b, env)
switch (ast.op) {
case '+':
return a + b
case '-':
return a - b
case '*':
return a * b
case '/':
return b === 0 ? NaN : a / b
case '%':
return b === 0 ? NaN : a % b
case '^':
return Math.pow(a, b)
default:
return NaN
}
}
case 'call': {
const def = FUNCTIONS[ast.name]
if (!def) return NaN
const args = ast.args.map((a) => evalAst(a, env))
if (args.some((x) => Number.isNaN(x))) return NaN
return def.fn(...args)
}
}
}
function collectIds(ast: Ast, out: Set<string>): void {
switch (ast.k) {
case 'num':
return
case 'id':
out.add(ast.v)
return
case 'un':
collectIds(ast.a, out)
return
case 'bin':
collectIds(ast.a, out)
collectIds(ast.b, out)
return
case 'call':
ast.args.forEach((a) => collectIds(a, out))
return
}
}
export type ParsedSimExpr = {
/** Evaluate against an environment; NaN when uncomputable. */
evaluate(env: Record<string, number>): number
/** Identifiers used (params/computed refs), excluding constants. */
identifiers: string[]
}
/**
* Parse a safe math expression. Returns error message on syntax problems.
* Unknown function names are rejected at parse time.
*/
export function parseSimExpr(src: string): ParsedSimExpr | SimExprError {
const trimmed = src.trim()
if (!trimmed || trimmed.length > 200) {
return { message: 'expression empty or too long (max 200 chars)' }
}
const tokens = tokenize(trimmed)
if ('message' in tokens) return tokens
const ast = new Parser(tokens).parseTop()
if ('message' in ast) return ast
const ids = new Set<string>()
collectIds(ast, ids)
for (const id of ids) {
if (id in FUNCTIONS) {
return { message: `"${id}" is a function name — call it with (...)` }
}
}
// Validate function calls (unknown names, arity)
const checkCalls = (node: Ast): SimExprError | null => {
if (node.k === 'call') {
const def = FUNCTIONS[node.name]
if (!def) return { message: `unknown function "${node.name}"` }
if (node.args.length < def.minArgs || node.args.length > def.maxArgs) {
return { message: `function "${node.name}" expects ${def.minArgs}${def.maxArgs} args` }
}
for (const a of node.args) {
const err = checkCalls(a)
if (err) return err
}
} else if (node.k === 'un') {
return checkCalls(node.a)
} else if (node.k === 'bin') {
return checkCalls(node.a) ?? checkCalls(node.b)
}
return null
}
const callErr = checkCalls(ast)
if (callErr) return callErr
const identifiers = [...ids].filter((id) => !(id in CONSTANTS))
return {
evaluate: (env) => evalAst(ast, env),
identifiers,
}
}
/**
* Validation helper: parse + require every identifier ∈ allowedIds.
* Returns a list of issue messages (empty = OK).
*/
export function validateSimExprRefs(
src: string,
allowedIds: Set<string>
): string[] {
const parsed = parseSimExpr(src)
if ('message' in parsed) return [parsed.message]
return parsed.identifiers
.filter((id) => !allowedIds.has(id))
.map((id) => `unknown identifier "${id}"`)
}
/** Type guard helper for zod refinements. */
export function isValidSimParamId(id: string): boolean {
return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS)
}

View File

@@ -0,0 +1,153 @@
import type { ChartPayload, IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
import type { CALLOUT_KINDS, PAGE_BLOCK_TYPES } from './constants'
export type { IntentId }
export type PageBlockType = (typeof PAGE_BLOCK_TYPES)[number]
export type CalloutKind = (typeof CALLOUT_KINDS)[number]
export type PageHero = {
kicker: string
title: string
subtitle?: string
meta?: string
}
export type OverviewCard = {
badge: string
title: string
body: string
intent?: IntentId
}
export type PageOverview = {
lead: string
cards: OverviewCard[]
}
export type ProseBlock = { type: 'prose'; md: string }
export type FormulaBlock = { type: 'formula'; tex: string; caption?: string }
export type CalloutBlock = {
type: 'callout'
kind: CalloutKind
title: string
md: string
}
export type DemoBlock = {
type: 'demo'
demo: InteractiveDemoV1
caption?: string
}
export type ChartBlock = {
type: 'chart'
payload: ChartPayload
caption?: string
}
export type StatsBlock = {
type: 'stats'
items: { value: string; label: string; intent?: IntentId }[]
}
export type TableBlock = {
type: 'table'
columns: string[]
rows: string[][]
caption?: string
}
export type ImageBlock = {
type: 'image'
src: string
alt: string
caption?: string
}
// ── Simulator block (plugin catalog + generic formula) ──────────────────────
/** Curated simulator from the plugin registry — AI only picks + presets. */
export type CatalogSimRef = {
simId: string
title?: string
preset?: Record<string, number>
disclaimer?: string
}
export type GenericSimParam = {
id: string
symbol: string
label: string
min: number
max: number
step: number
defaultValue: number
unit?: string
intent?: IntentId
}
export type GenericSimComputed = {
id: string
symbol: string
label: string
/** Safe math expression (see sim-eval) over params + previous computed. */
expr: string
unit?: string
intent?: IntentId
}
export type GenericSimVisual =
| { kind: 'gauges' }
| { kind: 'bars' }
| { kind: 'curve'; xParamId: string; expr: string; xLabel?: string; yLabel?: string }
/** Inline custom simulation — expressions validated by sim-eval (no eval). */
export type GenericFormulaSim = {
simId: 'generic-formula'
title: string
intro?: string
params: GenericSimParam[]
computed: GenericSimComputed[]
visual: GenericSimVisual
disclaimer?: string
}
export type SimRef = CatalogSimRef | GenericFormulaSim
export type SimBlock = {
type: 'sim'
sim: SimRef
caption?: string
}
export type PageBlock =
| ProseBlock
| FormulaBlock
| CalloutBlock
| DemoBlock
| ChartBlock
| StatsBlock
| TableBlock
| ImageBlock
| SimBlock
export type PageSection = {
id: string
title: string
blocks: PageBlock[]
}
export type PageSpecV1 = {
schemaVersion: 1
id: string
lang: string
hero: PageHero
overview?: PageOverview
sections: PageSection[]
footer?: string
}
export type PageValidationIssue = {
code: string
path: string
message: string
}
export type PageValidationResult =
| { ok: true; page: PageSpecV1 }
| { ok: false; issues: PageValidationIssue[] }

View File

@@ -0,0 +1,303 @@
import { FORBIDDEN_COLOR_RE } from '@/lib/interactive-demo/constants'
import { validateInteractiveDemo } from '@/lib/interactive-demo/validate'
import { getPlugin } from '@/lib/simulators'
import {
INTERACTIVE_PAGE_CAPS,
isPageHumanStringKey,
} from './constants'
import { pageSpecV1Schema } from './schema'
import { validateSimExprRefs } from './sim-eval'
import type {
PageBlock,
PageSpecV1,
PageValidationIssue,
PageValidationResult,
} from './types'
function issue(code: string, path: string, message: string): PageValidationIssue {
return { code, path, message }
}
function scanForbiddenColors(
value: unknown,
path: string,
out: PageValidationIssue[]
): void {
if (typeof value === 'string') {
if (FORBIDDEN_COLOR_RE.test(value)) {
out.push(
issue(
'forbidden_color',
path,
'Free color literals (hex/rgb) are forbidden — use intent enums'
)
)
}
return
}
if (Array.isArray(value)) {
value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out))
return
}
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (isPageHumanStringKey(k)) continue
// Nested demos are validated separately (incl. their own color scan)
if (k === 'demo') continue
scanForbiddenColors(v, path ? `${path}.${k}` : k, out)
}
}
}
function countDemos(page: PageSpecV1): number {
let n = 0
for (const s of page.sections) {
for (const b of s.blocks) {
if (b.type === 'demo') n += 1
}
}
return n
}
function countSims(page: PageSpecV1): number {
let n = 0
for (const s of page.sections) {
for (const b of s.blocks) {
if (b.type === 'sim') n += 1
}
}
return n
}
/** Simulator block: catalog ref integrity / generic exprs safety. */
function validateSim(
block: Extract<PageBlock, { type: 'sim' }>,
path: string,
out: PageValidationIssue[]
): void {
const sim = block.sim
if (sim.simId === 'generic-formula') {
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
const paramIds = new Set(generic.params.map((p) => p.id))
for (const p of generic.params) {
if (p.min >= p.max) {
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
}
if (p.defaultValue < p.min || p.defaultValue > p.max) {
out.push(
issue('sim_param_default', `${path}.params`, `Param "${p.id}": default outside [min, max]`)
)
}
}
const allowed = new Set<string>(paramIds)
for (const c of generic.computed) {
const errs = validateSimExprRefs(c.expr, allowed)
for (const e of errs) {
out.push(issue('sim_expr', `${path}.computed.${c.id}`, e))
}
if (errs.length === 0) allowed.add(c.id) // computed may chain
}
if (generic.visual.kind === 'curve') {
if (!paramIds.has(generic.visual.xParamId)) {
out.push(
issue('sim_curve_param', `${path}.visual.xParamId`, `Unknown param "${generic.visual.xParamId}"`)
)
}
const errs = validateSimExprRefs(generic.visual.expr, allowed)
for (const e of errs) {
out.push(issue('sim_expr', `${path}.visual.expr`, e))
}
}
return
}
// Catalog plugin: must exist. Sims: preset within bounds + compute finite.
const catalog = sim as Extract<typeof sim, { simId: string }> & {
preset?: Record<string, number>
}
const plugin = getPlugin(catalog.simId)
if (!plugin) {
out.push(issue('unknown_simulator', `${path}.simId`, `Unknown simulator "${catalog.simId}"`))
return
}
if (plugin.family === 'anim') {
if (!plugin.beats.length) {
out.push(issue('sim_anim_empty', `${path}`, 'Animation plugin has no beats'))
}
return
}
const env: Record<string, number> = {}
for (const p of plugin.params) env[p.id] = p.defaultValue
if (catalog.preset) {
for (const [k, v] of Object.entries(catalog.preset)) {
const def = plugin.params.find((p) => p.id === k)
if (!def) {
out.push(issue('sim_preset_key', `${path}.preset.${k}`, 'Not a parameter of this simulator'))
continue
}
if (v < def.min || v > def.max) {
out.push(
issue('sim_preset_range', `${path}.preset.${k}`, `Value ${v} outside [${def.min}, ${def.max}]`)
)
continue
}
env[k] = v
}
}
try {
const result = plugin.compute(env)
for (const o of plugin.outputs) {
if (!Number.isFinite(result[o.id])) {
out.push(
issue('sim_compute', `${path}`, `Output "${o.id}" not finite at preset values`)
)
}
}
} catch {
out.push(issue('sim_compute', `${path}`, 'Simulator compute() threw at preset values'))
}
}
function validateTable(
block: Extract<PageBlock, { type: 'table' }>,
path: string,
out: PageValidationIssue[]
): void {
const cols = block.columns.length
for (const [ri, row] of block.rows.entries()) {
if (row.length !== cols) {
out.push(
issue(
'table_shape',
`${path}.rows[${ri}]`,
`Expected ${cols} cells, got ${row.length}`
)
)
}
}
}
function validateImageSrc(
block: Extract<PageBlock, { type: 'image' }>,
path: string,
out: PageValidationIssue[]
): void {
const src = block.src.trim()
// Allow relative /uploads, https, and data:image — reject javascript: etc.
if (/^\s*javascript:/i.test(src) || /^\s*data:text\/html/i.test(src)) {
out.push(
issue('unsafe_image_src', `${path}.src`, 'Unsafe image src rejected')
)
}
}
function semanticValidate(page: PageSpecV1): PageValidationIssue[] {
const out: PageValidationIssue[] = []
const jsonBytes = new TextEncoder().encode(JSON.stringify(page)).length
if (jsonBytes > INTERACTIVE_PAGE_CAPS.maxJsonBytes) {
out.push(
issue(
'json_too_large',
'',
`JSON exceeds ${INTERACTIVE_PAGE_CAPS.maxJsonBytes} bytes (${jsonBytes})`
)
)
}
const demoCount = countDemos(page)
if (demoCount > INTERACTIVE_PAGE_CAPS.maxDemosPerPage) {
out.push(
issue(
'too_many_demos',
'sections',
`At most ${INTERACTIVE_PAGE_CAPS.maxDemosPerPage} demos per page (found ${demoCount})`
)
)
}
const simCount = countSims(page)
if (simCount > INTERACTIVE_PAGE_CAPS.maxSimsPerPage) {
out.push(
issue(
'too_many_sims',
'sections',
`At most ${INTERACTIVE_PAGE_CAPS.maxSimsPerPage} sims per page (found ${simCount})`
)
)
}
scanForbiddenColors(page, '', out)
const sectionIds = new Set<string>()
for (const [si, section] of page.sections.entries()) {
const sPath = `sections[${si}]`
if (sectionIds.has(section.id)) {
out.push(
issue(
'duplicate_section_id',
`${sPath}.id`,
`Duplicate section id "${section.id}"`
)
)
}
sectionIds.add(section.id)
if (!/^s\d+$/.test(section.id)) {
out.push(
issue(
'section_id_format',
`${sPath}.id`,
`Section id should match /^s\\d+$/ (got "${section.id}")`
)
)
}
for (const [bi, block] of section.blocks.entries()) {
const bPath = `${sPath}.blocks[${bi}]`
// Unknown types already hard-rejected by Zod discriminatedUnion.
if (block.type === 'table') validateTable(block, bPath, out)
if (block.type === 'image') validateImageSrc(block, bPath, out)
if (block.type === 'sim') validateSim(block, bPath, out)
if (block.type === 'demo') {
const demoResult = validateInteractiveDemo(block.demo)
if (!demoResult.ok) {
for (const iss of demoResult.issues) {
out.push({
code: `demo_${iss.code}`,
path: `${bPath}.demo${iss.path ? '.' + iss.path : ''}`,
message: iss.message,
})
}
}
}
}
}
return out
}
/**
* Validate a PageSpecV1 document.
* Structural (Zod allowlist) then semantic (caps, colors, demos, tables).
*/
export function validateInteractivePage(input: unknown): PageValidationResult {
const parsed = pageSpecV1Schema.safeParse(input)
if (!parsed.success) {
const issues: PageValidationIssue[] = parsed.error.issues.map((e) => ({
code: e.code,
path: e.path.join('.'),
message: e.message,
}))
return { ok: false, issues }
}
const page = parsed.data as PageSpecV1
const semantic = semanticValidate(page)
if (semantic.length > 0) {
return { ok: false, issues: semantic }
}
return { ok: true, page }
}

View File

@@ -25,6 +25,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 5,
voice_transcribe: 20,
publish_enhance: 2,
interactive_demo: 5,
interactive_page: 2,
},
PRO: {
semantic_search: 200,
@@ -42,6 +44,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 100,
voice_transcribe: 500,
publish_enhance: 15,
interactive_demo: 40,
interactive_page: 20,
},
BUSINESS: {
semantic_search: 1000,
@@ -59,6 +63,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 100,
interactive_demo: 200,
interactive_page: 80,
},
ENTERPRISE: {
semantic_search: 'unlimited',
@@ -75,6 +81,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 'unlimited',
interactive_demo: 'unlimited',
interactive_page: 'unlimited',
},
};

View File

@@ -1,4 +1,4 @@
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay', 'interactive-page'] as const
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
/** Métadonnées éditoriales IA — corps = HTML source original. */
@@ -19,3 +19,9 @@ export interface PublishRewriteSpec {
export function isPublishTemplateId(value: string): value is PublishTemplateId {
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
}
export function isInteractivePageTemplate(
value: string | null | undefined
): boolean {
return value === 'interactive-page'
}

View File

@@ -13,6 +13,8 @@ export const VALID_FEATURES = [
'ai_flashcard',
'voice_transcribe',
'publish_enhance',
'interactive_demo',
'interactive_page',
] as const;
export type FeatureName = (typeof VALID_FEATURES)[number];

View File

@@ -0,0 +1,59 @@
# Simulateurs interactifs (plugins)
Bibliothèque de simulateurs pédagogiques pour les **pages interactives** (`/p/{slug}`).
L'IA ne code jamais une simulation : elle **choisit** un simulateur de cette liste et le
**configure** (`preset` = valeurs de la note), ou utilise `generic-formula` si aucun ne
correspond au sujet.
## Liste des simulateurs disponibles
| `simId` | Famille | Sujet | Interaction |
|---|---|---|---|
| `carnot-cycle` | `sim` | Machine frigorifique / PAC / moteur de Carnot (2ᵉ principe) | Curseurs T_c, T_h, Q_c → COP, η, W min, Q_h en direct |
| `carnot-cycle-anim` | `anim` | Cycle de Carnot animé (piston + diagramme PV live, 5 étapes) | Play / Pause / Step / Reset / vitesse + narration |
| `generic-formula` | `sim` | (générique) toute relation chiffrée y = f(paramètres) | Curseurs définis par l'IA, expressions sûres (aucun `eval`) |
Deux familles de plugins : `sim` (manipulation de paramètres, calcul en direct) et
`anim` (scène animée codée en dur, pilotée par le player Play/Step — narration par
battements). Les deux sont choisis par l'IA via le même bloc `{ "type": "sim" }`.
## Comment l'IA les utilise
1. Le prompt de génération de section injecte `catalogForPrompt(lang)` (registry.ts) —
id + résumé + mots-clés + bornes de chaque simulateur.
2. Si le contenu de la note correspond (`keywords`), l'IA émet
`{ "type": "sim", "sim": { "simId": "carnot-cycle", "preset": { "t_hot": 300 } } }`.
3. `validateInteractivePage` vérifie : `simId` connu, preset dans les bornes,
`compute(preset)` fini — sinon rejet (renvoyé au LLM ou fallback).
## Ajouter un plugin (5 étapes)
Pour un **`sim`** (curseurs) :
1. **`lib/simulators/<id>.ts`** — implémenter `SimulatorPlugin` (`family: 'sim'`) :
`id`, `title`/`summary` fr+en (le summary sert au matching IA), `keywords`,
`params` (curseurs : bornes, pas, défaut, unité, intent), `outputs` (symbole KaTeX,
unité), et `compute(env)` **pur et déterministe**.
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-view.tsx`** — composant sur mesure
(props : `preset`, `title`, `disclaimer`, `lang`). Réutiliser
`SimSlider` / `SimOutputCard` / `SimHeading` / `SimKaTeX` de `sim-controls.tsx`.
4. **`components/simulators/index.ts`** — enregistrer dans `SIMULATOR_VIEWS`.
5. Vérifier : `compute` fini aux valeurs par défaut, `npx tsc --noEmit`, tester sur
`/dev/interactive-page`.
Pour une **`anim`** (scène animée Play/Step) :
1. **`lib/simulators/<id>.ts`** — implémenter `AnimPlugin` (`family: 'anim'`) :
`id`, `title`/`summary` fr+en, `keywords`, `disclaimer?`, `beats` (narration
fr+en par étape — KaTeX inline `$…$` dans `speak`).
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-anim-view.tsx`** — scène SVG/React pilotée par
`step` (props : `step`, `lang`). Transitions CSS sur `transform`/`opacity`
uniquement. Le chrome (Play/Pause/Step/Reset/vitesse + panneau de narration +
clavier Espace/←/→/R) est fourni par `AnimPlayerShell` — ne pas le réécrire.
4. **`components/simulators/index.ts`** — enregistrer dans `ANIM_VIEWS`.
5. Vérifier : chaque `step` rend un état cohérent (y compris état final sans JS),
`npx tsc --noEmit`, tester sur `/dev/interactive-page`.
Règles : pas de couleurs en dur (intents / tokens `--pp-*`), labels fr+en dans le
plugin, tout calcul côté `compute` ou scène codée (jamais de logique LLM), SSR =
état lisible sans JS.

View File

@@ -0,0 +1,76 @@
import type { AnimPlugin } from './types'
/**
* Carnot cycle — animated piston + live P-V diagram, 5 beats.
* Scene rendered by components/simulators/carnot-cycle-anim-view.tsx.
*/
export const carnotCycleAnim: AnimPlugin = {
family: 'anim',
id: 'carnot-cycle-anim',
title: {
fr: 'Le cycle de Carnot en mouvement',
en: 'The Carnot cycle in motion',
},
summary: {
fr: 'Animation du cycle de Carnot : piston, détentes/compressions isothermes et adiabatiques, diagramme P-V tracé en direct, échanges Q et W. 2ᵉ principe, machine thermique, réfrigérateur.',
en: 'Animated Carnot cycle: piston, isothermal and adiabatic expansion/compression, live P-V diagram, Q and W exchanges. Second law, heat engine, refrigerator.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cycle',
'piston',
'isotherme',
'adiabatique',
'isothermal',
'adiabatic',
'entropie',
'entropy',
'machine thermique',
'heat engine',
'deuxième principe',
'second law',
],
disclaimer: {
fr: 'Schéma pédagogique — grandeurs illustratives, gaz parfait.',
en: 'Teaching schematic — illustrative values, ideal gas.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : le gaz pousse le piston, la chaleur $Q_h$ entre depuis la source chaude.',
en: '**Isothermal expansion** at $T_h$: the gas pushes the piston, heat $Q_h$ flows in from the hot reservoir.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : isolé, le gaz continue de se détendre et se refroidit de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: insulated, the gas keeps expanding and cools from $T_h$ down to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : on travaille sur le gaz, la chaleur $Q_c$ sort vers la source froide.',
en: '**Isothermal compression** at $T_c$: work is done on the gas, heat $Q_c$ flows out to the cold reservoir.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : le gaz remonte de $T_c$ à $T_h$ — le cycle se referme.',
en: '**Adiabatic compression**: the gas warms back from $T_c$ to $T_h$ — the cycle closes.',
},
},
{
id: 'b5',
speak: {
fr: "Le **travail net** $W = Q_h - Q_c$ est l'aire du cycle sur le diagramme $P$$V$. Le rendement $\eta = 1 - T_c/T_h$ est le maximum permis par le 2ᵉ principe.",
en: '**Net work** $W = Q_h - Q_c$ is the cycle area on the $P$$V$ diagram. Efficiency $\eta = 1 - T_c/T_h$ is the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,254 @@
import type { SimulatorPlugin } from './types'
/**
* Ideal Carnot machine between two reservoirs (2nd law).
*
* Physics (absolute temperatures only):
* - Refrigerator: COP_R = Tc/(ThTc), W_min = Qc/COP_R, Qh = Qc+W
* - Heat pump: COP_HP = Th/(ThTc) = COP_R+1, W_min = Qh/COP_HP
* - Engine: η = 1Tc/Th, W_out = η·Qh, Qc = QhW
*
* `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only).
* For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin.
*
* Refs: COP_R Carnot = Tc/(ThTc); 1st law Qh=Qc+W; energy↔power interchangeable
* if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators).
*/
export const carnotCycleSimulator: SimulatorPlugin = {
family: 'sim',
id: 'carnot-cycle',
title: {
fr: 'Machine de Carnot (frigo / PAC / moteur)',
en: 'Carnot machine (fridge / heat pump / engine)',
},
summary: {
fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(ThTc), COP pompe à chaleur Th/(ThTc), rendement moteur η=1Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.',
en: 'Carnot limits between two reservoirs: fridge COP Tc/(ThTc), heat-pump COP Th/(ThTc), engine η=1Tc/Th, minimum work or power. 1st and 2nd laws.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cop',
'réfrigérateur',
'frigo',
'refrigerator',
'pompe à chaleur',
'heat pump',
'moteur',
'engine',
'rendement',
'efficiency',
'watt',
'puissance',
'power',
'deuxième principe',
'second law',
],
params: [
{
id: 't_cold',
symbol: 'T_c',
label: { fr: 'Source froide', en: 'Cold reservoir' },
min: 200,
max: 320,
step: 1,
defaultValue: 260,
unit: 'K',
intent: 'cache',
},
{
id: 't_hot',
symbol: 'T_h',
label: { fr: 'Source chaude', en: 'Hot reservoir' },
min: 273,
max: 400,
step: 1,
defaultValue: 300,
unit: 'K',
intent: 'warning',
},
{
id: 'q_cold',
symbol: 'Q_c',
label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' },
min: 10,
max: 500,
step: 5,
defaultValue: 100,
unit: 'kJ',
intent: 'flow',
},
],
outputs: [
{
id: 'cop_fridge',
symbol: '\\mathrm{COP}_{R}',
label: { fr: 'COP réfrigérateur', en: 'Fridge COP' },
intent: 'output',
digits: 2,
},
{
id: 'cop_hp',
symbol: '\\mathrm{COP}_{HP}',
label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' },
intent: 'output',
digits: 2,
},
{
id: 'eta',
symbol: '\\eta',
label: { fr: 'Rendement moteur', en: 'Engine efficiency' },
unit: '%',
intent: 'highlight',
digits: 1,
},
{
id: 'w_min',
symbol: 'W',
label: { fr: 'Travail (énergie)', en: 'Work (energy)' },
unit: 'kJ',
intent: 'compute',
digits: 1,
},
{
id: 'q_hot',
symbol: 'Q_h',
label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' },
unit: 'kJ',
intent: 'flow',
digits: 1,
},
],
compute(env) {
const tc = env.t_cold
const th = env.t_hot
const qc = env.q_cold
if (!(th > tc) || !(qc > 0)) {
return {
cop_fridge: NaN,
cop_hp: NaN,
eta: NaN,
w_min: NaN,
q_hot: NaN,
}
}
const copFridge = tc / (th - tc)
const copHp = th / (th - tc)
const eta = 1 - tc / th
const wMin = qc / copFridge
return {
cop_fridge: copFridge,
cop_hp: copHp,
eta: eta * 100,
w_min: wMin,
q_hot: qc + wMin,
}
},
}
/** Operating mode for the interactive view (UI-only; physics shared). */
export type CarnotMode = 'fridge' | 'heat_pump' | 'engine'
/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */
export type CarnotQuantity = 'energy' | 'power'
export type CarnotPhysics = {
ok: boolean
tc: number
th: number
copR: number
copHP: number
eta: number
/** Heat exchanged with cold reservoir (magnitude > 0). */
qc: number
/** Heat exchanged with hot reservoir (magnitude > 0). */
qh: number
/** Work magnitude > 0 (input for fridge/PAC, output for engine). */
w: number
/** Reversible check: Qc/Tc ≈ Qh/Th */
entropyOk: boolean
}
/**
* Resolve magnitudes for the selected mode.
* `load` is the primary useful quantity:
* - fridge: Qc extracted from cold
* - heat_pump: Qh delivered to hot
* - engine: Qh absorbed from hot
*/
export function resolveCarnotPhysics(
tc: number,
th: number,
load: number,
mode: CarnotMode
): CarnotPhysics {
if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) {
return {
ok: false,
tc,
th,
copR: NaN,
copHP: NaN,
eta: NaN,
qc: NaN,
qh: NaN,
w: NaN,
entropyOk: false,
}
}
const copR = tc / (th - tc)
const copHP = th / (th - tc)
const eta = 1 - tc / th
let qc: number
let qh: number
let w: number
if (mode === 'fridge') {
qc = load
w = qc / copR
qh = qc + w
} else if (mode === 'heat_pump') {
qh = load
w = qh / copHP
qc = qh - w
} else {
qh = load
w = eta * qh
qc = qh - w
}
const ratioC = qc / tc
const ratioH = qh / th
const entropyOk =
Number.isFinite(ratioC) &&
Number.isFinite(ratioH) &&
Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6
return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk }
}
/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */
export function fridgeLoadFromModeLoad(
tc: number,
th: number,
modeLoad: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(modeLoad > 0)) return modeLoad
if (mode === 'fridge') return modeLoad
if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th
return modeLoad * (tc / th) // engine: Qc = Qh · (1η) = Qh · Tc/Th
}
export function modeLoadFromFridgeLoad(
tc: number,
th: number,
fridgeQc: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc
if (mode === 'fridge') return fridgeQc
if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc
return fridgeQc * (th / tc) // engine Qh = Qc / (1η) = Qc · Th/Tc
}

View File

@@ -0,0 +1,65 @@
import type { IntentId } from '@/lib/interactive-demo/types'
import { carnotCycleSimulator } from './carnot-cycle'
import { carnotCycleAnim } from './carnot-cycle-anim'
import { tsDiagramAnim } from './ts-diagram'
import type { AnimPlugin, AnyPlugin, SimulatorPlugin } from './types'
export const GENERIC_SIM_ID = 'generic-formula' as const
const REGISTRY: Record<string, AnyPlugin> = {
[carnotCycleSimulator.id]: carnotCycleSimulator,
[carnotCycleAnim.id]: carnotCycleAnim,
[tsDiagramAnim.id]: tsDiagramAnim,
}
export function getPlugin(id: string): AnyPlugin | null {
return REGISTRY[id] ?? null
}
export function getSimulator(id: string): SimulatorPlugin | null {
const p = REGISTRY[id]
return p?.family === 'sim' ? p : null
}
export function getAnimPlugin(id: string): AnimPlugin | null {
const p = REGISTRY[id]
return p?.family === 'anim' ? p : null
}
export function isCatalogSimId(id: string): boolean {
return id !== GENERIC_SIM_ID && id in REGISTRY
}
export function listSimulators(): AnyPlugin[] {
return Object.values(REGISTRY)
}
/**
* Compact catalog injected into the section-generation prompt so the LLM
* can pick a curated plugin (sim or anim) and bind note values into a preset.
*/
export function catalogForPrompt(lang: string): string {
const fr = lang.startsWith('fr')
const catalog = listSimulators().map((plugin) => ({
simId: plugin.id,
family: plugin.family,
summary: fr ? plugin.summary.fr : plugin.summary.en,
keywords: plugin.keywords.slice(0, 10),
...(plugin.family === 'sim'
? {
params: plugin.params.map((p) => ({
id: p.id,
symbol: p.symbol,
range: [p.min, p.max],
default: p.defaultValue,
unit: p.unit,
})),
outputs: plugin.outputs.map((o) => o.symbol),
}
: {}),
}))
return JSON.stringify(catalog, null, 1)
}
export type { SimulatorPlugin, AnimPlugin, AnyPlugin, AnimBeat, SimI18n, SimParamDef, SimOutputDef } from './types'
export type { IntentId }

View File

@@ -0,0 +1,74 @@
import type { AnimPlugin } from './types'
/**
* Ts diagram of the Carnot cycle — the canonical 2nd-law diagram:
* isotherms are horizontal, adiabatics vertical, heat = area.
* Same 5 phases as carnot-cycle-anim (piston) so both tell one story.
*/
export const tsDiagramAnim: AnimPlugin = {
family: 'anim',
id: 'ts-diagram',
title: {
fr: 'Le cycle de Carnot sur le diagramme Ts',
en: 'The Carnot cycle on the Ts diagram',
},
summary: {
fr: 'Diagramme températureentropie (Ts) du cycle de Carnot : isothermes horizontales, adiabatiques verticales, aires = chaleurs Q_h et Q_c, aire du cycle = travail W. 2ᵉ principe, entropie, rendement.',
en: 'Temperatureentropy (Ts) diagram of the Carnot cycle: horizontal isotherms, vertical adiabatics, areas = heats Q_h and Q_c, cycle area = work W. Second law, entropy, efficiency.',
},
keywords: [
'carnot',
'entropie',
'entropy',
'diagramme t-s',
't-s diagram',
'thermodynamique',
'thermodynamics',
'deuxième principe',
'second law',
'rendement',
'efficiency',
'cycle',
],
disclaimer: {
fr: 'Diagramme pédagogique — grandeurs illustratives.',
en: 'Teaching diagram — illustrative values.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : lentropie croît, la chaleur $Q_h = T_h \\Delta s$ est laire sous lisotherme.',
en: '**Isothermal expansion** at $T_h$: entropy grows, heat $Q_h = T_h \\Delta s$ is the area under the isotherm.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : verticale — lentropie est constante, la température chute de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: vertical line — entropy is constant, temperature drops from $T_h$ to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : lentropie décroît, la chaleur $Q_c = T_c \\Delta s$ est rejetée — laire bleue.',
en: '**Isothermal compression** at $T_c$: entropy decreases, heat $Q_c = T_c \\Delta s$ is rejected — the blue area.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : remontée verticale de $T_c$ à $T_h$ — le rectangle se referme.',
en: '**Adiabatic compression**: vertical climb from $T_c$ back to $T_h$ — the rectangle closes.',
},
},
{
id: 'b5',
speak: {
fr: 'Le **travail net** $W = (T_h - T_c)\\Delta s$ est laire du rectangle. Rapport des aires = rendement $\\eta = 1 - T_c/T_h$ — le maximum du 2ᵉ principe.',
en: '**Net work** $W = (T_h - T_c)\\Delta s$ is the rectangle area. Area ratio = efficiency $\\eta = 1 - T_c/T_h$ — the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,72 @@
import type { IntentId } from '@/lib/interactive-demo/types'
/** Bilingual label — page content follows the note's language, not the UI locale. */
export type SimI18n = { fr: string; en: string }
export type SimParamDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "T_c" */
symbol: string
label: SimI18n
min: number
max: number
step: number
defaultValue: number
unit?: string
intent?: IntentId
}
export type SimOutputDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "\\mathrm{COP}" */
symbol: string
label: SimI18n
unit?: string
intent?: IntentId
/** Fraction digits for display (default 2). */
digits?: number
}
/**
* A curated interactive simulator plugin (catalog).
* The AI never writes simulation code — it picks a plugin and a preset.
* `compute` must be pure and deterministic; it runs client-side on every
* slider move and once server-side at validation time.
*/
export type SimulatorPlugin = {
family: 'sim'
id: string
title: SimI18n
/** Shown to the LLM for content matching. */
summary: SimI18n
keywords: string[]
params: SimParamDef[]
outputs: SimOutputDef[]
compute(env: Record<string, number>): Record<string, number>
}
// ── Animated pedagogical scenes (Play/Step narration over a coded scene) ────
export type AnimBeat = {
id: string
speak: SimI18n
}
/**
* A curated ANIMATION plugin: a hand-coded parametric scene (like the demos
* on distill.pub / the Kimi AttnRes page) driven beat-by-beat by the shared
* player chrome. The view component receives `stepIndex` and renders the
* scene state — transitions are CSS (transform/opacity) only.
*/
export type AnimPlugin = {
family: 'anim'
id: string
title: SimI18n
summary: SimI18n
keywords: string[]
disclaimer?: SimI18n
/** Narration beats; step N of the player = beat N. */
beats: AnimBeat[]
}
export type AnyPlugin = SimulatorPlugin | AnimPlugin

View File

@@ -38,7 +38,24 @@
"createYourSpaceSubtitle": "Join the new era of smart note-taking.",
"forgot": "Forgot?",
"backToSite": "Back to site",
"privacyTerms": "© 2025 Memento Labs — Privacy · Terms"
"privacyTerms": "© 2025 Memento Labs — Privacy · Terms",
"checkEmailTitle": "Check your email",
"checkEmailDescription": "We sent a confirmation link to {email}. Open it to activate your account before signing in.",
"checkEmailDescriptionGeneric": "We sent a confirmation link to your email. Open it to activate your account before signing in.",
"resendVerification": "Resend confirmation email",
"verifyResent": "Confirmation email sent. Check your inbox.",
"verifyResendFailed": "Could not send the confirmation email. Try again later.",
"verifyMissingEmail": "Enter your email address.",
"verifyLoading": "Confirming your email…",
"verifySuccessTitle": "Email confirmed",
"verifySuccessDescription": "Your account is ready. You can now sign in.",
"verifyExpiredTitle": "Link expired",
"verifyExpiredDescription": "This confirmation link has expired. Request a new one.",
"verifyInvalidTitle": "Invalid link",
"verifyInvalidDescription": "This confirmation link is invalid or already used.",
"emailNotVerified": "Please confirm your email before signing in.",
"emailVerifiedBanner": "Email confirmed. You can sign in now.",
"invalidCredentials": "Invalid email or password."
},
"sidebar": {
"notes": "Notes",
@@ -792,6 +809,18 @@
"formal": "Formal",
"casual": "Casual"
},
"interactiveDemo": {
"invalid": "Invalid interactive demo",
"empty": "Interactive demo unavailable",
"step": "step",
"speed": "Playback speed",
"needMoreText": "Select at least ~20 words (or write more content) to generate a demo",
"generating": "Generating interactive demo…",
"generateSuccess": "Interactive demo inserted",
"generateFailed": "Demo generation failed",
"insertFailed": "Could not insert the demo",
"quotaExceeded": "Not enough AI quota"
},
"memoryEcho": {
"title": "I noticed something...",
"description": "Proactive connections between your notes",
@@ -1602,7 +1631,58 @@
"notSynced": "Not synced yet (cron /api/cron/sync-usage)",
"byFeature": "By feature (PostgreSQL)",
"topUsers": "Top users",
"noUsageData": "No usage data for this period yet"
"noUsageData": "No usage data for this period yet",
"healthTitle": "Stripe health check",
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
"healthSecret": "Secret key (server)",
"healthPublishable": "Publishable key",
"healthWebhook": "Webhook secret",
"healthBillingFlag": "Billing enabled",
"healthTrial": "Free trial",
"trialDaysValue": "{days} days on first checkout",
"modeTest": "Test mode (sk_test_…)",
"modeLive": "Live mode (sk_live_…)",
"modePlaceholder": "Placeholder / invalid key",
"modeMissing": "Not configured",
"configured": "Configured",
"missing": "Missing",
"enabled": "Enabled",
"disabled": "Disabled",
"priceStatusTitle": "Price IDs vs Stripe",
"colKey": "Plan",
"colPriceId": "Price ID",
"colSource": "Source",
"colStripe": "Stripe amount",
"priceError": "Lookup failed",
"inactive": "inactive",
"notChecked": "Not checked (no Stripe key)",
"subsTitle": "Subscriptions overview",
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
"statPaid": "Active + trial",
"statTrialing": "On trial",
"statPastDue": "Past due",
"statCanceling": "Cancel at period end",
"byTier": "By tier",
"byStatus": "By status",
"usersWithoutSub": "Users with no Subscription row",
"noSubs": "No subscriptions yet",
"recentSubs": "Recent paid / trial accounts",
"colUser": "User",
"colTier": "Tier",
"colStatus": "Status",
"colPeriod": "Period / trial end",
"canceling": "canceling",
"manualTier": "manual (no Stripe sub)",
"trialUntil": "Trial until {date}",
"testGuideTitle": "How to test Stripe locally",
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
},
"dashboard": {
"title": "Dashboard",
@@ -1812,6 +1892,8 @@
"featureBrainstormCreate": "Brainstorm creations",
"featureBrainstormSessions": "Brainstorm sessions",
"featureCharts": "AI Charts",
"featureInteractiveDemo": "Interactive demos",
"featureInteractivePage": "Interactive pages",
"featurePublishEnhance": "AI Publishing",
"featureBrainstormExpand": "Brainstorm expansions",
"featureBrainstormEnrich": "Brainstorm enrichments",
@@ -2579,6 +2661,8 @@
"slashTableDesc": "Insert a simple grid",
"slashDatabase": "Structured View",
"slashDatabaseDesc": "Embed your notebook's structured data",
"slashInteractiveDemo": "Interactive Demo",
"slashInteractiveDemoDesc": "AI step-by-step teaching demo (Play / Step)",
"slashToggle": "Toggle Section",
"slashToggleDesc": "Create a collapsible section",
"slashCallout": "Callout",
@@ -2726,6 +2810,21 @@
"publishTemplateMagazine": "Magazine article",
"publishTemplateBrief": "Expert brief",
"publishTemplateEssay": "Essay",
"publishTemplateInteractivePage": "Interactive page",
"publishInteractivePage": "Interactive page",
"publishInteractivePageHint": "AI-generated page: hero, sections, Play/Step demos — 20 credits",
"publishInteractivePageGenerating": "Building interactive page…",
"publishInteractivePageGeneratingWait": "Almost ready…",
"publishInteractivePageGeneratingLong": "Still working…",
"publishInteractivePagePlanning": "Analyzing content — planning the page…",
"publishInteractivePageSectionProgress": "Section {current}/{total}: {title}",
"publishInteractivePageFallback": "AI generation unavailable — showing simplified page",
"publishInteractivePagePartialFallback": "Some sections were generated in simplified mode",
"publishInteractivePageTooShort": "Note too short — add more content and try again",
"publishInteractivePageSuccess": "Interactive page published!",
"publishInteractivePageFailed": "Interactive page generation failed",
"publishInteractivePagePreviewHint": "Preview — review, then publish to the public URL",
"publishInteractivePageConfirm": "Publish page",
"publishAiSuccess": "AI-enhanced page published!",
"publishRewriteLabel": "Rewrite for the web",
"publishRewriteOnHint": "Structures your editor blocks (exercises, toggles, callouts) for the web — AI writes only the intro",
@@ -3381,7 +3480,11 @@
"fetchStatusFailed": "Failed to fetch billing status",
"fetchQuotasFailed": "Failed to load credit usage",
"fetchInvoicesFailed": "Failed to load billing history.",
"savePercent": "Save ~17%"
"savePercent": "Save ~17%",
"startTrialCta": "Start {days}-day free trial",
"trialFeature": "{days}-day free trial (card required)",
"trialEndsOn": "Your free trial ends on {date}. You will then be billed automatically.",
"trialEndsLabel": "Trial ends"
},
"quotaPaywall": {
"title": "Out of AI credits",
@@ -3528,6 +3631,12 @@
"perMonthAnnual": "/mo, billed yearly",
"perUser": "+ €3.90/user",
"perUserAnnual": "+ €2.90/user, yearly",
"savePercent": "Save ~17%",
"proMonthly": "€9.90",
"proAnnualMonthly": "€8.25",
"businessMonthly": "€29.90",
"businessAnnualMonthly": "€24.92",
"enterprisePrice": "Custom",
"popular": "Most chosen",
"basic": {
"name": "Basic",
@@ -3572,7 +3681,10 @@
"feature4": "Dedicated support",
"feature5": "Live onboarding"
},
"basicPrice": "Free"
"basicPrice": "Free",
"trialBadge": "{days}-day free trial",
"trialFeature": "{days}-day free trial (card required)",
"trialCta": "Start {days}-day free trial"
},
"cta": {
"title": "Stop losing your best ideas.",

View File

@@ -38,7 +38,24 @@
"createYourSpaceSubtitle": "Rejoignez la nouvelle ère de la prise de notes intelligente.",
"forgot": "Oublié ?",
"backToSite": "Retour",
"privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions"
"privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions",
"checkEmailTitle": "Vérifiez votre e-mail",
"checkEmailDescription": "Nous avons envoyé un lien de confirmation à {email}. Ouvrez-le pour activer votre compte avant de vous connecter.",
"checkEmailDescriptionGeneric": "Nous avons envoyé un lien de confirmation à votre e-mail. Ouvrez-le pour activer votre compte avant de vous connecter.",
"resendVerification": "Renvoyer le-mail de confirmation",
"verifyResent": "E-mail de confirmation envoyé. Vérifiez votre boîte de réception.",
"verifyResendFailed": "Impossible denvoyer le-mail de confirmation. Réessayez plus tard.",
"verifyMissingEmail": "Saisissez votre adresse e-mail.",
"verifyLoading": "Confirmation de votre e-mail…",
"verifySuccessTitle": "E-mail confirmé",
"verifySuccessDescription": "Votre compte est prêt. Vous pouvez vous connecter.",
"verifyExpiredTitle": "Lien expiré",
"verifyExpiredDescription": "Ce lien de confirmation a expiré. Demandez-en un nouveau.",
"verifyInvalidTitle": "Lien invalide",
"verifyInvalidDescription": "Ce lien de confirmation est invalide ou déjà utilisé.",
"emailNotVerified": "Confirmez votre e-mail avant de vous connecter.",
"emailVerifiedBanner": "E-mail confirmé. Vous pouvez vous connecter.",
"invalidCredentials": "E-mail ou mot de passe incorrect."
},
"sidebar": {
"notes": "Notes",
@@ -798,6 +815,18 @@
"formal": "Formel",
"casual": "Décontracté"
},
"interactiveDemo": {
"invalid": "Démo interactive invalide",
"empty": "Démo interactive indisponible",
"step": "étape",
"speed": "Vitesse de lecture",
"needMoreText": "Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo",
"generating": "Génération de la démo interactive…",
"generateSuccess": "Démo interactive insérée",
"generateFailed": "Échec de la génération",
"insertFailed": "Impossible dinsérer la démo",
"quotaExceeded": "Quota IA insuffisant"
},
"memoryEcho": {
"title": "💡 J'ai remarqué quelque chose...",
"description": "Connexions proactives entre vos notes",
@@ -1608,7 +1637,58 @@
"notSynced": "Pas encore synchronisé (cron /api/cron/sync-usage)",
"byFeature": "Par fonctionnalité (PostgreSQL)",
"topUsers": "Utilisateurs les plus actifs",
"noUsageData": "Aucune donnée pour cette période"
"noUsageData": "Aucune donnée pour cette période",
"healthTitle": "État Stripe",
"healthDescription": "Statut des clés, webhooks, price IDs et flag facturation (les secrets ne sont jamais affichés).",
"healthSecret": "Clé secrète (serveur)",
"healthPublishable": "Clé publique",
"healthWebhook": "Secret webhook",
"healthBillingFlag": "Facturation activée",
"healthTrial": "Essai gratuit",
"trialDaysValue": "{days} jours au premier checkout",
"modeTest": "Mode test (sk_test_…)",
"modeLive": "Mode live (sk_live_…)",
"modePlaceholder": "Clé placeholder / invalide",
"modeMissing": "Non configurée",
"configured": "Configuré",
"missing": "Manquant",
"enabled": "Activé",
"disabled": "Désactivé",
"priceStatusTitle": "Price IDs vs Stripe",
"colKey": "Offre",
"colPriceId": "Price ID",
"colSource": "Source",
"colStripe": "Montant Stripe",
"priceError": "Échec lecture",
"inactive": "inactif",
"notChecked": "Non vérifié (pas de clé Stripe)",
"subsTitle": "Vue des abonnements",
"subsDescription": "Compteurs depuis la base locale (synchronisée via webhooks Stripe).",
"statPaid": "Actifs + essai",
"statTrialing": "En essai",
"statPastDue": "Impayés",
"statCanceling": "Résiliation fin de période",
"byTier": "Par tier",
"byStatus": "Par statut",
"usersWithoutSub": "Utilisateurs sans ligne Subscription",
"noSubs": "Aucun abonnement pour linstant",
"recentSubs": "Comptes payants / essai récents",
"colUser": "Utilisateur",
"colTier": "Tier",
"colStatus": "Statut",
"colPeriod": "Période / fin dessai",
"canceling": "résiliation",
"manualTier": "manuel (pas de sub Stripe)",
"trialUntil": "Essai jusquau {date}",
"testGuideTitle": "Comment tester Stripe en local",
"testGuideDescription": "Checklist pour valider checkout, webhooks et essai.",
"testStep1": "Stripe Dashboard → mode Test ON. Créer produits Pro/Business + prix mensuel/annuel + packs crédits.",
"testStep2": "Mettre sk_test_…, pk_test_… dans .env. Mettre les price_… dans Admin → Facturation (ou env) et activer la facturation.",
"testStep3": "Copier le whsec_… dans STRIPE_WEBHOOK_SECRET et redémarrer lapp.",
"testStep4": "npm run dev → ouvrir /settings/billing avec un compte BASIC.",
"testStep5": "Lancer le checkout Pro. Carte : 4242 4242 4242 4242, date future, CVC quelconque. Essai 7 jours attendu.",
"testStep6": "Vérifier Admin → Facturation (TRIALING) et /settings/billing (date de fin dessai).",
"testCardHint": "Autres cartes : 4000000000009995 = échec paiement · 4000002500003155 = 3D Secure. Jamais de vraie carte en mode test."
},
"dashboard": {
"title": "Tableau de bord",
@@ -1818,6 +1898,8 @@
"featureBrainstormCreate": "Créations brainstorm",
"featureBrainstormSessions": "Sessions brainstorm",
"featureCharts": "Graphiques IA",
"featureInteractiveDemo": "Démos interactives",
"featureInteractivePage": "Pages interactives",
"featurePublishEnhance": "Publication IA",
"featureBrainstormExpand": "Extensions brainstorm",
"featureBrainstormEnrich": "Enrichissements brainstorm",
@@ -2585,6 +2667,8 @@
"slashTableDesc": "Insérer un tableau simple",
"slashDatabase": "Vue structurée",
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
"slashInteractiveDemo": "Démo interactive",
"slashInteractiveDemoDesc": "Démo pédagogique IA étape par étape (Play / Step)",
"slashToggle": "Section repliable",
"slashToggleDesc": "Créer une section dépliable",
"slashCallout": "Encadré",
@@ -2732,6 +2816,21 @@
"publishTemplateMagazine": "Article magazine",
"publishTemplateBrief": "Fiche expert",
"publishTemplateEssay": "Essai",
"publishTemplateInteractivePage": "Page interactive",
"publishInteractivePage": "Page interactive",
"publishInteractivePageHint": "Page générée par IA : hero, sections, démos Play/Step — 20 crédits",
"publishInteractivePageGenerating": "Construction de la page…",
"publishInteractivePageGeneratingWait": "Presque prêt…",
"publishInteractivePageGeneratingLong": "Toujours en cours…",
"publishInteractivePagePlanning": "Analyse du contenu — plan de la page…",
"publishInteractivePageSectionProgress": "Section {current}/{total} : {title}",
"publishInteractivePageFallback": "Génération IA indisponible — page simplifiée affichée",
"publishInteractivePagePartialFallback": "Certaines sections ont été générées en mode simplifié",
"publishInteractivePageTooShort": "Note trop courte — ajoutez du contenu puis réessayez",
"publishInteractivePageSuccess": "Page interactive publiée !",
"publishInteractivePageFailed": "Échec de la page interactive",
"publishInteractivePagePreviewHint": "Aperçu — vérifiez puis publiez sur lURL publique",
"publishInteractivePageConfirm": "Publier la page",
"publishAiSuccess": "Page publiée avec mise en page IA !",
"publishRewriteLabel": "Reformuler pour le web",
"publishRewriteOnHint": "Structure vos blocs éditeur (exercices, toggles, encadrés) en page web — l'IA rédige seulement le chapô",
@@ -3387,7 +3486,11 @@
"fetchStatusFailed": "Échec du chargement des informations de facturation",
"fetchQuotasFailed": "Échec du chargement des crédits",
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
"savePercent": "Économisez ~17%"
"savePercent": "Économisez ~17%",
"startTrialCta": "Essai gratuit {days} jours",
"trialFeature": "Essai gratuit {days} jours (carte requise)",
"trialEndsOn": "Votre essai gratuit se termine le {date}. Vous serez ensuite facturé automatiquement.",
"trialEndsLabel": "Fin de l'essai"
},
"quotaPaywall": {
"title": "Plus de crédits IA",
@@ -3534,6 +3637,12 @@
"perMonthAnnual": "/mois, facturé à l'année",
"perUser": "+ 3,90€/user",
"perUserAnnual": "+ 2,90€/user, à l'année",
"savePercent": "~17 %",
"proMonthly": "9,90€",
"proAnnualMonthly": "8,25€",
"businessMonthly": "29,90€",
"businessAnnualMonthly": "24,92€",
"enterprisePrice": "Sur devis",
"popular": "Le plus choisi",
"basic": {
"name": "Basic",
@@ -3578,7 +3687,10 @@
"feature4": "Support dédié",
"feature5": "Onboarding live"
},
"basicPrice": "Gratuit"
"basicPrice": "Gratuit",
"trialBadge": "Essai gratuit {days} jours",
"trialFeature": "Essai gratuit {days} jours (carte requise)",
"trialCta": "Essayer {days} jours gratuitement"
},
"cta": {
"title": "Arrêtez de perdre vos meilleures idées.",

View File

@@ -0,0 +1,274 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'vitest'
import {
assertTranslatePreservesStructure,
heatmapCellIds,
resolveActFinalState,
validateInteractiveDemo,
type InteractiveDemoV1,
} from '../../lib/interactive-demo'
const fixturePath = join(
__dirname,
'../../lib/interactive-demo/fixtures/attnres.demo.json'
)
function loadFixture(): InteractiveDemoV1 {
return JSON.parse(readFileSync(fixturePath, 'utf8')) as InteractiveDemoV1
}
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T
}
describe('validateInteractiveDemo', () => {
test('AttnRes fixture passes (positive)', () => {
const result = validateInteractiveDemo(loadFixture())
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.demo.id).toBe('demo.attnres')
expect(result.demo.lang).toBe('fr')
expect(result.demo.schemaVersion).toBe(1)
}
})
test('allows hex mentioned in human speak (no false positive)', () => {
const demo = clone(loadFixture())
demo.acts[0].steps[0].speak =
'La couleur #FF0000 signale l\'erreur ; aussi rgb(20,20,20).'
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(true)
})
test('rejects hex color in non-human structural field', () => {
const demo = clone(loadFixture())
// Force a structural string via chartType bypass — use node id? ids can't be hex easily.
// Inject forbidden color into a future-proof structural key by mutating after parse path:
// Put hex in edge style is enum-only; use disclaimer is human.
// Put on a custom field that round-trips through JSON under panels payload as unknown — Zod strips unknown.
// Use annotate text is human. So inject via re-validate raw object with hex in `id` of a node:
demo.scene.panels[0] = {
id: 'panel.trunk',
type: 'svg-scene',
payload: {
nodes: [
{ id: 'residualTrunk', label: 'ok' },
{ id: '#ff0000', label: 'bad id that is also hex-like' },
],
},
}
// Wait — id "#ff0000" matches FORBIDDEN_COLOR_RE when scanning the id string.
// But refs will also break. Better: add hex only as scanned string on a kept structural key.
// chartType is enum. triangular is enum.
// Scan walks all non-human strings — node `id` is structural.
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'forbidden_color')).toBe(true)
}
})
test('rejects unknown pattern', () => {
const demo = clone(loadFixture())
// @ts-expect-error intentional invalid pattern
demo.acts[0].steps[0].pattern = 'zoomIntoDetail'
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.path.includes('pattern'))).toBe(true)
}
})
test('rejects unknown chartType', () => {
const demo = clone(loadFixture())
const panel = demo.scene.panels[1]
if (panel.type === 'chart') {
// @ts-expect-error intentional
panel.payload.chartType = 'pie3D'
}
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.path.includes('chartType'))).toBe(true)
}
})
test('rejects pointTo to missing element id', () => {
const demo = clone(loadFixture())
demo.acts[0].steps[0].pointTo = ['doesNotExist']
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'unknown_element_id')).toBe(true)
}
})
test('rejects step id not matching act.id.sN', () => {
const demo = clone(loadFixture())
demo.acts[0].steps[0].id = 's1'
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'step_id_format')).toBe(true)
}
})
test('rejects more than 5 scenes', () => {
const demo = clone(loadFixture())
const extraScene = clone(demo.scene)
for (let i = 0; i < 4; i++) {
demo.acts.push({
id: `a${10 + i}`,
title: `Extra ${i}`,
scene: { ...extraScene, id: `scene.extra${i}` },
steps: [
{
id: `a${10 + i}.s1`,
speak: 'Extra step for scene cap.',
pattern: 'overview',
},
],
})
}
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'too_many_scenes')).toBe(true)
}
})
test('rejects more than 12 steps per act', () => {
const demo = clone(loadFixture())
const act = demo.acts[0]
while (act.steps.length < 13) {
const n = act.steps.length + 1
act.steps.push({
id: `a1.s${n}`,
speak: `Étape de remplissage ${n}.`,
pattern: 'overview',
})
}
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(
result.issues.some(
(i) =>
i.path.includes('steps') ||
i.message.toLowerCase().includes('array') ||
i.code === 'too_big'
)
).toBe(true)
}
})
test('rejects annotate that overlaps pointTo in same step', () => {
const demo = clone(loadFixture())
demo.acts[0].steps[0].annotate = [
{
kind: 'arrow',
targetIds: ['e.L1.h'],
scope: 'act',
},
]
const result = validateInteractiveDemo(demo)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'annotate_pointto_overlap')).toBe(
true
)
}
})
})
describe('assertTranslatePreservesStructure', () => {
test('accepts lang + speak change with same ids', () => {
const source = loadFixture()
const translated = clone(source)
translated.lang = 'en'
translated.acts[0].title = 'The Problem — Depth Dilution'
translated.acts[0].steps[0].speak =
'Each layer enters the residual trunk with a **fixed ×1 coefficient**.'
const result = assertTranslatePreservesStructure(source, translated)
expect(result.ok).toBe(true)
})
test('rejects translated variant that mutates an element id', () => {
const source = loadFixture()
const translated = clone(source)
translated.lang = 'en'
translated.scene.panels[0] = {
...translated.scene.panels[0],
type: 'svg-scene',
payload: {
nodes: [
{ id: 'residualTrunkMUTATED', label: 'Residual trunk' },
{ id: 'L1', label: 'Layer 1' },
{ id: 'L2', label: 'Layer 2' },
],
edges: [
{
id: 'e.L1.h',
from: 'L1',
to: 'residualTrunkMUTATED',
style: 'solid',
weight: 1,
intent: 'flow',
},
],
},
}
translated.acts[0].steps[0].pointTo = ['residualTrunkMUTATED', 'e.L1.h']
const result = assertTranslatePreservesStructure(source, translated)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe(
true
)
}
})
test('rejects mutation of unknown structural string key (default-keep)', () => {
const source = clone(loadFixture()) as InteractiveDemoV1 & {
scene: InteractiveDemoV1['scene'] & { layout?: string }
}
const translated = clone(source) as typeof source
// Simulate a future structural field present on both, then mutated on translate
;(source.scene as { layout?: string }).layout = 'horizontal'
;(translated.scene as { layout?: string }).layout = 'vertical'
translated.lang = 'en'
const result = assertTranslatePreservesStructure(
source as InteractiveDemoV1,
translated as InteractiveDemoV1
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe(
true
)
}
})
})
describe('resolveInteractiveDemo', () => {
test('heatmap lower-triangular has 36 addressable cells', () => {
expect(heatmapCellIds(8, 8, 'lower')).toHaveLength(36)
})
test('a2 final state after s7: 36 cells revealed + 4 badges', () => {
const demo = loadFixture()
const final = resolveActFinalState(demo, 'a2')
expect(final).toBeDefined()
const revealedIds = Object.keys(final!.revealed)
expect(revealedIds).toHaveLength(36)
expect(revealedIds.every((id) => /^r\d+\.c\d+$/.test(id))).toBe(true)
const badges = final!.annotations.filter((a) => a.kind === 'badge')
expect(badges).toHaveLength(4)
expect(badges.map((b) => b.badgeIndex)).toEqual([1, 2, 3, 4])
expect(badges.map((b) => b.targetIds[0]).sort()).toEqual(
['r2.c2', 'r5.c4', 'r7.c1', 'r8.c1'].sort()
)
})
})

View File

@@ -0,0 +1,124 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'vitest'
import {
validateInteractivePage,
type PageSpecV1,
} from '../../lib/interactive-page'
const fixturePath = join(
__dirname,
'../../lib/interactive-page/fixtures/thermo-page.json'
)
function loadFixture(): PageSpecV1 {
return JSON.parse(readFileSync(fixturePath, 'utf8')) as PageSpecV1
}
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T
}
describe('validateInteractivePage', () => {
test('thermo fixture passes (positive)', () => {
const result = validateInteractivePage(loadFixture())
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.page.id).toBe('page.thermo-test')
expect(result.page.schemaVersion).toBe(1)
expect(result.page.sections.length).toBeGreaterThanOrEqual(3)
}
})
test('rejects unknown block type', () => {
const page = clone(loadFixture()) as Record<string, unknown>
const sections = page.sections as Array<{ blocks: unknown[] }>
sections[0].blocks.push({ type: 'carousel', items: [] })
const result = validateInteractivePage(page)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(
result.issues.some(
(i) =>
i.path.includes('blocks') ||
i.code === 'invalid_union_discriminator' ||
i.message.toLowerCase().includes('discriminat')
)
).toBe(true)
}
})
test('rejects hex color in non-human structural field', () => {
const page = clone(loadFixture())
// intent is enum — inject hex via overview card by forcing raw object
const raw = clone(loadFixture()) as Record<string, unknown>
const overview = raw.overview as {
cards: Array<Record<string, unknown>>
}
overview.cards[0].intent = '#ff0000'
const result = validateInteractivePage(raw)
expect(result.ok).toBe(false)
})
test('rejects duplicate section id', () => {
const page = clone(loadFixture())
page.sections[1].id = page.sections[0].id
const result = validateInteractivePage(page)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'duplicate_section_id')).toBe(
true
)
}
})
test('rejects invalid embedded demo (delegation)', () => {
const page = clone(loadFixture())
const demoBlock = page.sections
.flatMap((s) => s.blocks)
.find((b) => b.type === 'demo')
expect(demoBlock?.type).toBe('demo')
if (demoBlock?.type === 'demo') {
// @ts-expect-error intentional
demoBlock.demo.acts[0].steps[0].pattern = 'notARealPattern'
}
const result = validateInteractivePage(page)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(
result.issues.some(
(i) =>
i.path.includes('demo') ||
i.code.startsWith('demo_') ||
i.path.includes('pattern')
)
).toBe(true)
}
})
test('rejects table row width mismatch', () => {
const page = clone(loadFixture())
const table = page.sections
.flatMap((s) => s.blocks)
.find((b) => b.type === 'table')
expect(table?.type).toBe('table')
if (table?.type === 'table') {
table.rows[0] = ['only-one']
}
const result = validateInteractivePage(page)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.issues.some((i) => i.code === 'table_shape')).toBe(true)
}
})
test('allows hex mentioned in human prose', () => {
const page = clone(loadFixture())
const prose = page.sections[0].blocks.find((b) => b.type === 'prose')
if (prose?.type === 'prose') {
prose.md = 'La couleur #FF0000 est un signal dalarme.'
}
const result = validateInteractivePage(page)
expect(result.ok).toBe(true)
})
})