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