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