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:
76
memento-note/lib/interactive-demo/constants.ts
Normal file
76
memento-note/lib/interactive-demo/constants.ts
Normal 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 ≈ 1–2 sentences, ~25 words max — tempo is decided at writing time.
|
||||
*/
|
||||
export const SPEAK_WRITING_RULE =
|
||||
'speak ≈ 1–2 phrases, ~25 mots max — le tempo de la démo se décide à l’écriture, pas au rendu.'
|
||||
469
memento-note/lib/interactive-demo/fixtures/attnres.demo.json
Normal file
469
memento-note/lib/interactive-demo/fixtures/attnres.demo.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
memento-note/lib/interactive-demo/index.ts
Normal file
40
memento-note/lib/interactive-demo/index.ts
Normal 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'
|
||||
62
memento-note/lib/interactive-demo/intent-colors.ts
Normal file
62
memento-note/lib/interactive-demo/intent-colors.ts
Normal 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: '1–2',
|
||||
note: 'Demo tempo is decided at writing time, not at render.',
|
||||
} as const
|
||||
561
memento-note/lib/interactive-demo/normalize.ts
Normal file
561
memento-note/lib/interactive-demo/normalize.ts
Normal 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
|
||||
}
|
||||
227
memento-note/lib/interactive-demo/resolve.ts
Normal file
227
memento-note/lib/interactive-demo/resolve.ts
Normal 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
|
||||
}
|
||||
136
memento-note/lib/interactive-demo/schema.ts
Normal file
136
memento-note/lib/interactive-demo/schema.ts
Normal 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 }
|
||||
116
memento-note/lib/interactive-demo/types.ts
Normal file
116
memento-note/lib/interactive-demo/types.ts
Normal 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[] }
|
||||
452
memento-note/lib/interactive-demo/validate.ts
Normal file
452
memento-note/lib/interactive-demo/validate.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user