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