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:
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
23
memento-note/app/(main)/dev/interactive-page/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage } from '@/lib/interactive-page'
|
||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||
|
||||
/**
|
||||
* Dev preview of PageView + thermo fixture (spec §8.3).
|
||||
* Only available outside production.
|
||||
*/
|
||||
export default function InteractivePagePreviewPage() {
|
||||
if (process.env.NODE_ENV === 'production') notFound()
|
||||
|
||||
const parsed = validateInteractivePage(thermoFixture)
|
||||
if (!parsed.ok) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-destructive">
|
||||
Fixture invalide : {parsed.issues[0]?.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageView page={parsed.page} demoMode="interactive" />
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
import { ReadingProgress } from '@/components/publish/reading-progress'
|
||||
import { CopyLinkButton } from '@/components/publish/copy-link-button'
|
||||
import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
|
||||
import { isInteractivePageTemplate } from '@/lib/publish/types'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
if (
|
||||
isInteractivePageTemplate(note.publishedTemplate) &&
|
||||
note.publishedContent
|
||||
) {
|
||||
return (
|
||||
<InteractivePublishedPage
|
||||
publishedContent={note.publishedContent}
|
||||
isStale={isStale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const usesAiLayout = Boolean(note.publishedContent)
|
||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||
|
||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const readingTime = estimateReadingTime(readingSource)
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||
|
||||
|
||||
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
125
memento-note/app/api/ai/interactive-demo/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
|
||||
|
||||
export const maxDuration = 180
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(20),
|
||||
selection: z.string().optional().nullable(),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
})
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = requestSchema.parse(body)
|
||||
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
|
||||
const sourceRaw = parsed.selection?.trim() || parsed.content
|
||||
const sourcePlain = stripHtml(sourceRaw)
|
||||
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const lang = parsed.lang || 'fr'
|
||||
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
|
||||
const provider = getSlidesProvider(config)
|
||||
|
||||
const result = await generateInteractiveDemoFromContent({
|
||||
content: sourceRaw,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const first = result.issues[0]
|
||||
const detail = first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: ''
|
||||
console.error(
|
||||
'[/api/ai/interactive-demo] validation failed after repairs',
|
||||
result.issues.slice(0, 10)
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: detail
|
||||
? `Démo invalide après correction — ${detail}`
|
||||
: 'La génération a produit un JSON invalide après correction',
|
||||
issues: result.issues.slice(0, 10),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
demo: result.demo,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive demo'
|
||||
console.error('[/api/ai/interactive-demo]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
181
memento-note/app/api/ai/interactive-page/route.ts
Normal file
181
memento-note/app/api/ai/interactive-page/route.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||
import {
|
||||
generatePagePlan,
|
||||
generatePageSection,
|
||||
} from '@/lib/ai/services/interactive-page-llm.service'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
const sectionPlanSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
goal: z.string().min(1),
|
||||
demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
||||
demoGoal: z.string().optional(),
|
||||
})
|
||||
|
||||
const requestSchema = z.object({
|
||||
/** undefined = legacy deterministic full-page (fallback path) */
|
||||
action: z.enum(['plan', 'section']).optional(),
|
||||
content: z.string().min(40),
|
||||
lang: z.string().optional(),
|
||||
noteId: z.string().optional(),
|
||||
notebookId: z.string().optional(),
|
||||
pageTitle: z.string().optional(),
|
||||
sectionId: z.string().optional(),
|
||||
section: sectionPlanSchema.optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const parsed = requestSchema.parse(body)
|
||||
const wordCount = parsed.content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 40) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getSlidesProvider(config)
|
||||
const lang = parsed.lang || 'fr'
|
||||
|
||||
// ── LLM plan (billed: page = 20 crédits, spec §8.5) ──────────────────
|
||||
if (parsed.action === 'plan') {
|
||||
try {
|
||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return NextResponse.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
if (
|
||||
err instanceof QuotaServiceUnavailableError ||
|
||||
process.env.NODE_ENV === 'production'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
const result = await generatePagePlan({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error, reason: result.reason, attempts: result.attempts },
|
||||
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── LLM single section (already billed at plan time) ────────────────
|
||||
if (parsed.action === 'section') {
|
||||
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
||||
return NextResponse.json(
|
||||
{ error: 'section, sectionId and pageTitle are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const result = await generatePageSection({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
pageTitle: parsed.pageTitle,
|
||||
sectionId: parsed.sectionId,
|
||||
section: parsed.section,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'section_generation_failed',
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ section: result.section, attempts: result.attempts })
|
||||
}
|
||||
|
||||
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
|
||||
const result = await generateInteractivePageFromContent({
|
||||
content: parsed.content,
|
||||
lang,
|
||||
provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error === 'unsuitable_content') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'unsuitable_content',
|
||||
reason: result.reason,
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
const first = result.issues?.[0]
|
||||
const reason =
|
||||
result.reason ||
|
||||
(first
|
||||
? `${first.path ? first.path + ': ' : ''}${first.message}`
|
||||
: undefined)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
result.error === 'timeout'
|
||||
? 'La génération a pris trop de temps — réessayez'
|
||||
: reason ||
|
||||
'La génération a produit une page invalide',
|
||||
reason,
|
||||
issues: result.issues?.slice(0, 12),
|
||||
attempts: result.attempts,
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
page: result.page,
|
||||
attempts: result.attempts,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
||||
console.error('[/api/ai/interactive-page]', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
|
||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
||||
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
|
||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||
import { validateInteractivePage } from '@/lib/interactive-page'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
||||
|
||||
const MODERATION_TIMEOUT_MS = 12_000
|
||||
|
||||
@@ -93,13 +98,14 @@ export async function POST(request: NextRequest) {
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const { noteId, action, mode, template, language, rewrite } = body as {
|
||||
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
|
||||
noteId?: string
|
||||
action?: string
|
||||
mode?: 'simple' | 'ai'
|
||||
mode?: 'simple' | 'ai' | 'interactive-page'
|
||||
template?: string
|
||||
language?: string
|
||||
rewrite?: boolean
|
||||
pageSpec?: unknown
|
||||
}
|
||||
|
||||
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
@@ -111,13 +117,96 @@ export async function POST(request: NextRequest) {
|
||||
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
if (action === 'publish') {
|
||||
// ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
|
||||
if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
|
||||
let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
|
||||
|
||||
if (!validatedPage?.ok) {
|
||||
// Deterministic generate (no LLM quota)
|
||||
const config = await getSystemConfig()
|
||||
const provider = getSlidesProvider(config)
|
||||
const generated = await generateInteractivePageFromContent({
|
||||
content: note.content || '',
|
||||
lang: language || 'fr',
|
||||
provider,
|
||||
})
|
||||
if (!generated.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: generated.error || 'interactive_page_generation_failed',
|
||||
reason: generated.reason,
|
||||
issues: generated.issues?.slice(0, 12),
|
||||
},
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
validatedPage = { ok: true, page: generated.page }
|
||||
}
|
||||
|
||||
// Guaranteed valid PageSpec after generate-or-validate above
|
||||
if (!validatedPage?.ok) {
|
||||
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
|
||||
}
|
||||
|
||||
const textForModeration = [
|
||||
validatedPage.page.hero.title,
|
||||
validatedPage.page.hero.subtitle,
|
||||
validatedPage.page.overview?.lead,
|
||||
...validatedPage.page.sections.map((s) => s.title),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const moderation = await moderateWithFallback(
|
||||
note.title || '',
|
||||
textForModeration
|
||||
)
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'blocked',
|
||||
reason: moderation.reason,
|
||||
categories: moderation.categories,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (moderation.verdict === 'flagged') {
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
const sourceHash = computePublishedSourceHash(note.content || '')
|
||||
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: JSON.stringify(validatedPage.page),
|
||||
publishedTemplate: 'interactive-page',
|
||||
publishedSourceHash: sourceHash,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'interactive-page',
|
||||
template: 'interactive-page',
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
||||
|
||||
if (publishMode === 'ai') {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return aiConsentForbiddenResponse()
|
||||
}
|
||||
if (!template || !isPublishTemplateId(template)) {
|
||||
if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
|
||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal file
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Render node/callout label: plain lines + inline $KaTeX$. */
|
||||
export function DemoRichLabel({
|
||||
text,
|
||||
className,
|
||||
lit,
|
||||
}: {
|
||||
text: string
|
||||
className?: string
|
||||
lit?: boolean
|
||||
}) {
|
||||
const lines = useMemo(() => {
|
||||
return text.split(/\\n|\n/).map((line) => {
|
||||
const parts: Array<{ type: 'text' | 'math'; value: string }> = []
|
||||
const re = /\$([^$]+)\$/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m.index > last) {
|
||||
parts.push({ type: 'text', value: line.slice(last, m.index) })
|
||||
}
|
||||
parts.push({ type: 'math', value: m[1] || '' })
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < line.length) parts.push({ type: 'text', value: line.slice(last) })
|
||||
if (parts.length === 0) parts.push({ type: 'text', value: line })
|
||||
return parts
|
||||
})
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 text-center leading-snug',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{lines.map((parts, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-center gap-x-1',
|
||||
i === 0
|
||||
? lit
|
||||
? 'text-[13px] font-semibold tracking-tight'
|
||||
: 'text-[12.5px] font-semibold tracking-tight'
|
||||
: 'text-[12px] font-medium opacity-90'
|
||||
)}
|
||||
>
|
||||
{parts.map((p, j) => {
|
||||
if (p.type === 'math') {
|
||||
let html = p.value
|
||||
try {
|
||||
html = katex.renderToString(p.value, {
|
||||
displayMode: false,
|
||||
throwOnError: false,
|
||||
})
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
className="katex-node inline-block [&_.katex]:text-[0.95em]"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <span key={j}>{p.value}</span>
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
948
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal file
948
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useMemo } from 'react'
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import {
|
||||
badgeGlyph,
|
||||
dimOpacity,
|
||||
intentColor,
|
||||
spotlightColor,
|
||||
} from '@/lib/interactive-demo/intent-colors'
|
||||
import type { DemoScene, Panel } from '@/lib/interactive-demo/types'
|
||||
import type {
|
||||
ResolvedAnnotation,
|
||||
StepResolvedState,
|
||||
} from '@/lib/interactive-demo/resolve'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AnnKind = ResolvedAnnotation['kind']
|
||||
|
||||
export type DemoSceneViewProps = {
|
||||
scene: DemoScene
|
||||
state: StepResolvedState
|
||||
reducedMotion?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
type NodePos = {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function elementOpacity(
|
||||
id: string,
|
||||
state: StepResolvedState,
|
||||
dim: number
|
||||
): number {
|
||||
if (!(id in state.revealed)) return 0
|
||||
if (state.overview || state.spotlight.length === 0) return 1
|
||||
return state.spotlight.includes(id) ? 1 : dim
|
||||
}
|
||||
|
||||
function estimateNodeSize(label: string): { w: number; h: number } {
|
||||
const lines = label.split(/\\n|\n/)
|
||||
const hasMath = /\$/.test(label)
|
||||
const visualLen = (s: string) =>
|
||||
s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45))))
|
||||
.length
|
||||
const longest = Math.max(...lines.map(visualLen), 6)
|
||||
const w = Math.min(280, Math.max(128, longest * 7.4 + 40))
|
||||
const h = Math.max(
|
||||
hasMath ? 58 : 48,
|
||||
30 + lines.length * (hasMath ? 24 : 18)
|
||||
)
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
/** Prefer circular layout when edges form a closed loop covering most nodes. */
|
||||
function findCycleOrder(
|
||||
ids: string[],
|
||||
edges: { from: string; to: string }[]
|
||||
): string[] | null {
|
||||
if (ids.length < 3) return null
|
||||
const idSet = new Set(ids)
|
||||
const outs = new Map<string, string[]>()
|
||||
for (const id of ids) outs.set(id, [])
|
||||
for (const e of edges) {
|
||||
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
|
||||
outs.get(e.from)!.push(e.to)
|
||||
}
|
||||
|
||||
for (const start of ids) {
|
||||
const path = [start]
|
||||
const seen = new Set([start])
|
||||
let cur = start
|
||||
while (path.length < ids.length) {
|
||||
const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t))
|
||||
if (nexts.length === 0) break
|
||||
// Prefer continuing a simple cycle (single unused neighbor)
|
||||
const n = nexts[0]!
|
||||
path.push(n)
|
||||
seen.add(n)
|
||||
cur = n
|
||||
}
|
||||
const closes = (outs.get(cur) ?? []).includes(start)
|
||||
if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout strategies:
|
||||
* 1. AttnRes trunk pattern (residualTrunk + layers)
|
||||
* 2. Closed cycle → circular
|
||||
* 3. DAG layered (top → bottom)
|
||||
* 4. Fallback grid
|
||||
*/
|
||||
function layoutNodes(
|
||||
nodes: { id: string; label?: string }[],
|
||||
edges: { from: string; to: string }[]
|
||||
): { positions: Map<string, NodePos>; width: number; height: number } {
|
||||
const sizes = new Map(
|
||||
nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
|
||||
)
|
||||
const positions = new Map<string, NodePos>()
|
||||
const padX = 40
|
||||
const padY = 36
|
||||
const gapX = 52
|
||||
const gapY = 32
|
||||
|
||||
const trunk = nodes.find((n) => n.id === 'residualTrunk')
|
||||
const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk')
|
||||
|
||||
if (trunk && layersOnly.length >= 2) {
|
||||
const layerSizes = layersOnly.map((n) => sizes.get(n.id)!)
|
||||
const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96)
|
||||
const trunkSize = sizes.get(trunk.id)!
|
||||
const contentH =
|
||||
padY * 2 +
|
||||
layerSizes.reduce((acc, s) => acc + s.h, 0) +
|
||||
gapY * Math.max(0, layersOnly.length - 1)
|
||||
const height = Math.max(240, contentH)
|
||||
const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24
|
||||
|
||||
let y = padY
|
||||
layersOnly.forEach((n) => {
|
||||
const s = sizes.get(n.id)!
|
||||
positions.set(n.id, {
|
||||
x: padX + maxLayerW / 2,
|
||||
y: y + s.h / 2,
|
||||
w: s.w,
|
||||
h: s.h,
|
||||
})
|
||||
y += s.h + gapY
|
||||
})
|
||||
positions.set(trunk.id, {
|
||||
x: padX + maxLayerW + gapX + trunkSize.w / 2,
|
||||
y: height / 2,
|
||||
w: trunkSize.w,
|
||||
h: trunkSize.h,
|
||||
})
|
||||
return { positions, width, height }
|
||||
}
|
||||
|
||||
const cycle = findCycleOrder(
|
||||
nodes.map((n) => n.id),
|
||||
edges
|
||||
)
|
||||
if (cycle) {
|
||||
const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128)
|
||||
const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48)
|
||||
const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35)
|
||||
const leftovers = nodes.filter((n) => !cycle.includes(n.id))
|
||||
const leftoverRowH = leftovers.length ? maxH + gapY : 0
|
||||
const width = Math.max(
|
||||
420,
|
||||
padX * 2 + radius * 2 + maxW,
|
||||
padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1)
|
||||
)
|
||||
const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH)
|
||||
const cx = width / 2
|
||||
const cy = padY + radius + maxH / 2
|
||||
cycle.forEach((id, i) => {
|
||||
const s = sizes.get(id)!
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length
|
||||
positions.set(id, {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
w: s.w,
|
||||
h: s.h,
|
||||
})
|
||||
})
|
||||
// Leftover nodes: horizontal row BELOW the ring (never overlapping it)
|
||||
if (leftovers.length) {
|
||||
const totalW =
|
||||
leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) +
|
||||
gapX * (leftovers.length - 1)
|
||||
let x = cx - totalW / 2
|
||||
const y = cy + radius + maxH / 2 + gapY + maxH / 2
|
||||
for (const n of leftovers) {
|
||||
const s = sizes.get(n.id)!
|
||||
positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h })
|
||||
x += s.w + gapX
|
||||
}
|
||||
}
|
||||
return { positions, width, height }
|
||||
}
|
||||
|
||||
// Topological layers (sources at top)
|
||||
const ids = nodes.map((n) => n.id)
|
||||
const idSet = new Set(ids)
|
||||
const indeg = new Map(ids.map((id) => [id, 0]))
|
||||
const outs = new Map(ids.map((id) => [id, [] as string[]]))
|
||||
for (const e of edges) {
|
||||
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
|
||||
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1)
|
||||
outs.get(e.from)!.push(e.to)
|
||||
}
|
||||
|
||||
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0)
|
||||
const order: string[] = []
|
||||
const depth = new Map<string, number>()
|
||||
queue.forEach((id) => depth.set(id, 0))
|
||||
const q = [...queue]
|
||||
while (q.length) {
|
||||
const u = q.shift()!
|
||||
order.push(u)
|
||||
for (const v of outs.get(u) ?? []) {
|
||||
depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1))
|
||||
indeg.set(v, (indeg.get(v) ?? 1) - 1)
|
||||
if ((indeg.get(v) ?? 0) === 0) q.push(v)
|
||||
}
|
||||
}
|
||||
|
||||
const isDag = order.length === ids.length && edges.length > 0
|
||||
|
||||
if (isDag) {
|
||||
const byDepth = new Map<number, string[]>()
|
||||
for (const id of ids) {
|
||||
const d = depth.get(id) ?? 0
|
||||
if (!byDepth.has(d)) byDepth.set(d, [])
|
||||
byDepth.get(d)!.push(id)
|
||||
}
|
||||
const maxDepth = Math.max(...byDepth.keys(), 0)
|
||||
const rowHeights: number[] = []
|
||||
let width = padX * 2
|
||||
for (let d = 0; d <= maxDepth; d++) {
|
||||
const row = byDepth.get(d) ?? []
|
||||
const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36)
|
||||
rowHeights.push(rowH)
|
||||
const rowW =
|
||||
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
|
||||
gapX * Math.max(0, row.length - 1)
|
||||
width = Math.max(width, padX * 2 + rowW)
|
||||
}
|
||||
const height =
|
||||
padY * 2 +
|
||||
rowHeights.reduce((a, b) => a + b, 0) +
|
||||
gapY * Math.max(0, maxDepth)
|
||||
|
||||
let y = padY
|
||||
for (let d = 0; d <= maxDepth; d++) {
|
||||
const row = byDepth.get(d) ?? []
|
||||
const rowH = rowHeights[d]!
|
||||
const rowW =
|
||||
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
|
||||
gapX * Math.max(0, row.length - 1)
|
||||
let x = (width - rowW) / 2
|
||||
for (const id of row) {
|
||||
const s = sizes.get(id)!
|
||||
positions.set(id, {
|
||||
x: x + s.w / 2,
|
||||
y: y + rowH / 2,
|
||||
w: s.w,
|
||||
h: s.h,
|
||||
})
|
||||
x += s.w + gapX
|
||||
}
|
||||
y += rowH + gapY
|
||||
}
|
||||
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
|
||||
}
|
||||
|
||||
// Grid fallback
|
||||
const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length))))
|
||||
const rows = Math.ceil(nodes.length / cols)
|
||||
const colW: number[] = Array.from({ length: cols }, (_, c) => {
|
||||
let max = 120
|
||||
nodes.forEach((n, i) => {
|
||||
if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w)
|
||||
})
|
||||
return max
|
||||
})
|
||||
const rowH: number[] = Array.from({ length: rows }, (_, r) => {
|
||||
let max = 48
|
||||
nodes.forEach((n, i) => {
|
||||
if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h)
|
||||
})
|
||||
return max
|
||||
})
|
||||
const width =
|
||||
padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1)
|
||||
const height =
|
||||
padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1)
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const c = i % cols
|
||||
const r = Math.floor(i / cols)
|
||||
const s = sizes.get(n.id)!
|
||||
const xOff =
|
||||
padX +
|
||||
colW.slice(0, c).reduce((a, b) => a + b, 0) +
|
||||
gapX * c +
|
||||
colW[c]! / 2
|
||||
const yOff =
|
||||
padY +
|
||||
rowH.slice(0, r).reduce((a, b) => a + b, 0) +
|
||||
gapY * r +
|
||||
rowH[r]! / 2
|
||||
positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h })
|
||||
})
|
||||
|
||||
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
|
||||
}
|
||||
|
||||
/** Border intersection: line from center A → center B, clipped to rect A. */
|
||||
function borderPoint(
|
||||
from: NodePos,
|
||||
to: NodePos
|
||||
): { x: number; y: number } {
|
||||
const dx = to.x - from.x
|
||||
const dy = to.y - from.y
|
||||
if (dx === 0 && dy === 0) return { x: from.x, y: from.y }
|
||||
const hw = from.w / 2
|
||||
const hh = from.h / 2
|
||||
const ax = Math.abs(dx) / (hw || 1)
|
||||
const ay = Math.abs(dy) / (hh || 1)
|
||||
const t = 1 / Math.max(ax, ay)
|
||||
return { x: from.x + dx * t, y: from.y + dy * t }
|
||||
}
|
||||
|
||||
function edgePath(a: NodePos, b: NodePos, tipInset = 10): string {
|
||||
const p0 = borderPoint(a, b)
|
||||
const p1 = borderPoint(b, a)
|
||||
const dx = p1.x - p0.x
|
||||
const dy = p1.y - p0.y
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
// Stop short of the target so markerEnd tip lands on the node border, not under the card
|
||||
const inset = Math.min(tipInset, len * 0.35)
|
||||
const endX = p1.x - (dx / len) * inset
|
||||
const endY = p1.y - (dy / len) * inset
|
||||
const mx = (p0.x + endX) / 2
|
||||
const my = (p0.y + endY) / 2
|
||||
const bend = Math.min(36, len * 0.22)
|
||||
const cx = mx - (dy / len) * bend
|
||||
const cy = my + (dx / len) * bend
|
||||
return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}`
|
||||
}
|
||||
|
||||
function AnnotationsOverlay({
|
||||
annotations,
|
||||
getAnchor,
|
||||
dark,
|
||||
markerId,
|
||||
shadowId,
|
||||
}: {
|
||||
annotations: ResolvedAnnotation[]
|
||||
getAnchor: (
|
||||
id: string,
|
||||
kind: AnnKind
|
||||
) => { x: number; y: number } | null
|
||||
dark: boolean
|
||||
markerId: string
|
||||
shadowId?: string
|
||||
}) {
|
||||
const outline = spotlightColor(dark)
|
||||
return (
|
||||
<g className="demo-annotations" pointerEvents="none">
|
||||
{annotations.map((ann, i) => {
|
||||
const target = ann.targetIds[0]
|
||||
if (!target) return null
|
||||
const pos = getAnchor(target, ann.kind)
|
||||
if (!pos) return null
|
||||
const color = intentColor(ann.intent, dark)
|
||||
const key = `${ann.kind}-${target}-${i}`
|
||||
|
||||
if (ann.kind === 'circle') {
|
||||
return (
|
||||
<circle
|
||||
key={key}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={22}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
opacity={0.9}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (ann.kind === 'badge') {
|
||||
const n = ann.badgeIndex ?? i + 1
|
||||
return (
|
||||
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<circle r={11} fill={outline} opacity={0.95} />
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#fff"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
||||
>
|
||||
{badgeGlyph(n)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (ann.kind === 'callout') {
|
||||
const text = ann.text ?? ''
|
||||
const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
|
||||
return (
|
||||
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<rect
|
||||
x={8}
|
||||
y={-14}
|
||||
width={w}
|
||||
height={28}
|
||||
rx={8}
|
||||
fill={dark ? '#1a1a1a' : '#ffffff'}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
filter={shadowId ? `url(#${shadowId})` : undefined}
|
||||
/>
|
||||
<text
|
||||
x={18}
|
||||
y={5}
|
||||
fontSize={12}
|
||||
fontWeight={500}
|
||||
fill={dark ? '#f0f0f0' : '#1a1a1a'}
|
||||
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (ann.kind === 'arrow') {
|
||||
return (
|
||||
<path
|
||||
key={key}
|
||||
d={`M ${pos.x - 28} ${pos.y - 28} L ${pos.x - 6} ${pos.y - 6}`}
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
markerEnd={`url(#${markerId})`}
|
||||
fill="none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function SvgScenePanel({
|
||||
panel,
|
||||
state,
|
||||
reducedMotion,
|
||||
dark,
|
||||
}: {
|
||||
panel: Extract<Panel, { type: 'svg-scene' }>
|
||||
state: StepResolvedState
|
||||
reducedMotion?: boolean
|
||||
dark: boolean
|
||||
}) {
|
||||
const uid = useId().replace(/:/g, '')
|
||||
const nodes = panel.payload.nodes
|
||||
const edges = panel.payload.edges ?? []
|
||||
const dim = dimOpacity(dark)
|
||||
const outline = spotlightColor(dark)
|
||||
const markerId = `demo-arrow-${uid}`
|
||||
const dotsId = `demo-dots-${uid}`
|
||||
|
||||
const { positions, width, height } = useMemo(
|
||||
() => layoutNodes(nodes, edges),
|
||||
[nodes, edges]
|
||||
)
|
||||
|
||||
const transition = reducedMotion
|
||||
? 'none'
|
||||
: 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease'
|
||||
|
||||
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)'
|
||||
const boardBg = dark ? '#0c0e12' : '#f7f5f0'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{
|
||||
background: boardBg,
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={dotsId}
|
||||
width="18"
|
||||
height="18"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1.2" cy="1.2" r="1" fill={gridStroke} />
|
||||
</pattern>
|
||||
<marker
|
||||
id={markerId}
|
||||
markerWidth="10"
|
||||
markerHeight="8"
|
||||
refX="9"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,0 L10,4 L0,8 Z" fill={outline} />
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width={width} height={height} fill={`url(#${dotsId})`} />
|
||||
|
||||
{edges.map((e) => {
|
||||
const a = positions.get(e.from)
|
||||
const b = positions.get(e.to)
|
||||
if (!a || !b) return null
|
||||
const revealed = e.id in state.revealed || state.overview
|
||||
if (!revealed) return null
|
||||
const op = elementOpacity(e.id, state, dim)
|
||||
if (op === 0) return null
|
||||
const lit = state.overview || state.spotlight.includes(e.id)
|
||||
const stroke = intentColor(e.intent, dark)
|
||||
const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45
|
||||
return (
|
||||
<path
|
||||
key={e.id}
|
||||
d={edgePath(a, b)}
|
||||
stroke={stroke}
|
||||
strokeWidth={widthStroke}
|
||||
strokeDasharray={e.style === 'dashed' ? '7 5' : undefined}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
opacity={op}
|
||||
markerEnd={`url(#${markerId})`}
|
||||
style={{ transition }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<AnnotationsOverlay
|
||||
annotations={state.annotations}
|
||||
dark={dark}
|
||||
markerId={markerId}
|
||||
getAnchor={(id, kind) => {
|
||||
const p = positions.get(id)
|
||||
if (!p) return null
|
||||
if (kind === 'badge') {
|
||||
return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 }
|
||||
}
|
||||
if (kind === 'callout') {
|
||||
return { x: p.x + p.w / 2 - 4, y: p.y }
|
||||
}
|
||||
return { x: p.x, y: p.y }
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{nodes.map((n) => {
|
||||
const p = positions.get(n.id)
|
||||
if (!p) return null
|
||||
const op = elementOpacity(n.id, state, dim)
|
||||
if (op === 0) return null
|
||||
const lit = state.overview || state.spotlight.includes(n.id)
|
||||
const accent = intentColor(n.intent, dark)
|
||||
const label = n.label ?? n.id
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'absolute flex flex-col justify-center rounded-2xl px-3 py-2.5 backdrop-blur-[2px]',
|
||||
dark ? 'bg-[#141820]/ee text-zinc-50' : 'bg-white/95 text-zinc-900'
|
||||
)}
|
||||
style={{
|
||||
left: `${((p.x - p.w / 2) / width) * 100}%`,
|
||||
top: `${((p.y - p.h / 2) / height) * 100}%`,
|
||||
width: `${(p.w / width) * 100}%`,
|
||||
minHeight: `${(p.h / height) * 100}%`,
|
||||
opacity: op,
|
||||
borderStyle: 'solid',
|
||||
borderTopWidth: lit ? 2 : 1.5,
|
||||
borderRightWidth: lit ? 2 : 1.5,
|
||||
borderBottomWidth: lit ? 2 : 1.5,
|
||||
borderLeftWidth: 5,
|
||||
borderTopColor: lit ? outline : accent,
|
||||
borderRightColor: lit ? outline : accent,
|
||||
borderBottomColor: lit ? outline : accent,
|
||||
borderLeftColor: accent,
|
||||
boxShadow: lit
|
||||
? `0 0 0 3px ${outline}33, 0 10px 28px -8px ${outline}66`
|
||||
: dark
|
||||
? '0 8px 24px -10px rgba(0,0,0,0.65)'
|
||||
: '0 8px 22px -10px rgba(15,23,42,0.18)',
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
<DemoRichLabel text={label} lit={lit} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartPanel({
|
||||
panel,
|
||||
state,
|
||||
reducedMotion,
|
||||
dark,
|
||||
}: {
|
||||
panel: Extract<Panel, { type: 'chart' }>
|
||||
state: StepResolvedState
|
||||
reducedMotion?: boolean
|
||||
dark: boolean
|
||||
}) {
|
||||
const series = panel.payload.series
|
||||
const dim = dimOpacity(dark)
|
||||
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
|
||||
const data = useMemo(() => {
|
||||
return Array.from({ length: maxLen }, (_, i) => {
|
||||
const row: Record<string, number | string> = { i: String(i + 1) }
|
||||
for (const s of series) {
|
||||
if (!(s.id in state.revealed)) continue
|
||||
row[s.id] = s.values[i] ?? 0
|
||||
}
|
||||
return row
|
||||
})
|
||||
}, [maxLen, series, state.revealed])
|
||||
|
||||
const Chart =
|
||||
panel.payload.chartType === 'bar'
|
||||
? BarChart
|
||||
: panel.payload.chartType === 'area'
|
||||
? AreaChart
|
||||
: LineChart
|
||||
|
||||
return (
|
||||
<div className="w-full h-52 min-h-[13rem] min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={data} margin={{ top: 12, right: 16, left: 4, bottom: 8 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="i"
|
||||
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
|
||||
width={40}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--border)',
|
||||
background: dark ? '#18181b' : '#fff',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{series.map((s) => {
|
||||
if (!(s.id in state.revealed)) return null
|
||||
const op = elementOpacity(s.id, state, dim)
|
||||
const stroke = intentColor(s.intent, dark)
|
||||
if (panel.payload.chartType === 'bar') {
|
||||
return (
|
||||
<Bar
|
||||
key={s.id}
|
||||
dataKey={s.id}
|
||||
fill={stroke}
|
||||
fillOpacity={op}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (panel.payload.chartType === 'area') {
|
||||
return (
|
||||
<Area
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
fill={stroke}
|
||||
fillOpacity={op * 0.22}
|
||||
strokeOpacity={op}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
strokeOpacity={op}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeatmapPanel({
|
||||
panel,
|
||||
state,
|
||||
reducedMotion,
|
||||
dark,
|
||||
}: {
|
||||
panel: Extract<Panel, { type: 'heatmap-matrix' }>
|
||||
state: StepResolvedState
|
||||
reducedMotion?: boolean
|
||||
dark: boolean
|
||||
}) {
|
||||
const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload
|
||||
const cell = 34
|
||||
const labelW = 44
|
||||
const labelH = 26
|
||||
const w = labelW + cols * cell + 8
|
||||
const h = labelH + rows * cell + 8
|
||||
const transition = reducedMotion ? 'none' : 'opacity 220ms ease'
|
||||
const dim = dimOpacity(dark)
|
||||
const fillIntent = spotlightColor(dark)
|
||||
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
|
||||
|
||||
const cellRect = (r: number, c: number) => ({
|
||||
x: labelW + (c - 1) * cell,
|
||||
y: labelH + (r - 1) * cell,
|
||||
})
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ cx: number; cy: number; trX: number; trY: number }
|
||||
>()
|
||||
for (let r = 1; r <= rows; r++) {
|
||||
for (let c = 1; c <= cols; c++) {
|
||||
if (triangular === 'lower' && c > r) continue
|
||||
if (triangular === 'upper' && c < r) continue
|
||||
const id = `r${r}.c${c}`
|
||||
const { x, y } = cellRect(r, c)
|
||||
map.set(id, {
|
||||
cx: x + cell / 2,
|
||||
cy: y + cell / 2,
|
||||
trX: x + cell - 2,
|
||||
trY: y + 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [rows, cols, triangular])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
|
||||
{colLabels?.map((lab, i) => (
|
||||
<text
|
||||
key={`c-${i}`}
|
||||
x={labelW + i * cell + cell / 2}
|
||||
y={labelH / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontWeight={500}
|
||||
fill={dark ? '#a1a1aa' : '#71717a'}
|
||||
>
|
||||
{lab}
|
||||
</text>
|
||||
))}
|
||||
{rowLabels?.map((lab, i) => (
|
||||
<text
|
||||
key={`r-${i}`}
|
||||
x={labelW / 2}
|
||||
y={labelH + i * cell + cell / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontWeight={500}
|
||||
fill={dark ? '#a1a1aa' : '#71717a'}
|
||||
>
|
||||
{lab}
|
||||
</text>
|
||||
))}
|
||||
{Array.from({ length: rows }, (_, ri) =>
|
||||
Array.from({ length: cols }, (_, ci) => {
|
||||
const r = ri + 1
|
||||
const c = ci + 1
|
||||
if (triangular === 'lower' && c > r) return null
|
||||
if (triangular === 'upper' && c < r) return null
|
||||
const id = `r${r}.c${c}`
|
||||
const { x, y } = cellRect(r, c)
|
||||
const revealed = id in state.revealed
|
||||
const v = values[ri]?.[ci] ?? 0
|
||||
const lit = state.overview || state.spotlight.includes(id)
|
||||
const op = revealed ? elementOpacity(id, state, dim) : 1
|
||||
|
||||
if (!revealed) {
|
||||
return (
|
||||
<rect
|
||||
key={id}
|
||||
x={x + 1.5}
|
||||
y={y + 1.5}
|
||||
width={cell - 3}
|
||||
height={cell - 3}
|
||||
rx={5}
|
||||
fill="none"
|
||||
stroke={ghostStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 2"
|
||||
opacity={0.8}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fillOpacity = 0.14 + v * 0.78
|
||||
const textFill =
|
||||
v > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
|
||||
|
||||
return (
|
||||
<g key={id} opacity={op} style={{ transition }}>
|
||||
<rect
|
||||
x={x + 1.5}
|
||||
y={y + 1.5}
|
||||
width={cell - 3}
|
||||
height={cell - 3}
|
||||
rx={5}
|
||||
fill={fillIntent}
|
||||
fillOpacity={fillOpacity}
|
||||
stroke={lit ? fillIntent : 'transparent'}
|
||||
strokeWidth={lit ? 2 : 0}
|
||||
/>
|
||||
<text
|
||||
x={x + cell / 2}
|
||||
y={y + cell / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
fill={textFill}
|
||||
>
|
||||
{v.toFixed(2)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<AnnotationsOverlay
|
||||
annotations={state.annotations}
|
||||
dark={dark}
|
||||
markerId="demo-arrowhead-hm"
|
||||
getAnchor={(id, kind) => {
|
||||
const p = positions.get(id)
|
||||
if (!p) return null
|
||||
if (kind === 'badge') return { x: p.trX, y: p.trY }
|
||||
if (kind === 'callout') return { x: p.trX + 4, y: p.cy }
|
||||
return { x: p.cx, y: p.cy }
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DemoSceneView({
|
||||
scene,
|
||||
state,
|
||||
reducedMotion,
|
||||
className,
|
||||
}: DemoSceneViewProps) {
|
||||
const dark = useDarkMode()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4',
|
||||
scene.panels.length === 2 ? 'md:grid-cols-2' : 'grid-cols-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{scene.panels.map((panel) => (
|
||||
<div
|
||||
key={panel.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border shadow-sm',
|
||||
dark
|
||||
? 'border-white/10 bg-[#0f1115]'
|
||||
: 'border-black/10 bg-[#faf9f6]'
|
||||
)}
|
||||
>
|
||||
{panel.type === 'svg-scene' && (
|
||||
<SvgScenePanel
|
||||
panel={panel}
|
||||
state={state}
|
||||
reducedMotion={reducedMotion}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{panel.type === 'chart' && (
|
||||
<div className="p-3">
|
||||
<ChartPanel
|
||||
panel={panel}
|
||||
state={state}
|
||||
reducedMotion={reducedMotion}
|
||||
dark={dark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{panel.type === 'heatmap-matrix' && (
|
||||
<div className="p-3">
|
||||
<HeatmapPanel
|
||||
panel={panel}
|
||||
state={state}
|
||||
reducedMotion={reducedMotion}
|
||||
dark={dark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal file
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import katex from 'katex'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
/**
|
||||
* Render demo `speak`: light markdown + inline $KaTeX$ via the note pipeline pieces.
|
||||
*/
|
||||
export function DemoSpeak({ speak, className }: { speak: string; className?: string }) {
|
||||
const html = useMemo(() => {
|
||||
const placeholders: string[] = []
|
||||
const withSlots = speak.replace(/\$([^$]+)\$/g, (_, tex: string) => {
|
||||
const i = placeholders.length
|
||||
try {
|
||||
placeholders.push(
|
||||
katex.renderToString(tex, { displayMode: false, throwOnError: false })
|
||||
)
|
||||
} catch {
|
||||
placeholders.push(tex)
|
||||
}
|
||||
return `%%KATEX${i}%%`
|
||||
})
|
||||
|
||||
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||
placeholders.forEach((frag, i) => {
|
||||
md = md.replace(`%%KATEX${i}%%`, frag)
|
||||
})
|
||||
return sanitizeRichHtml(md)
|
||||
}, [speak])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDarkMode(): boolean {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.documentElement.classList.contains('dark')
|
||||
: false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const check = () =>
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
check()
|
||||
const obs = new MutationObserver(check)
|
||||
obs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
return isDark
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
StepBack,
|
||||
StepForward,
|
||||
} from 'lucide-react'
|
||||
import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
|
||||
import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
|
||||
import {
|
||||
resolveInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4] as const
|
||||
const WPM = 220
|
||||
const STEP_FLOOR_MS = 1500
|
||||
const STEP_CEIL_MS = 6000
|
||||
const ACT_CHANGE_PAUSE_MS = 800
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(mq.matches)
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
function stepDurationMs(speak: string, speed: number): number {
|
||||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||||
const raw = words * (60_000 / WPM)
|
||||
const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
|
||||
return clamped / speed
|
||||
}
|
||||
|
||||
export type InteractiveDemoPlayerProps = {
|
||||
demo: InteractiveDemoV1
|
||||
mode?: 'interactive' | 'static'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InteractiveDemoPlayer({
|
||||
demo,
|
||||
mode = 'interactive',
|
||||
className,
|
||||
}: InteractiveDemoPlayerProps) {
|
||||
const { t } = useLanguage()
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
|
||||
|
||||
const [actIndex, setActIndex] = useState(0)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speedIdx, setSpeedIdx] = useState(1)
|
||||
const speed = SPEEDS[speedIdx] ?? 1
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const actPauseRef = useRef(false)
|
||||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const act = resolved.acts[actIndex] ?? resolved.acts[0]
|
||||
const scenes = useMemo(() => {
|
||||
let scene = demo.scene
|
||||
const map: (typeof demo.scene)[] = []
|
||||
for (const a of demo.acts) {
|
||||
if (a.scene) scene = a.scene
|
||||
map.push(scene)
|
||||
}
|
||||
return map
|
||||
}, [demo])
|
||||
|
||||
const scene = scenes[actIndex] ?? demo.scene
|
||||
|
||||
const state =
|
||||
mode === 'static'
|
||||
? act?.final
|
||||
: act?.steps[stepIndex] ?? act?.final
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goStep = useCallback(
|
||||
(next: number) => {
|
||||
if (!act) return
|
||||
if (next < 0) {
|
||||
setStepIndex(0)
|
||||
return
|
||||
}
|
||||
if (next >= act.steps.length) {
|
||||
setPlaying(false)
|
||||
setStepIndex(act.steps.length - 1)
|
||||
return
|
||||
}
|
||||
setStepIndex(next)
|
||||
},
|
||||
[act]
|
||||
)
|
||||
|
||||
const resetAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}, [clearTimer])
|
||||
|
||||
const resetDemo = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
setActIndex(0)
|
||||
setStepIndex(0)
|
||||
}, [clearTimer])
|
||||
|
||||
const skipAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
if (actIndex >= resolved.acts.length - 1) {
|
||||
setStepIndex((act?.steps.length ?? 1) - 1)
|
||||
return
|
||||
}
|
||||
setActIndex((i) => i + 1)
|
||||
setStepIndex(0)
|
||||
}, [actIndex, act, resolved.acts.length, clearTimer])
|
||||
|
||||
const prevAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
if (actIndex <= 0) {
|
||||
setStepIndex(0)
|
||||
return
|
||||
}
|
||||
setActIndex((i) => i - 1)
|
||||
setStepIndex(0)
|
||||
}, [actIndex, clearTimer])
|
||||
|
||||
// Auto-play: duration recalculates immediately when speed/step changes
|
||||
useEffect(() => {
|
||||
clearTimer()
|
||||
if (!playing || mode !== 'interactive' || !act || !state) return
|
||||
|
||||
const atLastStep = stepIndex >= act.steps.length - 1
|
||||
const ms = stepDurationMs(state.speak, speed)
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (atLastStep) {
|
||||
// End of act while playing
|
||||
if (actIndex < resolved.acts.length - 1) {
|
||||
actPauseRef.current = true
|
||||
timerRef.current = setTimeout(() => {
|
||||
actPauseRef.current = false
|
||||
setActIndex((i) => i + 1)
|
||||
setStepIndex(0)
|
||||
}, ACT_CHANGE_PAUSE_MS)
|
||||
} else {
|
||||
// End of demo — pause on final state
|
||||
setPlaying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setStepIndex((s) => s + 1)
|
||||
}, ms)
|
||||
|
||||
return clearTimer
|
||||
}, [
|
||||
playing,
|
||||
stepIndex,
|
||||
speed,
|
||||
act,
|
||||
state,
|
||||
mode,
|
||||
actIndex,
|
||||
resolved.acts.length,
|
||||
clearTimer,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'interactive') return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Multi-player pages: only the hovered/focused player answers the keyboard
|
||||
if (!activeRef.current) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
setPlaying((p) => !p)
|
||||
} else if (e.code === 'ArrowRight' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
skipAct()
|
||||
} else if (e.code === 'ArrowLeft' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
prevAct()
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
goStep(stepIndex + 1)
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
goStep(stepIndex - 1)
|
||||
} else if (e.code === 'KeyR' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
resetDemo()
|
||||
} else if (e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
resetAct()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
|
||||
|
||||
if (!act || !state || !scene) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
data-demo-id={demo.id}
|
||||
data-mode={mode}
|
||||
onPointerEnter={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
>
|
||||
{demo.disclaimer && (
|
||||
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
|
||||
{demo.disclaimer}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="p-3 sm:p-4">
|
||||
<DemoSceneView
|
||||
scene={scene}
|
||||
state={state}
|
||||
reducedMotion={reducedMotion || mode === 'static'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
|
||||
<DemoSpeak
|
||||
speak={state.speak}
|
||||
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
|
||||
{progressLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'interactive' && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
goStep(stepIndex - 1)
|
||||
}}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<StepBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
goStep(stepIndex + 1)
|
||||
}}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-0.5 h-5 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={prevAct}
|
||||
aria-label="Previous act"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={skipAct}
|
||||
aria-label="Next act"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={resetAct}
|
||||
aria-label="Reset act"
|
||||
title="R"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
|
||||
role="group"
|
||||
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
|
||||
>
|
||||
{SPEEDS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeedIdx(i)}
|
||||
aria-pressed={speedIdx === i}
|
||||
className={cn(
|
||||
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
|
||||
speedIdx === i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
|
||||
{act.steps.map((s) => (
|
||||
<li key={s.stepId}>{s.speak}</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clapperboard, Loader2, Globe } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import {
|
||||
generateInteractivePage,
|
||||
generateInteractivePagePlan,
|
||||
generateInteractivePageSection,
|
||||
type PagePlan,
|
||||
} from '@/lib/ai/services/interactive-page-client.service'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSection,
|
||||
type PageSpecV1,
|
||||
} from '@/lib/interactive-page'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
|
||||
|
||||
function slugId(title: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : 'page.generated'
|
||||
}
|
||||
|
||||
/** Degraded section when its LLM call failed — page still completes. */
|
||||
function fallbackSection(
|
||||
sectionId: string,
|
||||
plan: PagePlan['sections'][number],
|
||||
lang: string
|
||||
): PageSection {
|
||||
const fr = lang.startsWith('fr')
|
||||
return {
|
||||
id: sectionId,
|
||||
title: plan.title,
|
||||
blocks: [
|
||||
{ type: 'prose', md: plan.goal },
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'note',
|
||||
title: fr ? 'En bref' : 'In short',
|
||||
md: plan.demoGoal || plan.goal,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assemblePage(
|
||||
plan: PagePlan,
|
||||
sections: PageSection[],
|
||||
lang: string
|
||||
): PageSpecV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: slugId(plan.heroTitle),
|
||||
lang,
|
||||
hero: {
|
||||
kicker: lang.startsWith('fr')
|
||||
? 'EXPLAINER INTERACTIF'
|
||||
: 'INTERACTIVE EXPLAINER',
|
||||
title: plan.heroTitle,
|
||||
subtitle: plan.heroSubtitle,
|
||||
meta: lang.startsWith('fr')
|
||||
? 'Généré depuis votre note'
|
||||
: 'Generated from your note',
|
||||
},
|
||||
overview: {
|
||||
lead: plan.overviewLead,
|
||||
cards: plan.overviewCards.map((c) => ({
|
||||
badge: c.badge,
|
||||
title: c.title,
|
||||
body: c.body,
|
||||
|
||||
intent: c.intent as any,
|
||||
})),
|
||||
},
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
|
||||
* → validate → preview → publish (pageSpec snapshot, no double quota).
|
||||
* Falls back to the deterministic page when the LLM path is unavailable.
|
||||
*/
|
||||
export function InteractivePagePublishDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
noteId,
|
||||
content,
|
||||
language,
|
||||
onPublished,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
noteId: string
|
||||
content: string
|
||||
language: string
|
||||
onPublished: (slug: string) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [page, setPage] = useState<PageSpecV1 | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<string | null>(null)
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'generating') {
|
||||
if (!open) setElapsedSec(0)
|
||||
return
|
||||
}
|
||||
const t0 = Date.now()
|
||||
const id = window.setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
|
||||
}, 500)
|
||||
return () => window.clearInterval(id)
|
||||
}, [open, phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhase('idle')
|
||||
setPage(null)
|
||||
setError(null)
|
||||
setProgress(null)
|
||||
return
|
||||
}
|
||||
// Guard: empty content from editor race
|
||||
const wordCount = content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 30) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
t('richTextEditor.publishInteractivePageTooShort') ||
|
||||
'Note trop courte — ajoutez du contenu puis réessayez'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setPhase('generating')
|
||||
setError(null)
|
||||
setPage(null)
|
||||
|
||||
const legacyFallback = async (notice?: string) => {
|
||||
const result = await generateInteractivePage({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
result.reason ||
|
||||
result.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'
|
||||
)
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
if (notice) toast.info(notice)
|
||||
setPage(result.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
// 1. LLM plan (billed)
|
||||
setProgress(
|
||||
t('richTextEditor.publishInteractivePagePlanning') ||
|
||||
'Analyse du contenu — plan de la page…'
|
||||
)
|
||||
const planResult = await generateInteractivePagePlan({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!planResult.ok) {
|
||||
if (planResult.quotaExceeded) {
|
||||
setPhase('error')
|
||||
setError(planResult.error)
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
if (planResult.error === 'unsuitable_content') {
|
||||
setPhase('error')
|
||||
setError(
|
||||
planResult.reason ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Contenu inadapté'
|
||||
)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
// LLM plan unavailable → deterministic full page
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
|
||||
// 2. One LLM call per section, with real progress
|
||||
const plan = planResult.plan
|
||||
const sections: PageSection[] = []
|
||||
let degraded = 0
|
||||
for (let i = 0; i < plan.sections.length; i++) {
|
||||
const planSection = plan.sections[i]
|
||||
const sectionId = `s${i + 1}`
|
||||
setProgress(
|
||||
(
|
||||
t('richTextEditor.publishInteractivePageSectionProgress') ||
|
||||
'Section {current}/{total} : {title}'
|
||||
)
|
||||
.replace('{current}', String(i + 1))
|
||||
.replace('{total}', String(plan.sections.length))
|
||||
.replace('{title}', planSection.title)
|
||||
)
|
||||
const sectionResult = await generateInteractivePageSection({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
pageTitle: plan.heroTitle,
|
||||
sectionId,
|
||||
section: planSection,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (sectionResult.ok) {
|
||||
sections.push(sectionResult.section)
|
||||
} else {
|
||||
degraded += 1
|
||||
sections.push(fallbackSection(sectionId, planSection, language))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assemble + hard validation client-side
|
||||
const candidate = assemblePage(plan, sections, language)
|
||||
const validated = validateInteractivePage(candidate)
|
||||
if (!validated.ok) {
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (degraded > 0) {
|
||||
toast.info(
|
||||
t('richTextEditor.publishInteractivePagePartialFallback') ||
|
||||
'Certaines sections ont été générées en mode simplifié'
|
||||
)
|
||||
}
|
||||
setPage(validated.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Intentionally omit `t` — unstable identity cancels in-flight generation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, content, language, noteId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!page || phase === 'publishing') return
|
||||
setPhase('publishing')
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId,
|
||||
action: 'publish',
|
||||
mode: 'interactive-page',
|
||||
template: 'interactive-page',
|
||||
language,
|
||||
pageSpec: page,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(
|
||||
data.reason ||
|
||||
data.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
t('richTextEditor.publishInteractivePageSuccess') ||
|
||||
'Page interactive publiée !'
|
||||
)
|
||||
onPublished(data.slug)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
toast.error(
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const generatingHint =
|
||||
progress ||
|
||||
(elapsedSec < 15
|
||||
? t('richTextEditor.publishInteractivePageGenerating') ||
|
||||
'Génération de la page…'
|
||||
: elapsedSec < 40
|
||||
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
|
||||
'Construction des sections et démos…'
|
||||
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
|
||||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Clapperboard className="h-4 w-4 text-brand-accent" />
|
||||
{t('richTextEditor.publishInteractivePage') ||
|
||||
'Page interactive'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{phase === 'generating'
|
||||
? generatingHint
|
||||
: t('richTextEditor.publishInteractivePagePreviewHint') ||
|
||||
'Aperçu — vérifiez puis publiez sur l’URL publique'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
{phase === 'generating' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
<p>{generatingHint}</p>
|
||||
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
|
||||
{elapsedSec}s
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'error' ? (
|
||||
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
|
||||
<p className="font-medium">
|
||||
{t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'}
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'preview' || phase === 'publishing' ? (
|
||||
page ? <PageView page={page} demoMode="interactive" /> : null
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={phase === 'publishing'}
|
||||
>
|
||||
{t('general.cancel') || 'Annuler'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={phase !== 'preview' || !page}
|
||||
className="gap-2"
|
||||
>
|
||||
{phase === 'publishing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
{t('richTextEditor.publishInteractivePageConfirm') ||
|
||||
'Publier la page'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Public / preview shell for published interactive pages.
|
||||
* Parses stored PageSpecV1 JSON from `publishedContent`.
|
||||
*/
|
||||
export function InteractivePublishedPage({
|
||||
publishedContent,
|
||||
isStale,
|
||||
}: {
|
||||
publishedContent: string
|
||||
isStale?: boolean
|
||||
}) {
|
||||
let page: PageSpecV1 | null = null
|
||||
let error: string | null = null
|
||||
try {
|
||||
const raw = JSON.parse(publishedContent)
|
||||
const result = validateInteractivePage(raw)
|
||||
if (result.ok) page = result.page
|
||||
else error = result.issues[0]?.message || 'PageSpec invalide'
|
||||
} catch {
|
||||
error = 'JSON de page interactive illisible'
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<p>{error || 'Page interactive indisponible'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isStale ? (
|
||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
|
||||
Le contenu source a évolué — cette page interactive est à régénérer.
|
||||
</div>
|
||||
) : null}
|
||||
<PageView page={page} demoMode="interactive" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
382
memento-note/components/interactive-page/page-blocks.tsx
Normal file
382
memento-note/components/interactive-page/page-blocks.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
|
||||
import { SimBlockView } from '@/components/interactive-page/sim-block'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||
import type { PageBlock } from '@/lib/interactive-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
|
||||
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const panel of demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
for (const act of demo.acts) {
|
||||
for (const step of act.steps) {
|
||||
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
const DEMO_INTENT_LABELS: Record<IntentId, { fr: string; en: string }> = {
|
||||
highlight: { fr: 'Focus', en: 'Focus' },
|
||||
flow: { fr: 'Flux', en: 'Flow' },
|
||||
cache: { fr: 'Mémoire', en: 'Memory' },
|
||||
compute: { fr: 'Calcul', en: 'Compute' },
|
||||
output: { fr: 'Résultat', en: 'Result' },
|
||||
warning: { fr: 'Attention', en: 'Warning' },
|
||||
}
|
||||
|
||||
function DemoLegend({ demo, lang }: { demo: InteractiveDemoV1; lang: string }) {
|
||||
const dark = useDarkMode()
|
||||
const fr = lang.startsWith('fr')
|
||||
const intents = collectDemoIntents(demo)
|
||||
if (!intents.length) return null
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
||||
{fr ? 'Légende' : 'Legend'}
|
||||
</span>
|
||||
{intents.map((id) => (
|
||||
<span key={id} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: intentColor(id, dark) }}
|
||||
/>
|
||||
{fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CALLOUT_STYLES: Record<
|
||||
string,
|
||||
{ border: string; bg: string; badge: string }
|
||||
> = {
|
||||
definition: {
|
||||
border: 'border-sky-500/30',
|
||||
bg: 'bg-sky-500/5',
|
||||
badge: 'text-sky-700 dark:text-sky-300',
|
||||
},
|
||||
warning: {
|
||||
border: 'border-amber-500/35',
|
||||
bg: 'bg-amber-500/5',
|
||||
badge: 'text-amber-800 dark:text-amber-300',
|
||||
},
|
||||
tip: {
|
||||
border: 'border-emerald-500/30',
|
||||
bg: 'bg-emerald-500/5',
|
||||
badge: 'text-emerald-800 dark:text-emerald-300',
|
||||
},
|
||||
note: {
|
||||
border: 'border-border',
|
||||
bg: 'bg-muted/40',
|
||||
badge: 'text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
function IntentBadge({
|
||||
label,
|
||||
intent,
|
||||
}: {
|
||||
label: string
|
||||
intent?: IntentId
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const color = intentColor(intent, dark)
|
||||
return (
|
||||
<span
|
||||
className="inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color,
|
||||
backgroundColor: `${color}22`,
|
||||
border: `1px solid ${color}44`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartBlockView({
|
||||
block,
|
||||
}: {
|
||||
block: Extract<PageBlock, { type: 'chart' }>
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const series = block.payload.series
|
||||
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
|
||||
const data = Array.from({ length: maxLen }, (_, i) => {
|
||||
const row: Record<string, number | string> = { i: String(i + 1) }
|
||||
for (const s of series) row[s.id] = s.values[i] ?? 0
|
||||
return row
|
||||
})
|
||||
const Chart =
|
||||
block.payload.chartType === 'bar'
|
||||
? BarChart
|
||||
: block.payload.chartType === 'area'
|
||||
? AreaChart
|
||||
: LineChart
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-6 rounded-xl border p-4"
|
||||
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<div className="h-56 min-h-[14rem] min-w-0 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
|
||||
/>
|
||||
<XAxis dataKey="i" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11 }} width={36} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
{series.map((s) => {
|
||||
const stroke = intentColor(s.intent, dark)
|
||||
if (block.payload.chartType === 'bar') {
|
||||
return (
|
||||
<Bar
|
||||
key={s.id}
|
||||
dataKey={s.id}
|
||||
fill={stroke}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (block.payload.chartType === 'area') {
|
||||
return (
|
||||
<Area
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
fill={stroke}
|
||||
fillOpacity={0.22}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-3 text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageBlockView({
|
||||
block,
|
||||
demoMode = 'static',
|
||||
lang = 'fr',
|
||||
}: {
|
||||
block: PageBlock
|
||||
demoMode?: 'interactive' | 'static'
|
||||
lang?: string
|
||||
}) {
|
||||
if (block.type === 'prose') {
|
||||
return <PageMd md={block.md} className="my-4 text-[17px] leading-[1.7]" />
|
||||
}
|
||||
|
||||
if (block.type === 'formula') {
|
||||
return <PageFormula tex={block.tex} caption={block.caption} />
|
||||
}
|
||||
|
||||
if (block.type === 'callout') {
|
||||
const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'my-5 rounded-xl border px-4 py-3.5 shadow-sm',
|
||||
style.border,
|
||||
style.bg
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]',
|
||||
style.badge
|
||||
)}
|
||||
>
|
||||
{block.kind} · {block.title}
|
||||
</p>
|
||||
<PageMd md={block.md} className="text-[15px] [&_p]:my-1" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'demo') {
|
||||
return (
|
||||
<figure className="my-8">
|
||||
{block.caption ? (
|
||||
<p className="mb-3 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{block.caption}
|
||||
</p>
|
||||
) : null}
|
||||
<InteractiveDemoPlayer demo={block.demo} mode={demoMode} />
|
||||
<DemoLegend demo={block.demo} lang={lang} />
|
||||
{demoMode === 'static' ? (
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
||||
{block.demo.acts.flatMap((a) =>
|
||||
a.steps.map((s) => (
|
||||
<li key={s.id}>
|
||||
<strong>{a.title}</strong> — {s.speak}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ol>
|
||||
</noscript>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'sim') {
|
||||
return <SimBlockView block={block} lang={lang} />
|
||||
}
|
||||
|
||||
if (block.type === 'chart') {
|
||||
return <ChartBlockView block={block} />
|
||||
}
|
||||
|
||||
if (block.type === 'stats') {
|
||||
return (
|
||||
<div className="my-8 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{block.items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.label}-${i}`}
|
||||
className="rounded-[14px] border px-4 py-3.5"
|
||||
style={{ background: 'var(--pp-paper)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<p
|
||||
className="font-extrabold leading-tight tabular-nums"
|
||||
style={{
|
||||
fontSize: 'clamp(22px, 2.4vw, 30px)',
|
||||
color: 'var(--pp-plum)',
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[13px] leading-snug" style={{ color: 'var(--pp-ink)' }}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'table') {
|
||||
return (
|
||||
<figure
|
||||
className="my-6 overflow-x-auto rounded-xl border"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<table className="w-full min-w-[460px] border-collapse text-[13.5px]">
|
||||
{block.caption ? (
|
||||
<caption
|
||||
className="px-3 py-2.5 text-left text-xs"
|
||||
style={{ fontFamily: 'var(--pp-mono)', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{block.caption}
|
||||
</caption>
|
||||
) : null}
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--pp-line)', background: 'var(--pp-paper)' }}>
|
||||
{block.columns.map((c) => (
|
||||
<th
|
||||
key={c}
|
||||
className="px-3 py-2.5 text-left font-semibold tracking-tight"
|
||||
>
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
style={{ borderBottom: ri < block.rows.length - 1 ? '1px solid var(--pp-line)' : undefined }}
|
||||
className="last:border-0"
|
||||
>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-3 py-2.5 align-top">
|
||||
<PageMd md={cell} className="text-sm [&_p]:my-0" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return (
|
||||
<figure className="my-6">
|
||||
{ }
|
||||
<img
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
className="mx-auto max-h-[480px] w-auto max-w-full rounded-xl border border-border/50 shadow-sm"
|
||||
/>
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-2 text-center text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export { IntentBadge }
|
||||
91
memento-note/components/interactive-page/page-md.tsx
Normal file
91
memento-note/components/interactive-page/page-md.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Light markdown + inline $KaTeX$ for page prose / callouts / speak. */
|
||||
export function PageMd({
|
||||
md,
|
||||
className,
|
||||
}: {
|
||||
md: string
|
||||
className?: string
|
||||
}) {
|
||||
const html = useMemo(() => {
|
||||
const placeholders: string[] = []
|
||||
const withSlots = md.replace(/\$([^$]+)\$/g, (_, tex: string) => {
|
||||
const i = placeholders.length
|
||||
try {
|
||||
placeholders.push(
|
||||
katex.renderToString(tex, { displayMode: false, throwOnError: false })
|
||||
)
|
||||
} catch {
|
||||
placeholders.push(tex)
|
||||
}
|
||||
return `%%KATEX${i}%%`
|
||||
})
|
||||
|
||||
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||
placeholders.forEach((frag, i) => {
|
||||
out = out.replace(`%%KATEX${i}%%`, frag)
|
||||
})
|
||||
return sanitizeRichHtml(out)
|
||||
}, [md])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:tracking-tight',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageFormula({
|
||||
tex,
|
||||
caption,
|
||||
}: {
|
||||
tex: string
|
||||
caption?: string
|
||||
}) {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return katex.renderToString(tex, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
})
|
||||
} catch {
|
||||
return tex
|
||||
}
|
||||
}, [tex])
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-5 overflow-x-auto rounded-xl border px-4 py-4 md:px-5"
|
||||
style={{
|
||||
background: 'var(--pp-paper)',
|
||||
borderColor: 'var(--pp-line)',
|
||||
borderLeft: '4px solid var(--pp-plum)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[1.15em] [&_.katex-display]:m-0"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{caption ? (
|
||||
<figcaption
|
||||
className="mt-2.5 text-sm"
|
||||
style={{ color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function collectIntents(page: PageSpecV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const card of page.overview?.cards ?? []) {
|
||||
if (card.intent) set.add(card.intent)
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
for (const block of section.blocks) {
|
||||
if (block.type === 'stats') {
|
||||
for (const item of block.items) {
|
||||
if (item.intent) set.add(item.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'chart') {
|
||||
for (const s of block.payload.series) {
|
||||
if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'demo') {
|
||||
for (const panel of block.demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
const INTENT_LABELS_FR: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flux',
|
||||
cache: 'Mémoire',
|
||||
compute: 'Calcul',
|
||||
output: 'Résultat',
|
||||
warning: 'Attention',
|
||||
}
|
||||
|
||||
const INTENT_LABELS_EN: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flow',
|
||||
cache: 'Memory',
|
||||
compute: 'Compute',
|
||||
output: 'Result',
|
||||
warning: 'Warning',
|
||||
}
|
||||
|
||||
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
|
||||
const [active, setActive] = useState(page.sections[0]?.id ?? '')
|
||||
const dark = useDarkMode()
|
||||
const intents = useMemo(() => collectIntents(page), [page])
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = page.sections
|
||||
.map((s) => document.getElementById(s.id))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
if (!nodes.length) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
|
||||
const top = visible[0]?.target?.id
|
||||
if (top) setActive(top)
|
||||
},
|
||||
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
|
||||
)
|
||||
nodes.forEach((n) => obs.observe(n))
|
||||
return () => obs.disconnect()
|
||||
}, [page.sections])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
borderBottom: '1px solid var(--pp-line)',
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
|
||||
aria-label="Sections"
|
||||
style={{ fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{page.sections.map((s, i) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`#${s.id}`}
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
|
||||
active === s.id ? 'font-semibold' : 'hover:opacity-80'
|
||||
)}
|
||||
style={
|
||||
active === s.id
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{i + 1} · {s.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{intents.length > 0 ? (
|
||||
<div
|
||||
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
|
||||
style={{
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
fontFamily: 'var(--pp-mono)',
|
||||
color: 'var(--pp-muted)',
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
||||
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
|
||||
</span>
|
||||
{intents.map((id) => {
|
||||
const color = intentColor(id, dark)
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{page.lang.startsWith('fr')
|
||||
? INTENT_LABELS_FR[id]
|
||||
: INTENT_LABELS_EN[id]}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
270
memento-note/components/interactive-page/page-view.tsx
Normal file
270
memento-note/components/interactive-page/page-view.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
|
||||
import { PageMd } from '@/components/interactive-page/page-md'
|
||||
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
|
||||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type PageViewProps = {
|
||||
page: PageSpecV1
|
||||
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
|
||||
demoMode?: 'interactive' | 'static'
|
||||
className?: string
|
||||
paper?: boolean
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(mq.matches)
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
function useScrollReveal(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||||
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
|
||||
return
|
||||
}
|
||||
const nodes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||||
)
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.setAttribute('data-scroll-visible', 'true')
|
||||
obs.unobserve(e.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
|
||||
)
|
||||
nodes.forEach((n) => obs.observe(n))
|
||||
return () => obs.disconnect()
|
||||
}, [enabled])
|
||||
}
|
||||
|
||||
/**
|
||||
* PageView — Kimi/AttnRes-style explainer page.
|
||||
* Design tokens replicated from the reference page (paper, plum accent,
|
||||
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
|
||||
*/
|
||||
export function PageView({
|
||||
page,
|
||||
demoMode = 'interactive',
|
||||
className,
|
||||
paper = true,
|
||||
}: PageViewProps) {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
useScrollReveal(!reducedMotion)
|
||||
|
||||
const paperStyle = {
|
||||
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
|
||||
data-page-id={page.id}
|
||||
style={paperStyle}
|
||||
>
|
||||
<style>{`
|
||||
.interactive-page {
|
||||
--pp-paper: #F4F0E8;
|
||||
--pp-paper-deep: #EAE4D9;
|
||||
--pp-card: #FFFDF8;
|
||||
--pp-ink: #242422;
|
||||
--pp-muted: #686762;
|
||||
--pp-line: #D6D0C6;
|
||||
--pp-plum: #9F3F70;
|
||||
--pp-plum-soft: #F2DCE8;
|
||||
--pp-blue: #3F6F9F;
|
||||
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--pp-ink);
|
||||
}
|
||||
.dark .interactive-page {
|
||||
--pp-paper: #17140F;
|
||||
--pp-paper-deep: #201C15;
|
||||
--pp-card: #221E17;
|
||||
--pp-ink: #EDE7DB;
|
||||
--pp-muted: #A39C8D;
|
||||
--pp-line: #3B352A;
|
||||
--pp-plum: #D07AA6;
|
||||
--pp-plum-soft: #3A2530;
|
||||
--pp-blue: #7FA8CC;
|
||||
}
|
||||
.interactive-page.page-paper {
|
||||
background-color: var(--pp-paper);
|
||||
background-image: radial-gradient(
|
||||
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
|
||||
transparent 0.7px
|
||||
);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.pp-card {
|
||||
background: var(--pp-card);
|
||||
border: 1px solid var(--pp-line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.pp-mono {
|
||||
font-family: var(--pp-mono);
|
||||
}
|
||||
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.interactive-page [data-scroll-init] {
|
||||
transition: opacity 420ms ease, transform 420ms ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.interactive-page [data-scroll-visible] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.interactive-page [data-scroll-init] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
|
||||
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
|
||||
<p
|
||||
className="pp-mono mb-2.5 text-xs uppercase"
|
||||
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{page.hero.kicker}
|
||||
</p>
|
||||
<h1
|
||||
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
|
||||
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
|
||||
>
|
||||
{page.hero.title}
|
||||
</h1>
|
||||
{page.hero.subtitle ? (
|
||||
<p
|
||||
className="mt-2.5 font-semibold"
|
||||
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
|
||||
>
|
||||
{page.hero.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
{page.hero.meta ? (
|
||||
<p
|
||||
className="pp-mono mt-3 leading-relaxed"
|
||||
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.hero.meta}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<PageStickyNav page={page} />
|
||||
|
||||
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
|
||||
{/* ── One-minute overview ── */}
|
||||
{page.overview ? (
|
||||
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
|
||||
<p
|
||||
className="pp-mono mb-3 text-[11px] uppercase"
|
||||
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.lang.startsWith('fr')
|
||||
? 'L’essentiel en une minute'
|
||||
: 'One-minute overview'}
|
||||
</p>
|
||||
<PageMd
|
||||
md={page.overview.lead}
|
||||
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
|
||||
/>
|
||||
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{page.overview.cards.map((card) => (
|
||||
<div
|
||||
key={card.badge + card.title}
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
background: 'var(--pp-paper)',
|
||||
borderColor: 'var(--pp-line)',
|
||||
}}
|
||||
>
|
||||
<IntentBadge label={card.badge} intent={card.intent} />
|
||||
<h3 className="mt-3 text-base font-semibold tracking-tight">
|
||||
{card.title}
|
||||
</h3>
|
||||
<PageMd
|
||||
md={card.body}
|
||||
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── Sections ── */}
|
||||
<div className="space-y-20">
|
||||
{page.sections.map((section, i) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className="scroll-mt-28"
|
||||
data-scroll-init
|
||||
>
|
||||
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
|
||||
<span
|
||||
className="pp-mono text-sm font-semibold"
|
||||
style={{ color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{section.title}
|
||||
</h2>
|
||||
<div>
|
||||
{section.blocks.map((block, bi) => {
|
||||
const narrow =
|
||||
block.type === 'prose' ||
|
||||
block.type === 'formula' ||
|
||||
block.type === 'callout'
|
||||
return (
|
||||
<div
|
||||
key={`${section.id}-${bi}`}
|
||||
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
|
||||
>
|
||||
<PageBlockView
|
||||
block={block}
|
||||
demoMode={demoMode}
|
||||
lang={page.lang}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{page.footer ? (
|
||||
<footer
|
||||
className="pp-mono mt-16 border-t pt-5 text-xs italic"
|
||||
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.footer}
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/interactive-page/sim-block.tsx
Normal file
104
memento-note/components/interactive-page/sim-block.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { GENERIC_SIM_ID, getPlugin } from '@/lib/simulators'
|
||||
import { ANIM_VIEWS, SIMULATOR_VIEWS } from '@/components/simulators'
|
||||
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
|
||||
import { GenericFormulaView } from '@/components/simulators/generic-formula-view'
|
||||
import type { SimBlock } from '@/lib/interactive-page'
|
||||
|
||||
/** Renders a `sim` block: catalog plugin (bespoke view) or generic formula. */
|
||||
export function SimBlockView({
|
||||
block,
|
||||
lang,
|
||||
}: {
|
||||
block: SimBlock
|
||||
lang: string
|
||||
}) {
|
||||
const sim = block.sim
|
||||
const fr = lang.startsWith('fr')
|
||||
const plugin = sim.simId === GENERIC_SIM_ID ? null : getPlugin(sim.simId)
|
||||
const kindLabel =
|
||||
plugin?.family === 'anim'
|
||||
? fr
|
||||
? 'Animation interactive'
|
||||
: 'Interactive animation'
|
||||
: fr
|
||||
? 'Simulation interactive'
|
||||
: 'Interactive simulation'
|
||||
|
||||
let title: string | undefined
|
||||
let body: React.ReactNode = null
|
||||
|
||||
if (sim.simId === GENERIC_SIM_ID) {
|
||||
title = sim.title
|
||||
body = (
|
||||
<GenericFormulaView
|
||||
sim={sim as Extract<typeof sim, { simId: 'generic-formula' }>}
|
||||
lang={lang}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
|
||||
|
||||
if (plugin.family === 'anim') {
|
||||
const AnimScene = ANIM_VIEWS[sim.simId]
|
||||
if (!AnimScene) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
body = (
|
||||
<AnimPlayerShell beats={plugin.beats} lang={lang} disclaimer={plugin.disclaimer}>
|
||||
{(stepIndex) => <AnimScene step={stepIndex} lang={lang} />}
|
||||
</AnimPlayerShell>
|
||||
)
|
||||
} else {
|
||||
const View = SIMULATOR_VIEWS[sim.simId]
|
||||
if (!View) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const preset = (sim as { preset?: Record<string, number> }).preset
|
||||
body = (
|
||||
<View
|
||||
preset={preset}
|
||||
title={title}
|
||||
disclaimer={sim.disclaimer}
|
||||
lang={lang}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-8 rounded-2xl border p-4 md:p-5"
|
||||
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<div className="mb-4 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
{body}
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -19,10 +19,11 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History, Clapperboard
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -53,6 +54,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const [publishOpen, setPublishOpen] = useState(false)
|
||||
const [publishLoading, setPublishLoading] = useState(false)
|
||||
const [interactivePageOpen, setInteractivePageOpen] = useState(false)
|
||||
const [interactivePageContent, setInteractivePageContent] = useState('')
|
||||
const [publishMeta, setPublishMeta] = useState({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
@@ -343,6 +346,28 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
magazine: t('richTextEditor.publishTemplateMagazine'),
|
||||
brief: t('richTextEditor.publishTemplateBrief'),
|
||||
essay: t('richTextEditor.publishTemplateEssay'),
|
||||
'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
|
||||
}
|
||||
|
||||
const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
|
||||
(tpl) => tpl !== 'interactive-page'
|
||||
)
|
||||
|
||||
const handlePublishInteractivePage = async () => {
|
||||
if (publishLoading) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
const html =
|
||||
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
|
||||
state.content ||
|
||||
note.content ||
|
||||
''
|
||||
setInteractivePageContent(html)
|
||||
setPublishOpen(false)
|
||||
setInteractivePageOpen(true)
|
||||
}
|
||||
|
||||
const handlePublishWithAi = async () => {
|
||||
@@ -664,6 +689,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
{t('richTextEditor.publishSimpleHint')}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishInteractivePage}
|
||||
disabled={publishLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Clapperboard size={14} />
|
||||
)}
|
||||
{t('richTextEditor.publishInteractivePage') ||
|
||||
'Page interactive'}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
||||
{t('richTextEditor.publishInteractivePageHint') ||
|
||||
'Hero, sections, démos Play/Step — 20 crédits'}
|
||||
</p>
|
||||
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={13} className="text-brand-accent shrink-0" />
|
||||
@@ -679,7 +723,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</p>
|
||||
{/* Sélection template */}
|
||||
<div className="space-y-1">
|
||||
{PUBLISH_TEMPLATES.map((tpl) => (
|
||||
{classicPublishTemplates.map((tpl) => (
|
||||
<label
|
||||
key={tpl}
|
||||
className={cn(
|
||||
@@ -1009,6 +1053,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
}}
|
||||
/>
|
||||
|
||||
<InteractivePagePublishDialog
|
||||
open={interactivePageOpen}
|
||||
onOpenChange={setInteractivePageOpen}
|
||||
noteId={note.id}
|
||||
content={interactivePageContent}
|
||||
language={language}
|
||||
onPublished={(slug) => {
|
||||
setPublishMeta({
|
||||
isPublic: true,
|
||||
slug,
|
||||
template: 'interactive-page',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
aria-label={t('notes.documentInfoAria')}
|
||||
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
||||
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
|
||||
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
||||
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
||||
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
||||
@@ -70,7 +71,7 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -298,6 +299,9 @@ const slashCommands: SlashItem[] = [
|
||||
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
||||
},
|
||||
{
|
||||
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
|
||||
},
|
||||
{
|
||||
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
|
||||
command: (e) => { insertToggleBlock(e) },
|
||||
@@ -385,7 +389,7 @@ function useImageInsert() {
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
@@ -589,6 +593,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
UndoRedoFeedbackExtension,
|
||||
LiveBlockExtension,
|
||||
StructuredViewBlockExtension,
|
||||
InteractiveDemoExtension,
|
||||
ToggleExtension,
|
||||
CalloutExtension,
|
||||
OutlineExtension,
|
||||
@@ -951,6 +956,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor, requestAiConsent])
|
||||
|
||||
const handleGenerateInteractiveDemo = useCallback(async () => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
|
||||
let content = ''
|
||||
let selection: string | null = null
|
||||
try {
|
||||
content = editor.getHTML() || editor.state.doc.textContent || ''
|
||||
const { from, to, empty } = editor.state.selection
|
||||
if (!empty) {
|
||||
const parts: string[] = []
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
const latex = (node.attrs as { latex?: string } | undefined)?.latex
|
||||
if (typeof latex === 'string' && latex.trim()) {
|
||||
parts.push(
|
||||
node.type.name === 'mathEquationBlock'
|
||||
? `$$${latex}$$`
|
||||
: `$${latex}$`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (node.isText && node.text) {
|
||||
parts.push(node.text)
|
||||
}
|
||||
return true
|
||||
})
|
||||
selection = parts.join(' ').trim() || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[interactive-demo] content extract fallback', err)
|
||||
content = editor.state.doc.textContent || ''
|
||||
}
|
||||
|
||||
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
|
||||
const words = sourceForWords.split(/\s+/).filter(Boolean).length
|
||||
if (words < 20) {
|
||||
toast.error(
|
||||
t('interactiveDemo.needMoreText') ||
|
||||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = toast.loading(
|
||||
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
|
||||
)
|
||||
try {
|
||||
const { generateInteractiveDemo } = await import(
|
||||
'@/lib/ai/services/interactive-demo-client.service'
|
||||
)
|
||||
const result = await generateInteractiveDemo({
|
||||
content,
|
||||
selection,
|
||||
lang: language || 'fr',
|
||||
noteId: noteId || undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
|
||||
id: toastId,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
typeof result.error === 'string'
|
||||
? result.error
|
||||
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
|
||||
toast.error(
|
||||
t('interactiveDemo.insertFailed') || 'Impossible d’insérer la démo',
|
||||
{ id: toastId }
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
toast.success(
|
||||
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
|
||||
{ id: toastId }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[interactive-demo] generate error', err)
|
||||
toast.error(
|
||||
t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
}, [editor, requestAiConsent, t, language, noteId])
|
||||
|
||||
const insertCitationInEditor = useCallback((
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
@@ -1321,7 +1420,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
{editor && (
|
||||
<SlashCommandMenu
|
||||
editor={editor}
|
||||
onInsertImage={imageInsert.requestInsert}
|
||||
onSuggestCharts={handleOpenChartSuggestions}
|
||||
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
@@ -1910,7 +2016,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
|
||||
}
|
||||
}
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -1968,6 +2074,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
@@ -2083,6 +2190,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (
|
||||
item.title === 'Interactive Demo'
|
||||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
@@ -2092,7 +2204,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
|
||||
|
||||
// Charger les favoris fréquents lors de l'ouverture
|
||||
useEffect(() => {
|
||||
|
||||
@@ -32,6 +32,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
interactive_demo: 'usageMeter.featureInteractiveDemo',
|
||||
interactive_page: 'usageMeter.featureInteractivePage',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
|
||||
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
|
||||
import { PageMd } from '@/components/interactive-page/page-md'
|
||||
import type { AnimBeat, SimI18n } from '@/lib/simulators'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const WPM = 220
|
||||
const STEP_FLOOR_MS = 1500
|
||||
const STEP_CEIL_MS = 6000
|
||||
const SPEEDS = [0.5, 1, 2, 4]
|
||||
|
||||
function stepDurationMs(speak: string, speed: number): number {
|
||||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||||
const raw = words * (60_000 / WPM)
|
||||
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(mq.matches)
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
export type AnimPlayerShellProps = {
|
||||
beats: AnimBeat[]
|
||||
lang: string
|
||||
disclaimer?: SimI18n
|
||||
/** Scene renderer — receives the current beat index (0-based). */
|
||||
children: (stepIndex: number) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
|
||||
* + narration panel, same pacing rules as InteractiveDemoPlayer.
|
||||
*/
|
||||
export function AnimPlayerShell({
|
||||
beats,
|
||||
lang,
|
||||
disclaimer,
|
||||
children,
|
||||
}: AnimPlayerShellProps) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speed, setSpeed] = useState(1)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const atLast = stepIndex >= beats.length - 1
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer()
|
||||
if (!playing) return
|
||||
const beat = beats[stepIndex]
|
||||
if (!beat) return
|
||||
const speak = fr ? beat.speak.fr : beat.speak.en
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
if (stepIndex >= beats.length - 1) {
|
||||
setPlaying(false)
|
||||
return
|
||||
}
|
||||
setStepIndex((s) => s + 1)
|
||||
},
|
||||
reducedMotion ? 250 : stepDurationMs(speak, speed)
|
||||
)
|
||||
return clearTimer
|
||||
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
// Multi-player pages: only the hovered/focused player answers the keyboard
|
||||
if (!activeRef.current) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
setPlaying((p) => !p)
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.max(0, s - 1))
|
||||
} else if (e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [beats.length])
|
||||
|
||||
const beat = beats[stepIndex]
|
||||
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
|
||||
{children(stepIndex)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
}}
|
||||
aria-label={fr ? 'Étape suivante' : 'Next step'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}}
|
||||
aria-label="Reset"
|
||||
title="R"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="ml-2 text-xs tabular-nums"
|
||||
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
|
||||
style={{ borderColor: 'var(--pp-line)' }}
|
||||
role="group"
|
||||
aria-label={fr ? 'Vitesse' : 'Speed'}
|
||||
>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
|
||||
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
style={
|
||||
speed === s
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 rounded-lg border px-4 py-3"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
|
||||
</div>
|
||||
|
||||
{disclaimer ? (
|
||||
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
|
||||
{fr ? disclaimer.fr : disclaimer.en}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
|
||||
{beats.map((b) => (
|
||||
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal file
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
|
||||
/**
|
||||
* Carnot cycle animated scene — piston apparatus + live P-V diagram.
|
||||
* Driven by AnimPlayerShell via `step` (0..4). Pure SVG, transitions on
|
||||
* transform/opacity only (GPU-friendly, reduced-motion safe).
|
||||
*/
|
||||
|
||||
const PISTON_Y = [105, 70, 135, 170, 110]
|
||||
const GAS_HOT = [1, 0.55, 0.12, 0.6, 0.5]
|
||||
const GAS_COLD = [0, 0.45, 0.9, 0.4, 0.4]
|
||||
const T_LABEL = ['T_h', 'T_h → T_c', 'T_c', 'T_c → T_h', 'η']
|
||||
|
||||
// P–V anchors (illustrative)
|
||||
const A = { x: 405, y: 50 }
|
||||
const B = { x: 495, y: 159 }
|
||||
const C = { x: 630, y: 212 }
|
||||
const D = { x: 465, y: 175 }
|
||||
const ANCHORS = [A, B, C, D]
|
||||
const CYCLE = `${A.x},${A.y} ${B.x},${B.y} ${C.x},${C.y} ${D.x},${D.y}`
|
||||
|
||||
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
|
||||
|
||||
export function CarnotCycleAnimView({ step, lang }: { step: number; lang: string }) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const s = Math.min(step, 4)
|
||||
|
||||
const ink = dark ? '#EDE7DB' : '#242422'
|
||||
const muted = dark ? '#A39C8D' : '#686762'
|
||||
const plum = dark ? '#D07AA6' : '#9F3F70'
|
||||
const blue = dark ? '#7FA8CC' : '#3F6F9F'
|
||||
const line = dark ? '#3B352A' : '#D6D0C6'
|
||||
const card = dark ? '#221E17' : '#FFFDF8'
|
||||
const paper = dark ? '#17140F' : '#F4F0E8'
|
||||
|
||||
const pistonY = PISTON_Y[s]
|
||||
const showHotPlate = s === 0
|
||||
const showColdPlate = s === 2
|
||||
const showInsulation = s === 1 || s === 3
|
||||
const showQh = s === 0
|
||||
const showQc = s === 2
|
||||
const showWout = s === 0 || s === 1
|
||||
const showWin = s === 2 || s === 3
|
||||
const marker = ANCHORS[Math.min(s, 3)]
|
||||
const segDone = s // segments A→B (0), B→C (1), C→D (2), D→A (3)
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 300"
|
||||
className="w-full"
|
||||
role="img"
|
||||
aria-label={fr ? 'Animation du cycle de Carnot' : 'Carnot cycle animation'}
|
||||
style={{ background: paper, display: 'block' }}
|
||||
>
|
||||
{/* ══ Piston apparatus ══ */}
|
||||
<g>
|
||||
{/* cylinder walls */}
|
||||
<rect x={70} y={40} width={110} height={195} fill={card} stroke={ink} strokeWidth={2} rx={4} />
|
||||
{/* gas: cold + hot crossfade */}
|
||||
<g style={{ transform: `translateY(${pistonY - 45}px)`, transition: EASE }}>
|
||||
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={blue} opacity={GAS_COLD[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
|
||||
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={plum} opacity={GAS_HOT[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
|
||||
</g>
|
||||
{/* piston */}
|
||||
<g style={{ transform: `translateY(${pistonY - 95}px)`, transition: EASE }}>
|
||||
<rect x={74} y={95} width={102} height={14} fill={ink} rx={3} opacity={0.9} />
|
||||
<rect x={118} y={60} width={14} height={36} fill={ink} rx={3} opacity={0.9} />
|
||||
<rect x={105} y={48} width={40} height={12} fill={ink} rx={4} opacity={0.9} />
|
||||
</g>
|
||||
{/* hot plate */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill={plum} opacity={showHotPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showHotPlate ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={plum}>
|
||||
{fr ? 'Source chaude' : 'Hot'} T_h
|
||||
</text>
|
||||
) : null}
|
||||
{/* cold plate */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill={blue} opacity={showColdPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showColdPlate ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={blue}>
|
||||
{fr ? 'Source froide' : 'Cold'} T_c
|
||||
</text>
|
||||
) : null}
|
||||
{/* insulation */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill="none" stroke={muted} strokeWidth={2} strokeDasharray="6 4" opacity={showInsulation ? 1 : 0} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showInsulation ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={muted}>
|
||||
{fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'}
|
||||
</text>
|
||||
) : null}
|
||||
{/* Q_h arrow in */}
|
||||
<g opacity={showQh ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={95} y1={238} x2={95} y2={196} stroke={plum} strokeWidth={5} strokeLinecap="round" />
|
||||
<polygon points="95,190 89,202 101,202" fill={plum} />
|
||||
<text x={70} y={210} fontSize={12} fontWeight={700} fill={plum}>Q_h</text>
|
||||
</g>
|
||||
{/* Q_c arrow out */}
|
||||
<g opacity={showQc ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={155} y1={196} x2={155} y2={238} stroke={blue} strokeWidth={5} strokeLinecap="round" />
|
||||
<polygon points="155,244 149,232 161,232" fill={blue} />
|
||||
<text x={164} y={226} fontSize={12} fontWeight={700} fill={blue}>Q_c</text>
|
||||
</g>
|
||||
{/* W arrows */}
|
||||
<g opacity={showWout ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={210} y1={180} x2={210} y2={120} stroke={ink} strokeWidth={4} strokeLinecap="round" />
|
||||
<polygon points="210,112 204,124 216,124" fill={ink} />
|
||||
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
|
||||
</g>
|
||||
<g opacity={showWin ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={210} y1={120} x2={210} y2={180} stroke={ink} strokeWidth={4} strokeLinecap="round" />
|
||||
<polygon points="210,188 204,176 216,176" fill={ink} />
|
||||
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
|
||||
</g>
|
||||
{/* temperature label inside gas */}
|
||||
<text x={125} y={pistonY + 40} textAnchor="middle" fontSize={13} fontWeight={700} fill={ink} style={{ transition: EASE }}>
|
||||
{T_LABEL[s]}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* ══ P–V diagram ══ */}
|
||||
<g>
|
||||
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
|
||||
P–V
|
||||
</text>
|
||||
{/* axes */}
|
||||
<line x1={330} y1={250} x2={660} y2={250} stroke={ink} strokeWidth={1.5} />
|
||||
<line x1={330} y1={250} x2={330} y2={30} stroke={ink} strokeWidth={1.5} />
|
||||
<text x={655} y={266} fontSize={11} fill={muted} textAnchor="end">V</text>
|
||||
<text x={322} y={40} fontSize={11} fill={muted} textAnchor="end">P</text>
|
||||
{/* isotherms */}
|
||||
<path d="M 395 56 Q 460 130 660 218" fill="none" stroke={plum} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
|
||||
<text x={620} y={200} fontSize={10} fill={plum}>T_h</text>
|
||||
<path d="M 400 175 Q 520 208 660 230" fill="none" stroke={blue} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
|
||||
<text x={620} y={242} fontSize={10} fill={blue}>T_c</text>
|
||||
{/* cycle area (final beat) */}
|
||||
<polygon points={CYCLE} fill={plum} opacity={s === 4 ? 0.14 : 0} style={{ transition: 'opacity 600ms ease' }} />
|
||||
{s === 4 ? (
|
||||
<text x={505} y={150} fontSize={15} fontWeight={800} fill={plum} textAnchor="middle">W</text>
|
||||
) : null}
|
||||
{/* cycle segments, revealed progressively */}
|
||||
<polyline points={`${A.x},${A.y} ${B.x},${B.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 0 ? 1 : 0.15} />
|
||||
<polyline points={`${B.x},${B.y} ${C.x},${C.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polyline points={`${C.x},${C.y} ${D.x},${D.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polyline points={`${D.x},${D.y} ${A.x},${A.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* current state marker */}
|
||||
<circle cx={marker.x} cy={marker.y} r={6} fill={plum} stroke={card} strokeWidth={2} style={{ transition: EASE }} />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal file
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
carnotCycleSimulator as sim,
|
||||
fridgeLoadFromModeLoad,
|
||||
modeLoadFromFridgeLoad,
|
||||
resolveCarnotPhysics,
|
||||
type CarnotMode,
|
||||
type CarnotQuantity,
|
||||
} from '@/lib/simulators/carnot-cycle'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SVG_W = 360
|
||||
const SVG_H = 270
|
||||
|
||||
type TempUnit = 'K' | 'C' | 'F'
|
||||
|
||||
const TEMP_UNITS: { id: TempUnit; label: string }[] = [
|
||||
{ id: 'K', label: 'K' },
|
||||
{ id: 'C', label: '°C' },
|
||||
{ id: 'F', label: '°F' },
|
||||
]
|
||||
|
||||
const MODES: { id: CarnotMode; fr: string; en: string }[] = [
|
||||
{ id: 'fridge', fr: 'Frigo', en: 'Fridge' },
|
||||
{ id: 'heat_pump', fr: 'PAC', en: 'Heat pump' },
|
||||
{ id: 'engine', fr: 'Moteur', en: 'Engine' },
|
||||
]
|
||||
|
||||
const QTY: { id: CarnotQuantity; fr: string; en: string }[] = [
|
||||
{ id: 'energy', fr: 'Énergie (kJ)', en: 'Energy (kJ)' },
|
||||
{ id: 'power', fr: 'Puissance (W)', en: 'Power (W)' },
|
||||
]
|
||||
|
||||
function kelvinToDisplay(k: number, unit: TempUnit): number {
|
||||
if (unit === 'C') return k - 273.15
|
||||
if (unit === 'F') return (k * 9) / 5 - 459.67
|
||||
return k
|
||||
}
|
||||
|
||||
function displayToKelvin(v: number, unit: TempUnit): number {
|
||||
if (unit === 'C') return v + 273.15
|
||||
if (unit === 'F') return ((v + 459.67) * 5) / 9
|
||||
return v
|
||||
}
|
||||
|
||||
function formatTemp(k: number, unit: TempUnit): string {
|
||||
const v = kelvinToDisplay(k, unit)
|
||||
if (unit === 'K') return `${Math.round(v)} K`
|
||||
if (unit === 'C') {
|
||||
const r = Math.round(v * 10) / 10
|
||||
return `${Number.isInteger(r) ? r : r.toFixed(1)} °C`
|
||||
}
|
||||
return `${Math.round(v)} °F`
|
||||
}
|
||||
|
||||
function tempSliderMeta(
|
||||
param: { min: number; max: number; step: number },
|
||||
unit: TempUnit
|
||||
): { min: number; max: number; step: number; unitLabel: string } {
|
||||
if (unit === 'K') {
|
||||
return { min: param.min, max: param.max, step: param.step, unitLabel: 'K' }
|
||||
}
|
||||
if (unit === 'C') {
|
||||
return {
|
||||
min: Math.round((param.min - 273.15) * 10) / 10,
|
||||
max: Math.round((param.max - 273.15) * 10) / 10,
|
||||
step: 0.5,
|
||||
unitLabel: '°C',
|
||||
}
|
||||
}
|
||||
return {
|
||||
min: Math.round(((param.min * 9) / 5 - 459.67) * 10) / 10,
|
||||
max: Math.round(((param.max * 9) / 5 - 459.67) * 10) / 10,
|
||||
step: 1,
|
||||
unitLabel: '°F',
|
||||
}
|
||||
}
|
||||
|
||||
function formatQty(v: number): string {
|
||||
if (!Number.isFinite(v)) return '—'
|
||||
if (Math.abs(v - Math.round(v)) < 0.05) return String(Math.round(v))
|
||||
return v.toFixed(1)
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { id: T; label: string }[]
|
||||
ariaLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex max-w-full flex-wrap rounded-lg border border-border/60 bg-background/80 p-0.5"
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[11px] font-semibold transition-colors',
|
||||
value === o.id
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={value === o.id}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Arrowhead + shaft, thickness ∝ |value|/maxRef. */
|
||||
function FlowArrow({
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
value,
|
||||
maxRef,
|
||||
color,
|
||||
label,
|
||||
unit,
|
||||
labelSide = 'right',
|
||||
}: {
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
value: number
|
||||
maxRef: number
|
||||
color: string
|
||||
label: string
|
||||
unit?: string
|
||||
labelSide?: 'left' | 'right' | 'above' | 'below'
|
||||
}) {
|
||||
const mag = Math.max(0, value)
|
||||
const t = 2.2 + (mag / Math.max(1, maxRef)) * 10
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
const headLen = Math.min(14, Math.max(9, 7 + t * 0.45))
|
||||
const headHalf = Math.min(7, 3.2 + t * 0.35)
|
||||
const bx = x2 - ux * headLen
|
||||
const by = y2 - uy * headLen
|
||||
const px = -uy
|
||||
const py = ux
|
||||
const mx = (x1 + bx) / 2
|
||||
const my = (y1 + by) / 2
|
||||
const labelGap = 11 + t * 0.45
|
||||
|
||||
let lx = mx
|
||||
let ly = my
|
||||
let textAnchor: 'start' | 'middle' | 'end' = 'middle'
|
||||
if (labelSide === 'above') ly = my - labelGap
|
||||
else if (labelSide === 'below') ly = my + labelGap
|
||||
else if (labelSide === 'right') {
|
||||
lx = mx + labelGap
|
||||
textAnchor = 'start'
|
||||
} else {
|
||||
lx = mx - labelGap
|
||||
textAnchor = 'end'
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={bx}
|
||||
y2={by}
|
||||
stroke={color}
|
||||
strokeWidth={t}
|
||||
strokeLinecap="round"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<polygon
|
||||
points={`${x2},${y2} ${bx + px * headHalf},${by + py * headHalf} ${bx - px * headHalf},${by - py * headHalf}`}
|
||||
fill={color}
|
||||
opacity={0.95}
|
||||
/>
|
||||
<text
|
||||
x={lx}
|
||||
y={ly}
|
||||
fontSize={11}
|
||||
fill={color}
|
||||
fontWeight={600}
|
||||
dominantBaseline="middle"
|
||||
textAnchor={textAnchor}
|
||||
>
|
||||
{label} {formatQty(value)}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export function CarnotCycleView({
|
||||
preset,
|
||||
disclaimer,
|
||||
lang,
|
||||
}: {
|
||||
preset?: Record<string, number>
|
||||
title?: string
|
||||
disclaimer?: string
|
||||
lang: string
|
||||
}) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const [tempUnit, setTempUnit] = useState<TempUnit>('K')
|
||||
const [mode, setMode] = useState<CarnotMode>('fridge')
|
||||
const [qty, setQty] = useState<CarnotQuantity>('energy')
|
||||
const [values, setValues] = useState<Record<string, number>>(() => {
|
||||
const env: Record<string, number> = {}
|
||||
for (const p of sim.params) env[p.id] = preset?.[p.id] ?? p.defaultValue
|
||||
return env
|
||||
})
|
||||
|
||||
const phys = useMemo(
|
||||
() =>
|
||||
resolveCarnotPhysics(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
modeLoadFromFridgeLoad(values.t_cold, values.t_hot, values.q_cold, mode),
|
||||
mode
|
||||
),
|
||||
[values.t_cold, values.t_hot, values.q_cold, mode]
|
||||
)
|
||||
|
||||
const unitE = qty === 'energy' ? 'kJ' : 'W'
|
||||
const workName =
|
||||
qty === 'energy'
|
||||
? fr
|
||||
? 'Travail'
|
||||
: 'Work'
|
||||
: fr
|
||||
? 'Puissance'
|
||||
: 'Power'
|
||||
|
||||
const maxRef = Math.max(phys.qh || 0, phys.qc || 0, phys.w || 0, 1)
|
||||
const cHot = intentColor('warning', dark)
|
||||
const cCold = intentColor('cache', dark)
|
||||
const cWork = intentColor('compute', dark)
|
||||
const text = dark ? '#e4e4e7' : '#27272a'
|
||||
|
||||
const cx = SVG_W / 2 - 20
|
||||
const hotY = 28
|
||||
const coldY = SVG_H - 42
|
||||
const midY = SVG_H / 2 - 4
|
||||
const r = 32
|
||||
const qOffset = 42
|
||||
const isEngine = mode === 'engine'
|
||||
|
||||
const loadMeta = useMemo(() => {
|
||||
if (mode === 'fridge') {
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_c' : '\\dot{Q}_c',
|
||||
label: fr ? 'Chaleur extraite (froid)' : 'Heat extracted (cold)',
|
||||
hint: fr
|
||||
? 'Charge utile du réfrigérateur'
|
||||
: 'Useful fridge cooling load',
|
||||
}
|
||||
}
|
||||
if (mode === 'heat_pump') {
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
|
||||
label: fr ? 'Chaleur fournie (chaud)' : 'Heat delivered (hot)',
|
||||
hint: fr ? 'Charge utile de la PAC' : 'Useful heat-pump output',
|
||||
}
|
||||
}
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
|
||||
label: fr ? 'Chaleur absorbée (chaud)' : 'Heat absorbed (hot)',
|
||||
hint: fr ? 'Entrée thermique du moteur' : 'Engine heat input',
|
||||
}
|
||||
}, [mode, qty, fr])
|
||||
|
||||
const modeLoad = modeLoadFromFridgeLoad(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
values.q_cold,
|
||||
mode
|
||||
)
|
||||
|
||||
const workSym = qty === 'energy' ? 'W' : 'P'
|
||||
const qLabel = (base: 'c' | 'h') => (qty === 'energy' ? `Q_${base}` : `Q̇_${base}`)
|
||||
|
||||
const lawLine = !phys.ok
|
||||
? fr
|
||||
? 'Il faut T_h > T_c (températures absolues).'
|
||||
: 'Need T_h > T_c (absolute temperatures).'
|
||||
: fr
|
||||
? `1ᵉʳ principe : ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
|
||||
: `1st law: ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel={fr ? 'Mode machine' : 'Machine mode'}
|
||||
options={MODES.map((m) => ({ id: m.id, label: fr ? m.fr : m.en }))}
|
||||
/>
|
||||
<Segmented
|
||||
value={qty}
|
||||
onChange={setQty}
|
||||
ariaLabel={fr ? 'Énergie ou puissance' : 'Energy or power'}
|
||||
options={QTY.map((q) => ({ id: q.id, label: fr ? q.fr : q.en }))}
|
||||
/>
|
||||
<Segmented
|
||||
value={tempUnit}
|
||||
onChange={setTempUnit}
|
||||
ariaLabel={fr ? 'Unité de température' : 'Temperature unit'}
|
||||
options={TEMP_UNITS.map((u) => ({ id: u.id, label: u.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="mx-auto w-full max-w-md"
|
||||
role="img"
|
||||
aria-label={fr ? sim.title.fr : sim.title.en}
|
||||
>
|
||||
<rect
|
||||
x={cx - 105}
|
||||
y={hotY - 18}
|
||||
width={210}
|
||||
height={34}
|
||||
rx={10}
|
||||
fill={`${cHot}14`}
|
||||
stroke={cHot}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={hotY}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={600}
|
||||
fill={cHot}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)}
|
||||
</text>
|
||||
|
||||
<rect
|
||||
x={cx - 105}
|
||||
y={coldY - 18}
|
||||
width={210}
|
||||
height={34}
|
||||
rx={10}
|
||||
fill={`${cCold}14`}
|
||||
stroke={cCold}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={coldY}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={600}
|
||||
fill={cCold}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Source froide' : 'Cold'} · T_c ={' '}
|
||||
{formatTemp(values.t_cold, tempUnit)}
|
||||
</text>
|
||||
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={midY}
|
||||
r={r}
|
||||
fill={dark ? '#141820' : '#ffffff'}
|
||||
stroke={text}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={midY}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill={text}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Machine' : 'Engine'}
|
||||
</text>
|
||||
|
||||
{phys.ok && !isEngine ? (
|
||||
<>
|
||||
{/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */}
|
||||
<FlowArrow
|
||||
x1={cx - qOffset}
|
||||
y1={coldY - 22}
|
||||
x2={cx - qOffset}
|
||||
y2={midY + r + 2}
|
||||
value={phys.qc}
|
||||
maxRef={maxRef}
|
||||
color={cCold}
|
||||
label={qLabel('c')}
|
||||
unit={unitE}
|
||||
labelSide="left"
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + qOffset}
|
||||
y1={midY - r - 2}
|
||||
x2={cx + qOffset}
|
||||
y2={hotY + 22}
|
||||
value={phys.qh}
|
||||
maxRef={maxRef}
|
||||
color={cHot}
|
||||
label={qLabel('h')}
|
||||
unit={unitE}
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + r + 78}
|
||||
y1={midY}
|
||||
x2={cx + r + 4}
|
||||
y2={midY}
|
||||
value={phys.w}
|
||||
maxRef={maxRef}
|
||||
color={cWork}
|
||||
label={workSym}
|
||||
unit={unitE}
|
||||
labelSide="above"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{phys.ok && isEngine ? (
|
||||
<>
|
||||
{/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */}
|
||||
<FlowArrow
|
||||
x1={cx + qOffset}
|
||||
y1={hotY + 22}
|
||||
x2={cx + qOffset}
|
||||
y2={midY - r - 2}
|
||||
value={phys.qh}
|
||||
maxRef={maxRef}
|
||||
color={cHot}
|
||||
label={qLabel('h')}
|
||||
unit={unitE}
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx - qOffset}
|
||||
y1={midY + r + 2}
|
||||
x2={cx - qOffset}
|
||||
y2={coldY - 22}
|
||||
value={phys.qc}
|
||||
maxRef={maxRef}
|
||||
color={cCold}
|
||||
label={qLabel('c')}
|
||||
unit={unitE}
|
||||
labelSide="left"
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + r + 4}
|
||||
y1={midY}
|
||||
x2={cx + r + 78}
|
||||
y2={midY}
|
||||
value={phys.w}
|
||||
maxRef={maxRef}
|
||||
color={cWork}
|
||||
label={workSym}
|
||||
unit={unitE}
|
||||
labelSide="above"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<text x={10} y={SVG_H - 10} fontSize={10} fill={dark ? '#8b8b93' : '#6b7280'}>
|
||||
{fr
|
||||
? qty === 'energy'
|
||||
? 'Épaisseur ∝ énergie (kJ) — W = travail (pas le watt)'
|
||||
: 'Épaisseur ∝ puissance — P et W (watt) = même unité ici'
|
||||
: qty === 'energy'
|
||||
? 'Thickness ∝ energy (kJ) — W = work (not the watt)'
|
||||
: 'Thickness ∝ power — P uses watts'}
|
||||
</text>
|
||||
</svg>
|
||||
<p className="mt-2 text-center text-[11px] text-muted-foreground">{lawLine}</p>
|
||||
{phys.ok && phys.entropyOk ? (
|
||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/80">
|
||||
{fr
|
||||
? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h'
|
||||
: '2nd law (reversible): Q_c/T_c = Q_h/T_h'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
|
||||
<div className="space-y-2">
|
||||
{sim.params
|
||||
.filter((p) => p.id === 't_cold' || p.id === 't_hot')
|
||||
.map((p) => {
|
||||
const meta = tempSliderMeta(p, tempUnit)
|
||||
const displayVal = kelvinToDisplay(values[p.id], tempUnit)
|
||||
return (
|
||||
<SimSlider
|
||||
key={`${p.id}-${tempUnit}`}
|
||||
symbol={p.symbol}
|
||||
label={fr ? p.label.fr : p.label.en}
|
||||
unit={meta.unitLabel}
|
||||
min={meta.min}
|
||||
max={meta.max}
|
||||
step={meta.step}
|
||||
value={
|
||||
tempUnit === 'K'
|
||||
? Math.round(displayVal)
|
||||
: Math.round(displayVal * 10) / 10
|
||||
}
|
||||
intent={p.intent}
|
||||
onChange={(v) => {
|
||||
const k = displayToKelvin(v, tempUnit)
|
||||
const clamped = Math.min(p.max, Math.max(p.min, k))
|
||||
setValues((s) => ({ ...s, [p.id]: clamped }))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<SimSlider
|
||||
key={`load-${mode}-${qty}`}
|
||||
symbol={loadMeta.symbol}
|
||||
label={loadMeta.label}
|
||||
unit={unitE}
|
||||
min={10}
|
||||
max={500}
|
||||
step={5}
|
||||
value={Math.round(modeLoad * 10) / 10}
|
||||
intent="flow"
|
||||
onChange={(v) => {
|
||||
const fridgeQc = fridgeLoadFromModeLoad(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
v,
|
||||
mode
|
||||
)
|
||||
setValues((s) => ({
|
||||
...s,
|
||||
q_cold: Math.min(500, Math.max(10, fridgeQc)),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<p className="px-1 text-[10px] text-muted-foreground">{loadMeta.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SimHeading>
|
||||
{fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'}
|
||||
</SimHeading>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{mode === 'fridge' || mode === 'heat_pump' ? (
|
||||
<>
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\mathrm{COP}_{R}`}
|
||||
label={fr ? 'COP frigo' : 'Fridge COP'}
|
||||
value={phys.copR}
|
||||
intent="output"
|
||||
digits={2}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\mathrm{COP}_{HP}`}
|
||||
label={fr ? 'COP PAC' : 'Heat-pump COP'}
|
||||
value={phys.copHP}
|
||||
intent="output"
|
||||
digits={2}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\eta`}
|
||||
label={fr ? 'Rendement moteur' : 'Engine efficiency'}
|
||||
value={phys.eta * 100}
|
||||
unit="%"
|
||||
intent="highlight"
|
||||
digits={1}
|
||||
/>
|
||||
)}
|
||||
<SimOutputCard
|
||||
symbol={workSym}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? `${workName} fourni`
|
||||
: `${workName} out`
|
||||
: fr
|
||||
? `${workName} minimale`
|
||||
: `Minimum ${workName.toLowerCase()}`
|
||||
}
|
||||
value={phys.w}
|
||||
unit={unitE}
|
||||
intent="compute"
|
||||
digits={1}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={
|
||||
qty === 'energy' ? String.raw`Q_h` : String.raw`\dot{Q}_h`
|
||||
}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? 'Chaleur absorbée'
|
||||
: 'Heat absorbed'
|
||||
: fr
|
||||
? 'Chaleur côté chaud'
|
||||
: 'Hot-side heat'
|
||||
}
|
||||
value={phys.qh}
|
||||
unit={unitE}
|
||||
intent="flow"
|
||||
digits={1}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={
|
||||
qty === 'energy' ? String.raw`Q_c` : String.raw`\dot{Q}_c`
|
||||
}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? 'Chaleur rejetée (froid)'
|
||||
: 'Heat rejected (cold)'
|
||||
: fr
|
||||
? 'Chaleur côté froid'
|
||||
: 'Cold-side heat'
|
||||
}
|
||||
value={phys.qc}
|
||||
unit={unitE}
|
||||
intent="cache"
|
||||
digits={1}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
{qty === 'energy'
|
||||
? fr
|
||||
? 'W = travail (énergie en kJ), pas le watt. Passe en « Puissance (W) » pour raisonner en watts.'
|
||||
: 'W = work (energy in kJ), not the watt. Switch to “Power (W)” to use watts.'
|
||||
: fr
|
||||
? 'Mode puissance : P, Q̇_c et Q̇_h sont en watts (W). Les COP / η restent sans unité.'
|
||||
: 'Power mode: P, Q̇_c and Q̇_h are in watts (W). COP / η stay dimensionless.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{disclaimer ? (
|
||||
<p className="mt-3 text-xs italic text-muted-foreground">{disclaimer}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
memento-note/components/simulators/generic-formula-view.tsx
Normal file
205
memento-note/components/simulators/generic-formula-view.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceDot,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Bar,
|
||||
BarChart,
|
||||
} from 'recharts'
|
||||
import type { GenericFormulaSim } from '@/lib/interactive-page'
|
||||
import { parseSimExpr } from '@/lib/interactive-page/sim-eval'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
|
||||
|
||||
const CURVE_SAMPLES = 60
|
||||
|
||||
/** Generic slider-driven formula simulator (safe exprs, no eval). */
|
||||
export function GenericFormulaView({
|
||||
sim,
|
||||
lang,
|
||||
}: {
|
||||
sim: GenericFormulaSim
|
||||
lang: string
|
||||
}) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const [values, setValues] = useState<Record<string, number>>(() => {
|
||||
const env: Record<string, number> = {}
|
||||
for (const p of sim.params) env[p.id] = p.defaultValue
|
||||
return env
|
||||
})
|
||||
|
||||
const compiled = useMemo(
|
||||
() =>
|
||||
sim.computed.map((c) => ({
|
||||
def: c,
|
||||
parsed: parseSimExpr(c.expr),
|
||||
})),
|
||||
[sim.computed]
|
||||
)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const env = { ...values }
|
||||
const out: Record<string, number> = {}
|
||||
for (const c of compiled) {
|
||||
const v = 'message' in c.parsed ? NaN : c.parsed.evaluate(env)
|
||||
env[c.def.id] = v
|
||||
out[c.def.id] = v
|
||||
}
|
||||
return out
|
||||
}, [compiled, values])
|
||||
|
||||
const visual = sim.visual
|
||||
const curve = useMemo(() => {
|
||||
if (visual.kind !== 'curve') return null
|
||||
const xParam = sim.params.find((p) => p.id === visual.xParamId)
|
||||
const parsed = parseSimExpr(visual.expr)
|
||||
if (!xParam || 'message' in parsed) return null
|
||||
const pts: { x: number; y: number }[] = []
|
||||
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
||||
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
|
||||
const y = parsed.evaluate({ ...values, [xParam.id]: x })
|
||||
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
|
||||
}
|
||||
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) }
|
||||
}, [visual, sim.params, values])
|
||||
|
||||
const accent = intentColor('highlight', dark)
|
||||
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sim.intro ? (
|
||||
<p className="mb-4 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{sim.intro}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
|
||||
<div className="space-y-2">
|
||||
{sim.params.map((p) => (
|
||||
<SimSlider
|
||||
key={p.id}
|
||||
symbol={p.symbol}
|
||||
label={p.label}
|
||||
unit={p.unit}
|
||||
min={p.min}
|
||||
max={p.max}
|
||||
step={p.step}
|
||||
value={values[p.id]}
|
||||
intent={p.intent}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [p.id]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SimHeading>{fr ? 'Résultats' : 'Results'}</SimHeading>
|
||||
{sim.visual.kind === 'gauges' ? (
|
||||
<div className="space-y-2">
|
||||
{sim.computed.map((c) => {
|
||||
const v = results[c.id]
|
||||
const maxRef = Math.max(
|
||||
...sim.computed.map((o) => Math.abs(results[o.id] || 0)),
|
||||
1
|
||||
)
|
||||
const color = intentColor(c.intent, dark)
|
||||
return (
|
||||
<div key={c.id} className="rounded-xl border border-border/50 bg-background/70 px-3 py-2">
|
||||
<div className="flex items-baseline justify-between text-xs">
|
||||
<span className="text-muted-foreground">{c.label}</span>
|
||||
<span className="font-semibold tabular-nums" style={{ color }}>
|
||||
{Number.isFinite(v) ? v.toFixed(2) : '—'}
|
||||
{c.unit ? ` ${c.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted/60">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-150"
|
||||
style={{
|
||||
width: `${Math.min(100, (Math.abs(v || 0) / maxRef) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sim.visual.kind === 'bars' ? (
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sim.computed.map((c) => ({
|
||||
name: c.label,
|
||||
value: Number.isFinite(results[c.id]) ? results[c.id] : 0,
|
||||
}))}
|
||||
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill={accent} radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sim.visual.kind === 'curve' && curve ? (
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={curve.pts} margin={{ top: 8, right: 8, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={[curve.xParam.min, curve.xParam.max]}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: number) => String(Math.round(v))}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="y" stroke={accent} strokeWidth={2.5} dot={false} isAnimationActive={false} />
|
||||
{Number.isFinite(curve.currentY) ? (
|
||||
<ReferenceDot x={curve.currentX} y={curve.currentY} r={5} fill={accent} stroke="none" />
|
||||
) : null}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sim.computed.map((c) => (
|
||||
<SimOutputCard
|
||||
key={c.id}
|
||||
symbol={c.symbol}
|
||||
label={c.label}
|
||||
value={results[c.id]}
|
||||
unit={c.unit}
|
||||
intent={c.intent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sim.disclaimer ? (
|
||||
<p className="mt-3 text-xs italic text-muted-foreground">{sim.disclaimer}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
memento-note/components/simulators/index.ts
Normal file
27
memento-note/components/simulators/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { CarnotCycleView } from './carnot-cycle-view'
|
||||
import { CarnotCycleAnimView } from './carnot-cycle-anim-view'
|
||||
import { TsDiagramView } from './ts-diagram-view'
|
||||
|
||||
export type CatalogSimViewProps = {
|
||||
preset?: Record<string, number>
|
||||
title?: string
|
||||
disclaimer?: string
|
||||
lang: string
|
||||
}
|
||||
|
||||
export type CatalogAnimViewProps = {
|
||||
step: number
|
||||
lang: string
|
||||
}
|
||||
|
||||
/** Registry: catalog simId → bespoke slider-simulation view. */
|
||||
export const SIMULATOR_VIEWS: Record<string, ComponentType<CatalogSimViewProps>> = {
|
||||
'carnot-cycle': CarnotCycleView,
|
||||
}
|
||||
|
||||
/** Registry: catalog simId → bespoke animated scene (step-driven). */
|
||||
export const ANIM_VIEWS: Record<string, ComponentType<CatalogAnimViewProps>> = {
|
||||
'carnot-cycle-anim': CarnotCycleAnimView,
|
||||
'ts-diagram': TsDiagramView,
|
||||
}
|
||||
144
memento-note/components/simulators/sim-controls.tsx
Normal file
144
memento-note/components/simulators/sim-controls.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SimKaTeX({ tex, className }: { tex: string; className?: string }) {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
// output:'html' avoids MathML annotation leaking as visible "mathrm{…}" in flex layouts
|
||||
return katex.renderToString(tex, { throwOnError: false, output: 'html' })
|
||||
} catch {
|
||||
return tex
|
||||
}
|
||||
}, [tex])
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-block max-w-full [&_.katex]:text-[1em]', className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimSlider({
|
||||
symbol,
|
||||
label,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
intent,
|
||||
onChange,
|
||||
}: {
|
||||
symbol: string
|
||||
label: string
|
||||
unit?: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
value: number
|
||||
intent?: IntentId
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const accent = intentColor(intent, dark)
|
||||
return (
|
||||
<label className="block rounded-xl border border-border/50 bg-background/70 px-3 py-2.5">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="flex items-baseline gap-2 text-sm font-medium">
|
||||
<SimKaTeX tex={symbol} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
<span
|
||||
className="rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums"
|
||||
style={{ color: accent, backgroundColor: `${accent}18` }}
|
||||
>
|
||||
{value}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="sim-slider mt-2 w-full"
|
||||
style={{ accentColor: accent }}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="mt-0.5 flex justify-between text-[10px] tabular-nums text-muted-foreground/70">
|
||||
<span>
|
||||
{min}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{max}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimOutputCard({
|
||||
symbol,
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
intent,
|
||||
digits = 2,
|
||||
}: {
|
||||
symbol: string
|
||||
label: string
|
||||
value: number
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
digits?: number
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const accent = intentColor(intent, dark)
|
||||
const finite = Number.isFinite(value)
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-background/70 px-3 py-2.5 text-center">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 flex items-baseline justify-center gap-1.5">
|
||||
<SimKaTeX tex={symbol} className="text-sm" />
|
||||
<span
|
||||
className={cn('text-lg font-semibold tabular-nums', !finite && 'text-muted-foreground')}
|
||||
style={finite ? { color: accent } : undefined}
|
||||
>
|
||||
{finite ? value.toFixed(digits) : '—'}
|
||||
{finite && unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small section heading inside a simulator card. */
|
||||
export function SimHeading({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
|
||||
/**
|
||||
* T–s diagram of the Carnot cycle — canonical 2nd-law diagram.
|
||||
* Driven by AnimPlayerShell via `step` (0..4). SVG, transform/opacity only.
|
||||
* Cycle = rectangle: horizontal isotherms (Th, Tc), vertical adiabatics.
|
||||
*/
|
||||
|
||||
const X0 = 110 // y-axis x
|
||||
const Y0 = 280 // x-axis y
|
||||
const S1 = 190 // entropy left
|
||||
const S2 = 520 // entropy right
|
||||
const YH = 80 // T_h line y
|
||||
const YC = 220 // T_c line y
|
||||
|
||||
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
|
||||
|
||||
// marker position per beat (end of each phase)
|
||||
const MARKER = [
|
||||
{ x: S2, y: YH }, // b1 end: right-top
|
||||
{ x: S2, y: YC }, // b2 end: right-bottom
|
||||
{ x: S1, y: YC }, // b3 end: left-bottom
|
||||
{ x: S1, y: YH }, // b4 end: left-top
|
||||
{ x: S1, y: YH }, // b5: stay
|
||||
]
|
||||
|
||||
export function TsDiagramView({ step, lang }: { step: number; lang: string }) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const s = Math.min(step, 4)
|
||||
|
||||
const ink = dark ? '#EDE7DB' : '#242422'
|
||||
const muted = dark ? '#A39C8D' : '#686762'
|
||||
const plum = dark ? '#D07AA6' : '#9F3F70'
|
||||
const blue = dark ? '#7FA8CC' : '#3F6F9F'
|
||||
const paper = dark ? '#17140F' : '#F4F0E8'
|
||||
|
||||
const m = MARKER[s]
|
||||
const qhW = S2 - S1
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 320"
|
||||
className="w-full"
|
||||
role="img"
|
||||
aria-label={fr ? 'Diagramme T–s du cycle de Carnot' : 'T–s diagram of the Carnot cycle'}
|
||||
style={{ background: paper, display: 'block' }}
|
||||
>
|
||||
{/* axes */}
|
||||
<line x1={X0} y1={Y0} x2={650} y2={Y0} stroke={ink} strokeWidth={1.5} />
|
||||
<line x1={X0} y1={Y0} x2={X0} y2={30} stroke={ink} strokeWidth={1.5} />
|
||||
<text x={648} y={Y0 + 18} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">s</text>
|
||||
<text x={X0 - 8} y={40} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">T</text>
|
||||
|
||||
{/* isotherm T_h */}
|
||||
<line x1={X0} y1={YH} x2={650} y2={YH} stroke={plum} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
|
||||
<text x={X0 - 8} y={YH + 4} fontSize={11} fill={plum} textAnchor="end" fontWeight={600}>T_h</text>
|
||||
{/* isotherm T_c */}
|
||||
<line x1={X0} y1={YC} x2={650} y2={YC} stroke={blue} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
|
||||
<text x={X0 - 8} y={YC + 4} fontSize={11} fill={blue} textAnchor="end" fontWeight={600}>T_c</text>
|
||||
|
||||
{/* Q_h area (beat ≥ 0, shown from beat 0) */}
|
||||
<rect
|
||||
x={S1} y={YH} width={qhW} height={Y0 - YH}
|
||||
fill={plum} opacity={s >= 0 ? 0.10 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s === 0 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YH + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={plum}>Q_h</text>
|
||||
) : null}
|
||||
{/* Q_c area (from beat 2) */}
|
||||
<rect
|
||||
x={S1} y={YC} width={qhW} height={Y0 - YC}
|
||||
fill={blue} opacity={s >= 2 ? 0.16 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s >= 2 && s < 4 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YC + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={blue}>Q_c</text>
|
||||
) : null}
|
||||
{/* W = cycle area (beat 4) */}
|
||||
<rect
|
||||
x={S1} y={YH} width={qhW} height={YC - YH}
|
||||
fill={plum} opacity={s === 4 ? 0.18 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s === 4 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YH + YC) / 2} textAnchor="middle" fontSize={17} fontWeight={800} fill={plum}>W</text>
|
||||
) : null}
|
||||
|
||||
{/* cycle edges, drawn progressively */}
|
||||
{/* top: b1 (isothermal expansion) */}
|
||||
<line x1={S1} y1={YH} x2={S2} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 0 ? 1 : 0.15} />
|
||||
<polygon points={`${S2 - 14},${YH - 5} ${S2 - 4},${YH} ${S2 - 14},${YH + 5}`} fill={ink} opacity={s >= 0 ? 1 : 0.15} />
|
||||
{/* right: b2 (adiabatic expansion) */}
|
||||
<line x1={S2} y1={YH} x2={S2} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S2 - 5},${YC - 14} ${S2},${YC - 4} ${S2 + 5},${YC - 14}`} fill={ink} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* bottom: b3 (isothermal compression) */}
|
||||
<line x1={S2} y1={YC} x2={S1} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S1 + 14},${YC - 5} ${S1 + 4},${YC} ${S1 + 14},${YC + 5}`} fill={ink} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* left: b4 (adiabatic compression) */}
|
||||
<line x1={S1} y1={YC} x2={S1} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S1 - 5},${YH + 14} ${S1},${YH + 4} ${S1 + 5},${YH + 14}`} fill={ink} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
|
||||
{/* entropy ticks */}
|
||||
<text x={S1} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s₁</text>
|
||||
<text x={S2} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s₂</text>
|
||||
|
||||
{/* state marker */}
|
||||
<circle cx={m.x} cy={m.y} r={6} fill={plum} stroke={paper} strokeWidth={2} style={{ transition: EASE }} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
|
||||
import {
|
||||
validateInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
function InteractiveDemoView(props: NodeViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const raw = props.node.attrs.demoJson as string
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
try {
|
||||
const data = JSON.parse(raw || '{}')
|
||||
return validateInteractiveDemo(data)
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }],
|
||||
}
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
if (!parsed.ok) {
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="interactive-demo-block my-4"
|
||||
data-drag-handle
|
||||
contentEditable={false}
|
||||
>
|
||||
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
|
||||
</p>
|
||||
<ul className="mt-1 text-muted-foreground list-disc list-inside">
|
||||
{parsed.issues.slice(0, 5).map((iss, i) => (
|
||||
<li key={i}>
|
||||
{iss.path ? `${iss.path}: ` : ''}
|
||||
{iss.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="interactive-demo-block my-4"
|
||||
data-drag-handle
|
||||
contentEditable={false}
|
||||
>
|
||||
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const InteractiveDemoExtension = Node.create({
|
||||
name: 'interactiveDemo',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
demoJson: {
|
||||
default: '{}',
|
||||
parseHTML: (el) => el.getAttribute('data-demo-json') || '{}',
|
||||
renderHTML: (attrs) => ({
|
||||
'data-demo-json': attrs.demoJson || '{}',
|
||||
}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-interactive-demo]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }),
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(InteractiveDemoView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertInteractiveDemoAtSelection(
|
||||
editor: Editor,
|
||||
demo?: InteractiveDemoV1
|
||||
): boolean {
|
||||
const type = editor.schema.nodes.interactiveDemo
|
||||
if (!type) return false
|
||||
|
||||
const payload = demo ?? (attnresFixture as InteractiveDemoV1)
|
||||
const check = validateInteractiveDemo(payload)
|
||||
// Prefer validated shape; if already-server-validated payload fails client Zod
|
||||
// (HMR drift), still insert so the block appears — NodeView shows issues if needed.
|
||||
const attrs = {
|
||||
demoJson: JSON.stringify(check.ok ? check.demo : payload),
|
||||
}
|
||||
const { empty, $from } = editor.state.selection
|
||||
const pos = empty ? $from.pos : editor.state.selection.from
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, { type: 'interactiveDemo', attrs })
|
||||
.run()
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
interactive_demo: 'usageMeter.featureInteractiveDemo',
|
||||
interactive_page: 'usageMeter.featureInteractivePage',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
|
||||
29
memento-note/docs/PUBLISHING.md
Normal file
29
memento-note/docs/PUBLISHING.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Publication — point de branchement `interactive-page`
|
||||
|
||||
## Canal existant (ne pas dupliquer)
|
||||
|
||||
| Élément | Emplacement |
|
||||
|---|---|
|
||||
| API | `app/api/notes/publish/route.ts` — `action: publish \| unpublish`, `mode?: simple \| ai \| interactive-page` |
|
||||
| UI | `components/note-editor/note-editor-toolbar.tsx` (menu Globe) + `InteractivePagePublishDialog` |
|
||||
| Page publique | `app/(public)/p/[slug]/page.tsx` → `/p/{publicSlug}` |
|
||||
| Fetch | `getPublishedNote(slug)` dans `app/actions/notes-publishing.ts` |
|
||||
| Templates | `lib/publish/types.ts` — `PUBLISH_TEMPLATES` = `magazine \| brief \| essay \| interactive-page` |
|
||||
|
||||
### Champs `Note` (Prisma)
|
||||
|
||||
- `isPublic`, `publicSlug`, `publishedAt`
|
||||
- `publishedContent` — snapshot opaque (`String?`) : HTML aujourd’hui, **JSON PageSpecV1** pour `interactive-page`
|
||||
- `publishedTemplate` — discriminant de rendu
|
||||
- `publishedSourceHash` — détection stale (pas de régénération silencieuse)
|
||||
|
||||
## Branchement `interactive-page`
|
||||
|
||||
1. Étendre `PUBLISH_TEMPLATES` avec `'interactive-page'`.
|
||||
2. Publish : générer/valider via `lib/interactive-page/` → stocker `JSON.stringify(PageSpecV1)` dans `publishedContent`, `publishedTemplate = 'interactive-page'`.
|
||||
3. Rendu public : dans `PublishedNotePage`, si `publishedTemplate === 'interactive-page'` → parser → `<PageView spec={…} />` (SSR + hydrate démos).
|
||||
4. Unpublish / stale : **même** cycle de vie que magazine/brief/essay (`publishedSourceHash` → badge « à régénérer »).
|
||||
|
||||
## Hors scope
|
||||
|
||||
- `NotebookSite` `/c/[slug]`, `NoteShare` (collab privée), server actions legacy `publishNote` incomplets.
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { InteractiveDemoV1, ValidationIssue } from '@/lib/interactive-demo'
|
||||
|
||||
export type GenerateInteractiveDemoResponse =
|
||||
| { ok: true; demo: InteractiveDemoV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
issues?: ValidationIssue[]
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export async function generateInteractiveDemo(params: {
|
||||
content: string
|
||||
selection?: string | null
|
||||
lang?: string
|
||||
noteId?: string
|
||||
}): Promise<GenerateInteractiveDemoResponse> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 170_000)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/ai/interactive-demo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: params.content,
|
||||
selection: params.selection,
|
||||
lang: params.lang,
|
||||
noteId: params.noteId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
const aborted = err instanceof Error && err.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
error: aborted
|
||||
? 'Génération trop longue — réessaie (ou raccourcis la note)'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Network error',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
issues: data.issues,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
demo: data.demo,
|
||||
attempts: data.attempts ?? 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { generateText } from 'ai'
|
||||
import type { AIProvider } from '@/lib/ai/types'
|
||||
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
|
||||
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||
import {
|
||||
INTERACTIVE_DEMO_SCHEMA_VERSION,
|
||||
PATTERN_IDS,
|
||||
PANEL_TYPES,
|
||||
INTENT_IDS,
|
||||
ANNOTATION_KINDS,
|
||||
SPEAK_WRITING_RULE,
|
||||
validateInteractiveDemo,
|
||||
normalizeInteractiveDemoCandidate,
|
||||
type InteractiveDemoV1,
|
||||
type ValidationIssue,
|
||||
} from '@/lib/interactive-demo'
|
||||
|
||||
const MAX_ATTEMPTS = 2
|
||||
|
||||
function stripToPlain(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function extractJsonObject(raw: string): unknown | null {
|
||||
if (!raw) return null
|
||||
const cleaned = cleanAIJsonResponse(raw)
|
||||
const tryParse = (s: string): unknown | null => {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
|
||||
|
||||
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
|
||||
if (parsed) return parsed
|
||||
|
||||
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||
const candidate = fence?.[1]?.trim() ?? cleaned
|
||||
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
|
||||
if (parsed) return parsed
|
||||
|
||||
const start = candidate.indexOf('{')
|
||||
const end = candidate.lastIndexOf('}')
|
||||
if (start >= 0 && end > start) {
|
||||
const slice = candidate.slice(start, end + 1)
|
||||
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
|
||||
if (parsed) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** STEM cycle example — equations in speak + node labels via $...$ */
|
||||
const STEM_CYCLE_EXAMPLE = `{
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.refrigeration-cycle",
|
||||
"lang": "fr",
|
||||
"disclaimer": "Schéma pédagogique — grandeurs illustratives.",
|
||||
"scene": {
|
||||
"id": "scene.cycle",
|
||||
"panels": [{
|
||||
"id": "panel.cycle",
|
||||
"type": "svg-scene",
|
||||
"payload": {
|
||||
"nodes": [
|
||||
{ "id": "compressor", "label": "1 · Compresseur\\n$W = h_2 - h_1$", "intent": "compute" },
|
||||
{ "id": "condenser", "label": "2 · Condenseur\\n$Q_c = h_2 - h_3$", "intent": "output" },
|
||||
{ "id": "expansion", "label": "3 · Détente\\n$h_3 = h_4$", "intent": "flow" },
|
||||
{ "id": "evaporator", "label": "4 · Évaporateur\\n$Q_e = h_1 - h_4$", "intent": "cache" }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e12", "from": "compressor", "to": "condenser", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e23", "from": "condenser", "to": "expansion", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e34", "from": "expansion", "to": "evaporator", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e41", "from": "evaporator", "to": "compressor", "style": "solid", "intent": "flow" }
|
||||
]
|
||||
}
|
||||
}]
|
||||
},
|
||||
"acts": [{
|
||||
"id": "a1",
|
||||
"title": "Cycle frigorifique",
|
||||
"pattern": "flowTrace",
|
||||
"steps": [
|
||||
{ "id": "a1.s1", "speak": "Compression : le travail fourni est $W = h_2 - h_1$.", "pattern": "spotlightTour", "pointTo": ["compressor"], "reveal": [{"ids":["compressor"],"scope":"act"}] },
|
||||
{ "id": "a1.s2", "speak": "Au condenseur, la chaleur rejetée : $Q_c = h_2 - h_3$.", "pattern": "spotlightTour", "pointTo": ["condenser"], "reveal": [{"ids":["condenser","e12"],"scope":"act"}] },
|
||||
{ "id": "a1.s3", "speak": "Détente isenthalpique : $h_3 = h_4$.", "pattern": "spotlightTour", "pointTo": ["expansion"], "reveal": [{"ids":["expansion","e23"],"scope":"act"}] },
|
||||
{ "id": "a1.s4", "speak": "Évaporateur : $Q_e = h_1 - h_4$. COP $= Q_e / W$.", "pattern": "overview", "pointTo": ["evaporator"], "reveal": [{"ids":["evaporator","e34","e41"],"scope":"act"}] }
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
function buildSystemPrompt(lang: string, hasMath: boolean): string {
|
||||
return `You generate Interactive Demo JSON for Memento notes (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}).
|
||||
|
||||
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
|
||||
|
||||
MISSION: teach the ACTUAL content of the note — concepts, quantities, AND equations.
|
||||
You are NOT allowed to invent a toy 2-node graph ("A" → "B") that ignores the note.
|
||||
|
||||
CONTENT FIDELITY (critical):
|
||||
- Read EVERY extracted formula. Put the important ones in speak as inline KaTeX: $...$
|
||||
- Put key equations on the matching node labels too (use \\n then $latex$).
|
||||
- Use the note's real names (Compresseur, Condenseur, COP, …) — never vague placeholders.
|
||||
- For a physical CYCLE / loop (frigo, Carnot, Rankine, feedback…): 4+ stage nodes + cycle edges closing the loop.
|
||||
- For a derivation: nodes = successive expressions / steps, speak cites the formula at each step.
|
||||
- Min 3 nodes for svg-scene (4+ for cycles). Min 3 steps. Prefer 4–6 steps.
|
||||
- NEVER output only 2 boxes with plain words and zero equations when the note has math.
|
||||
|
||||
PANEL CHOICE:
|
||||
- Process / cycle / architecture → svg-scene (rich graph)
|
||||
- Time series / comparison of measured values → chart
|
||||
- Matrix / attention / correlation → heatmap-matrix
|
||||
- Max 2 panels. May combine svg-scene + chart if useful.
|
||||
|
||||
SCHEMA:
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.<slug>",
|
||||
"lang": "${lang}",
|
||||
"disclaimer?": string,
|
||||
"scene": { "id?": string, "panels": [Panel] },
|
||||
"acts": [ { "id": "a1", "title": string, "pattern?", "steps": [Step] } ]
|
||||
}
|
||||
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?", "reveal?":[{"ids":[],"scope":"transient|act|scene"}], "annotate?":[{"kind","targetIds","scope","text?","intent?"}] }
|
||||
Panel types: ${PANEL_TYPES.join(', ')}
|
||||
Patterns: ${PATTERN_IDS.join(', ')}
|
||||
Intents: ${INTENT_IDS.join(', ')}
|
||||
Annotation kinds: ${ANNOTATION_KINDS.join(', ')}
|
||||
chartType: line | bar | area
|
||||
Heatmap cells: r{row}.c{col}; triangular:"lower" when appropriate.
|
||||
svg-scene: nodes[{id,label?,intent?}], edges[{id,from,to,style?,weight?,intent?}]
|
||||
chart: series[{id,label?,values:number[],intent?}]
|
||||
|
||||
${hasMath ? `STEM MODE ON: formulas were extracted from the note. You MUST:
|
||||
- Include at least 2 distinct equations from FORMULES_EXTRAITES in speak and/or node labels as $latex$
|
||||
- Prefer flowTrace / spotlightTour over vague overview-only demos
|
||||
- Example shape to imitate (adapt to THIS note's real formulas/stages):
|
||||
${STEM_CYCLE_EXAMPLE}` : `If the note has little math, still build a faithful conceptual diagram (≥3 nodes) from the real vocabulary of the note.`}
|
||||
|
||||
RULES:
|
||||
- ${SPEAK_WRITING_RULE}
|
||||
- speak may include light markdown + inline $KaTeX$ (required for STEM).
|
||||
- Never put hex/rgb colors — intents only.
|
||||
- Never annotate an id that is also in pointTo on the same step.
|
||||
- Step ids = a1.s1, a1.s2… matching act id.
|
||||
- All pointTo/reveal/annotate ids must exist in the active scene.
|
||||
- Wildcard "*" reveal ONLY for heatmap/chart panels — not svg-scene alone.
|
||||
- Narration language = lang ("${lang}").
|
||||
- disclaimer when values are pedagogical/illustrative.`
|
||||
}
|
||||
|
||||
function buildUserPrompt(
|
||||
content: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>,
|
||||
opts?: {
|
||||
issues?: ValidationIssue[]
|
||||
previousJson?: string
|
||||
}
|
||||
): string {
|
||||
const plain = stripToPlain(content).slice(0, 7000)
|
||||
let msg = `Create an Interactive Demo that teaches THIS note faithfully (equations included).
|
||||
|
||||
EXCERPT_START
|
||||
${plain}
|
||||
EXCERPT_END
|
||||
`
|
||||
|
||||
if (assets.formulas.length) {
|
||||
msg += `\nFORMULES_EXTRAITES (OBLIGATOIRE — réutilise telles quelles en $...$ dans speak et labels):\n`
|
||||
msg += assets.formulas
|
||||
.slice(0, 16)
|
||||
.map((f, i) => `${i + 1}. ${f}`)
|
||||
.join('\n')
|
||||
msg += '\n'
|
||||
}
|
||||
|
||||
if (assets.keySentences.length) {
|
||||
msg += `\nPHRASES_CLES:\n`
|
||||
msg += assets.keySentences
|
||||
.slice(0, 8)
|
||||
.map((s) => `- ${s}`)
|
||||
.join('\n')
|
||||
msg += '\n'
|
||||
}
|
||||
|
||||
if (assets.numbers.length) {
|
||||
msg += `\nDONNEES_NUMERIQUES:\n${JSON.stringify(assets.numbers.slice(0, 8))}\n`
|
||||
}
|
||||
|
||||
msg += `\nCONTRAINTES: ≥3 nœuds svg (cycle → 4+ et boucle fermée). speak avec $équations$ si FORMULES_EXTRAITES non vide. Interdit: schéma jouet à 2 boîtes sans maths.\n`
|
||||
|
||||
if (opts?.issues?.length) {
|
||||
msg += `\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
|
||||
msg += opts.issues
|
||||
.slice(0, 12)
|
||||
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
|
||||
.join('\n')
|
||||
}
|
||||
if (opts?.previousJson) {
|
||||
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 12000)}\nPREVIOUS_JSON_END`
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
export type GenerateInteractiveDemoInput = {
|
||||
content: string
|
||||
lang?: string
|
||||
provider: AIProvider
|
||||
}
|
||||
|
||||
export type GenerateInteractiveDemoResult =
|
||||
| { ok: true; demo: InteractiveDemoV1; attempts: number }
|
||||
| { ok: false; issues: ValidationIssue[]; raw?: string; attempts: number }
|
||||
|
||||
/**
|
||||
* LLM → JSON → normalize → validateInteractiveDemo, with repair passes.
|
||||
* Harvests formulas like slide generation before prompting.
|
||||
*/
|
||||
export async function generateInteractiveDemoFromContent(
|
||||
input: GenerateInteractiveDemoInput
|
||||
): Promise<GenerateInteractiveDemoResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const system = buildSystemPrompt(lang, assets.hasMath || assets.formulas.length > 0)
|
||||
const model = input.provider.getModel()
|
||||
let lastIssues: ValidationIssue[] = []
|
||||
let lastRaw = ''
|
||||
let lastNormalizedJson = ''
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const user = buildUserPrompt(input.content, assets, {
|
||||
issues: attempt > 1 ? lastIssues : undefined,
|
||||
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
|
||||
})
|
||||
|
||||
const { text: raw } = await generateText({
|
||||
model,
|
||||
system,
|
||||
prompt: user,
|
||||
temperature: attempt === 1 ? 0.25 : 0.1,
|
||||
})
|
||||
lastRaw = raw
|
||||
|
||||
const parsed = extractJsonObject(raw)
|
||||
if (!parsed) {
|
||||
lastIssues = [
|
||||
{
|
||||
code: 'invalid_json',
|
||||
path: '',
|
||||
message: 'Model did not return parseable JSON',
|
||||
},
|
||||
]
|
||||
console.warn(
|
||||
`[interactive-demo] attempt ${attempt}: unparseable JSON`,
|
||||
raw.slice(0, 400)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeInteractiveDemoCandidate(parsed, lang)
|
||||
lastNormalizedJson = JSON.stringify(normalized)
|
||||
|
||||
const result = validateInteractiveDemo(normalized)
|
||||
if (result.ok) {
|
||||
return { ok: true, demo: result.demo, attempts: attempt }
|
||||
}
|
||||
lastIssues = result.issues
|
||||
console.warn(
|
||||
`[interactive-demo] attempt ${attempt}: validation failed`,
|
||||
result.issues.slice(0, 8)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
issues: lastIssues,
|
||||
raw: lastRaw,
|
||||
attempts: MAX_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
211
memento-note/lib/ai/services/interactive-page-client.service.ts
Normal file
211
memento-note/lib/ai/services/interactive-page-client.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
PageSection,
|
||||
PageSpecV1,
|
||||
PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
|
||||
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
||||
|
||||
export type PagePlanSection = {
|
||||
title: string
|
||||
goal: string
|
||||
demoKind: PagePlanDemoKind
|
||||
demoGoal?: string
|
||||
}
|
||||
|
||||
export type PagePlan = {
|
||||
heroTitle: string
|
||||
heroSubtitle?: string
|
||||
overviewLead: string
|
||||
overviewCards: {
|
||||
badge: string
|
||||
title: string
|
||||
body: string
|
||||
intent?: string
|
||||
}[]
|
||||
sections: PagePlanSection[]
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageResponse =
|
||||
| { ok: true; page: PageSpecV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
reason?: string
|
||||
issues?: PageValidationIssue[]
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export type GeneratePlanResponse =
|
||||
| { ok: true; plan: PagePlan; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
reason?: string
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export type GenerateSectionResponse =
|
||||
| { ok: true; section: PageSection; attempts: number }
|
||||
| { ok: false; error: string; status?: number }
|
||||
|
||||
async function postJson(
|
||||
body: Record<string, unknown>,
|
||||
timeoutMs = 55_000
|
||||
): Promise<{ res: Response; data: Record<string, never> } | { abortError: true }> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch('/api/ai/interactive-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { res, data }
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { abortError: true }
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM plan (billed — 20 crédits). */
|
||||
export async function generateInteractivePagePlan(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
}): Promise<GeneratePlanResponse> {
|
||||
let out: Awaited<ReturnType<typeof postJson>>
|
||||
try {
|
||||
out = await postJson({ ...params, action: 'plan' })
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
}
|
||||
}
|
||||
if ('abortError' in out) {
|
||||
return { ok: false, error: 'Génération trop longue — réessaie' }
|
||||
}
|
||||
const { res, data } = out as {
|
||||
res: Response
|
||||
|
||||
data: any
|
||||
}
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
reason: data.reason,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 }
|
||||
}
|
||||
|
||||
/** LLM single section (not billed — page billed at plan time). */
|
||||
export async function generateInteractivePageSection(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
pageTitle: string
|
||||
sectionId: string
|
||||
section: PagePlanSection
|
||||
}): Promise<GenerateSectionResponse> {
|
||||
let out: Awaited<ReturnType<typeof postJson>>
|
||||
try {
|
||||
out = await postJson({ ...params, action: 'section' })
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
}
|
||||
}
|
||||
if ('abortError' in out) {
|
||||
return { ok: false, error: 'timeout' }
|
||||
}
|
||||
const { res, data } = out as {
|
||||
res: Response
|
||||
|
||||
data: any
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status }
|
||||
}
|
||||
return { ok: true, section: data.section, attempts: data.attempts ?? 1 }
|
||||
}
|
||||
|
||||
/** Legacy deterministic full page (fallback, no quota). */
|
||||
export async function generateInteractivePage(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
notebookId?: string
|
||||
}): Promise<GenerateInteractivePageResponse> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 75_000)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/ai/interactive-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
const aborted = err instanceof Error && err.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
error: aborted
|
||||
? 'Génération trop longue — réessaie'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Network error',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
reason: data.reason,
|
||||
issues: data.issues,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page: data.page,
|
||||
attempts: data.attempts ?? 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { AIProvider } from '@/lib/ai/types'
|
||||
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||
import {
|
||||
validateInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSpecV1,
|
||||
type PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize'
|
||||
|
||||
function stripToPlain(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function slugId(title: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : 'page.generated'
|
||||
}
|
||||
|
||||
function firstSentence(text: string, max = 100): string {
|
||||
const s = text.split(/[.!?。]/)[0]?.trim() || text.trim()
|
||||
return s.slice(0, max) || 'Page interactive'
|
||||
}
|
||||
|
||||
function chunkSentences(text: string): string[] {
|
||||
return text
|
||||
.split(/(?<=[.!?。])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 20)
|
||||
}
|
||||
|
||||
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
|
||||
|
||||
/**
|
||||
* Instant Play/Step demo from note vocabulary — no LLM.
|
||||
* 3–4 nodes + spotlight steps; formulas in speak when available.
|
||||
*/
|
||||
export function buildDeterministicDemo(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): InteractiveDemoV1 | null {
|
||||
const fr = lang.startsWith('fr')
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, a) => a.indexOf(s) === i)
|
||||
|
||||
// Prefer short phrase labels from key sentences — never raw formula fragments
|
||||
const labels: string[] = []
|
||||
for (const s of sentences.slice(0, 8)) {
|
||||
const words = s
|
||||
.replace(/\$[^$]*\$/g, ' ')
|
||||
.replace(/[\\{}]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
|
||||
labels.push(words)
|
||||
}
|
||||
if (labels.length >= 4) break
|
||||
}
|
||||
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
|
||||
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
|
||||
if (labels.length < 3) {
|
||||
const fallbacks = fr
|
||||
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
|
||||
: ['Input', 'Transform', 'Output', 'Loop']
|
||||
for (const fb of fallbacks) {
|
||||
if (labels.length >= 4) break
|
||||
if (!labels.includes(fb)) labels.push(fb)
|
||||
}
|
||||
}
|
||||
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
|
||||
const nodeCount = Math.min(4, Math.max(3, labels.length))
|
||||
|
||||
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
|
||||
id: `n${i + 1}`,
|
||||
label:
|
||||
cleanFormulas[i] && i < 2
|
||||
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
|
||||
: `${i + 1} · ${label}`,
|
||||
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
|
||||
}))
|
||||
|
||||
const edges = nodes.map((n, i) => {
|
||||
const next = nodes[(i + 1) % nodes.length]
|
||||
return {
|
||||
id: `e${i + 1}`,
|
||||
from: n.id,
|
||||
to: next.id,
|
||||
style: 'solid' as const,
|
||||
intent: 'flow' as const,
|
||||
}
|
||||
})
|
||||
|
||||
const steps = nodes.map((n, i) => {
|
||||
const formula = cleanFormulas[i]
|
||||
const speakBase =
|
||||
sentences[i]?.slice(0, 100) ||
|
||||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
|
||||
const speak = formula
|
||||
? `${speakBase.split('.')[0]}. $${formula}$`
|
||||
: speakBase
|
||||
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
|
||||
if (i > 0) revealed.push(edges[i - 1].id)
|
||||
const isLast = i === nodes.length - 1
|
||||
return {
|
||||
id: `a1.s${i + 1}`,
|
||||
speak: speak.slice(0, 160),
|
||||
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
|
||||
pointTo: [n.id],
|
||||
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
|
||||
}
|
||||
})
|
||||
|
||||
const raw = {
|
||||
schemaVersion: 1 as const,
|
||||
id: 'demo.page-auto',
|
||||
lang,
|
||||
disclaimer: fr
|
||||
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
|
||||
: 'Pedagogical diagram from your note — illustrative values.',
|
||||
scene: {
|
||||
id: 'scene.main',
|
||||
panels: [
|
||||
{
|
||||
id: 'panel.main',
|
||||
type: 'svg-scene' as const,
|
||||
payload: { nodes, edges },
|
||||
},
|
||||
],
|
||||
},
|
||||
acts: [
|
||||
{
|
||||
id: 'a1',
|
||||
title: fr ? 'Parcours' : 'Walkthrough',
|
||||
pattern: 'flowTrace' as const,
|
||||
steps,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const validated = validateInteractiveDemo(raw)
|
||||
if (!validated.ok) {
|
||||
console.warn(
|
||||
'[interactive-page] deterministic demo invalid',
|
||||
validated.issues.slice(0, 5)
|
||||
)
|
||||
return null
|
||||
}
|
||||
return validated.demo
|
||||
}
|
||||
|
||||
export function buildPageFromNote(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): Record<string, unknown> {
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, arr) => arr.indexOf(s) === i)
|
||||
const title = firstSentence(sentences[0] || plain, 90)
|
||||
const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title
|
||||
const fr = lang.startsWith('fr')
|
||||
// Displayable formulas only (KaTeX eats spaces — no prose, bounded length)
|
||||
const displayFormulas = assets.formulas.filter((f) => f.length <= 120)
|
||||
const formula = displayFormulas[0]
|
||||
const formula2 = displayFormulas[1]
|
||||
|
||||
const cards = [
|
||||
{
|
||||
badge: 'PROBLEM',
|
||||
title: fr ? 'Contexte' : 'Context',
|
||||
body: (sentences[0] || lead).slice(0, 160),
|
||||
intent: 'warning' as const,
|
||||
},
|
||||
{
|
||||
badge: 'APPROACH',
|
||||
title: fr ? 'Approche' : 'Approach',
|
||||
body: (sentences[1] || sentences[0] || lead).slice(0, 160),
|
||||
intent: 'flow' as const,
|
||||
},
|
||||
{
|
||||
badge: 'RESULT',
|
||||
title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea',
|
||||
body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160),
|
||||
intent: 'output' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const sections: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 's1',
|
||||
title: fr ? 'Le problème' : 'The problem',
|
||||
blocks: [
|
||||
{ type: 'prose', md: sentences[0] || lead },
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'definition',
|
||||
title: fr ? 'En bref' : 'In short',
|
||||
md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
title: fr ? 'Mécanisme' : 'Mechanism',
|
||||
blocks: [
|
||||
{ type: 'prose', md: sentences[1] || sentences[0] || lead },
|
||||
...(formula ? [{ type: 'formula', tex: formula }] : []),
|
||||
...(formula2
|
||||
? [
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'tip',
|
||||
title: fr ? 'Aussi' : 'Also',
|
||||
md: `$${formula2}$`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
title: fr ? 'Synthèse' : 'Synthesis',
|
||||
blocks: [
|
||||
{
|
||||
type: 'prose',
|
||||
md:
|
||||
sentences[2] ||
|
||||
(fr
|
||||
? 'Retenez le mécanisme — la démo interactive en retrace le flux.'
|
||||
: 'Keep the mechanism — the interactive demo traces the flow.'),
|
||||
},
|
||||
{
|
||||
type: 'stats',
|
||||
items: [
|
||||
{
|
||||
value: String(Math.max(assets.formulas.length, 1)),
|
||||
label: fr ? 'Formules' : 'Formulas',
|
||||
},
|
||||
{
|
||||
value: String(Math.min(Math.max(sentences.length, 3), 8)),
|
||||
label: fr ? 'Idées' : 'Ideas',
|
||||
},
|
||||
assets.numbers[0]
|
||||
? {
|
||||
value: String(assets.numbers[0].value),
|
||||
label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'),
|
||||
}
|
||||
: { value: '→', label: fr ? 'Suite' : 'Next' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: slugId(title),
|
||||
lang,
|
||||
hero: {
|
||||
kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER',
|
||||
title,
|
||||
subtitle: (sentences[1] || plain).slice(0, 140),
|
||||
meta: fr ? 'Généré depuis votre note' : 'Generated from your note',
|
||||
},
|
||||
overview: { lead, cards },
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
function injectDemo(
|
||||
page: Record<string, unknown>,
|
||||
demo: unknown
|
||||
): Record<string, unknown> {
|
||||
const sections = Array.isArray(page.sections)
|
||||
? ([...page.sections] as Record<string, unknown>[])
|
||||
: []
|
||||
if (!sections.length) return page
|
||||
const targetIdx = Math.min(1, sections.length - 1)
|
||||
const target = { ...sections[targetIdx] }
|
||||
const blocks = Array.isArray(target.blocks)
|
||||
? [...(target.blocks as Record<string, unknown>[])]
|
||||
: []
|
||||
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
|
||||
target.blocks = blocks
|
||||
sections[targetIdx] = target
|
||||
return { ...page, sections }
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageInput = {
|
||||
content: string
|
||||
lang?: string
|
||||
/** Kept for API compatibility — page skeleton no longer depends on LLM. */
|
||||
provider: AIProvider
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageResult =
|
||||
| { ok: true; page: PageSpecV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
issues?: PageValidationIssue[]
|
||||
error?: string
|
||||
reason?: string
|
||||
raw?: string
|
||||
attempts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
|
||||
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
|
||||
*/
|
||||
export async function generateInteractivePageFromContent(
|
||||
input: GenerateInteractivePageInput
|
||||
): Promise<GenerateInteractivePageResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const plain = stripToPlain(input.content)
|
||||
if (plain.split(/\s+/).filter(Boolean).length < 30) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'unsuitable_content',
|
||||
reason: 'Contenu trop court pour une page interactive',
|
||||
attempts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let pageObj = buildPageFromNote(input.content, lang, assets)
|
||||
const demo = buildDeterministicDemo(input.content, lang, assets)
|
||||
if (demo) {
|
||||
pageObj = injectDemo(pageObj, demo)
|
||||
}
|
||||
|
||||
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'normalize_failed',
|
||||
reason: 'Impossible de normaliser la page',
|
||||
attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const result = validateInteractivePage(normalized)
|
||||
if (!result.ok) {
|
||||
// Last resort: page without demo
|
||||
const sections = Array.isArray(normalized.sections)
|
||||
? (normalized.sections as Record<string, unknown>[]).map((sec) => ({
|
||||
...sec,
|
||||
blocks: Array.isArray(sec.blocks)
|
||||
? (sec.blocks as Record<string, unknown>[]).filter(
|
||||
(b) => b.type !== 'demo'
|
||||
)
|
||||
: [],
|
||||
}))
|
||||
: []
|
||||
const stripped = validateInteractivePage({ ...normalized, sections })
|
||||
if (stripped.ok) {
|
||||
return { ok: true, page: stripped.page, attempts: 1 }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
issues: result.issues,
|
||||
error: 'validation_failed',
|
||||
reason: result.issues[0]
|
||||
? `${result.issues[0].path}: ${result.issues[0].message}`
|
||||
: 'Page invalide',
|
||||
attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, page: result.page, attempts: 1 }
|
||||
}
|
||||
547
memento-note/lib/ai/services/interactive-page-llm.service.ts
Normal file
547
memento-note/lib/ai/services/interactive-page-llm.service.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* LLM generation for interactive pages (spec §7) — split into short calls:
|
||||
* 1. generatePagePlan → hero + overview + section list (one fast call)
|
||||
* 2. generatePageSection → blocks of ONE section, incl. a content-matched demo
|
||||
*
|
||||
* Splitting keeps every LLM round-trip well under the client abort (~75 s),
|
||||
* unlike the original single-shot page generation that timed out (~150 s).
|
||||
* Validation failures are fed back verbatim (max 2 attempts per call).
|
||||
*/
|
||||
|
||||
import { generateText } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import type { AIProvider } from '@/lib/ai/types'
|
||||
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
|
||||
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||
import {
|
||||
INTERACTIVE_DEMO_SCHEMA_VERSION,
|
||||
PATTERN_IDS,
|
||||
PANEL_TYPES,
|
||||
INTENT_IDS,
|
||||
SPEAK_WRITING_RULE,
|
||||
} from '@/lib/interactive-demo'
|
||||
import {
|
||||
CALLOUT_KINDS,
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
validateInteractivePage,
|
||||
normalizeInteractivePageCandidate,
|
||||
type PageSection,
|
||||
type PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
import { catalogForPrompt } from '@/lib/simulators'
|
||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||
|
||||
const MAX_ATTEMPTS = 2
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function stripToPlain(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function extractJsonObject(raw: string): unknown | null {
|
||||
if (!raw) return null
|
||||
const cleaned = cleanAIJsonResponse(raw)
|
||||
const tryParse = (s: string): unknown | null => {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
|
||||
|
||||
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
|
||||
if (parsed) return parsed
|
||||
|
||||
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||
const candidate = fence?.[1]?.trim() ?? cleaned
|
||||
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
|
||||
if (parsed) return parsed
|
||||
|
||||
const start = candidate.indexOf('{')
|
||||
const end = candidate.lastIndexOf('}')
|
||||
if (start >= 0 && end > start) {
|
||||
const slice = candidate.slice(start, end + 1)
|
||||
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
|
||||
if (parsed) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function assetsBlock(assets: ReturnType<typeof extractSourceAssets>): string {
|
||||
let msg = ''
|
||||
if (assets.formulas.length) {
|
||||
msg += `\nFORMULES_EXTRAITES (réutilise telles quelles en $...$ — KaTeX inline):\n`
|
||||
msg += assets.formulas
|
||||
.slice(0, 14)
|
||||
.map((f, i) => `${i + 1}. ${f}`)
|
||||
.join('\n')
|
||||
msg += '\n'
|
||||
}
|
||||
if (assets.keySentences.length) {
|
||||
msg += `\nPHRASES_CLES:\n`
|
||||
msg += assets.keySentences
|
||||
.slice(0, 8)
|
||||
.map((s) => `- ${s}`)
|
||||
.join('\n')
|
||||
msg += '\n'
|
||||
}
|
||||
if (assets.numbers.length) {
|
||||
msg += `\nDONNEES_NUMERIQUES (source des blocs "stats"/"table" — ne pas inventer d'autres chiffres):\n${JSON.stringify(assets.numbers.slice(0, 10))}\n`
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ── Golden demo examples (from the hand-crafted thermo fixture) ─────────────
|
||||
|
||||
type FixtureDemoBlock = { type: string; demo: unknown }
|
||||
|
||||
function fixtureDemo(panelType: string): string | null {
|
||||
for (const section of thermoFixture.sections) {
|
||||
for (const block of section.blocks as FixtureDemoBlock[]) {
|
||||
if (block.type !== 'demo') continue
|
||||
const demo = block.demo as {
|
||||
scene?: { panels?: { type: string }[] }
|
||||
}
|
||||
const panels = demo?.scene?.panels ?? []
|
||||
if (panels.some((p) => p.type === panelType)) {
|
||||
return JSON.stringify(block.demo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── 1. Page plan ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
|
||||
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
|
||||
|
||||
const planSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
goal: z.string().min(1),
|
||||
demoKind: z.enum(DEMO_KINDS),
|
||||
demoGoal: z.string().optional(),
|
||||
})
|
||||
|
||||
const pagePlanSchema = z.object({
|
||||
heroTitle: z.string().min(1),
|
||||
heroSubtitle: z.string().optional(),
|
||||
overviewLead: z.string().min(1),
|
||||
overviewCards: z
|
||||
.array(
|
||||
z.object({
|
||||
badge: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
intent: z.enum(INTENT_IDS).optional(),
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
sections: z.array(planSectionSchema).min(2).max(5),
|
||||
})
|
||||
|
||||
export type PagePlanSection = z.infer<typeof planSectionSchema>
|
||||
export type PagePlan = z.infer<typeof pagePlanSchema>
|
||||
|
||||
export type GeneratePagePlanResult =
|
||||
| { ok: true; plan: PagePlan; attempts: number }
|
||||
| { ok: false; error: string; reason?: string; attempts: number }
|
||||
|
||||
function buildPlanSystemPrompt(lang: string): string {
|
||||
return `You plan an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
|
||||
You output ONLY the PLAN as one JSON object — sections content is generated later, one call per section.
|
||||
|
||||
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
|
||||
|
||||
SCHEMA:
|
||||
{
|
||||
"heroTitle": string, // the SUBJECT of the note, never a generic title
|
||||
"heroSubtitle": string, // one sentence, autoportant
|
||||
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
|
||||
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
||||
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5
|
||||
}
|
||||
|
||||
COUVERTURE (règle n°1):
|
||||
- The page covers the CORE of the source: its 2–5 major ideas, one section each.
|
||||
- NEVER build the page on the note's simplest example.
|
||||
- First section = the problem / context; last section = synthesis / results.
|
||||
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
|
||||
|
||||
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
|
||||
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
|
||||
- Loi / relation / courbe / évolution chiffrée → "chart"
|
||||
- Processus / flux / cycle / architecture → "svg-scene"
|
||||
- Avant/après, comparaison d'états → "svg-scene" (2 groupes de nœuds) et précise-le dans demoGoal
|
||||
- Loi chiffrée avec paramètres manipulables (η = 1 − Tc/Th, COP, loi physique, modèle) → "simulation" (l'apprenant manipule des curseurs)
|
||||
- Une démo seulement si elle ENSEIGNE mieux que le texte. Max 3 sections avec démo/simulation.
|
||||
|
||||
RULES:
|
||||
- Language of ALL strings = "${lang}" (the note's language).
|
||||
- demoGoal: one sentence stating what the demo must show (used by the next LLM call).
|
||||
- No colors anywhere. Intents only.`
|
||||
}
|
||||
|
||||
export async function generatePagePlan(input: {
|
||||
content: string
|
||||
lang?: string
|
||||
provider: AIProvider
|
||||
}): Promise<GeneratePagePlanResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const plain = stripToPlain(input.content).slice(0, 6000)
|
||||
const system = buildPlanSystemPrompt(lang)
|
||||
const model = input.provider.getModel()
|
||||
|
||||
let lastError = 'unknown'
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
let user = `Plan the interactive page for THIS note.
|
||||
|
||||
EXCERPT_START
|
||||
${plain}
|
||||
EXCERPT_END
|
||||
${assetsBlock(assets)}`
|
||||
if (attempt > 1) {
|
||||
user += `\nPREVIOUS_ANSWER_INVALID: ${lastError}\nReturn a corrected FULL JSON object matching the schema exactly.`
|
||||
}
|
||||
|
||||
const { text: raw } = await generateText({
|
||||
model,
|
||||
system,
|
||||
prompt: user,
|
||||
temperature: attempt === 1 ? 0.3 : 0.1,
|
||||
})
|
||||
|
||||
const parsed = extractJsonObject(raw)
|
||||
if (!parsed) {
|
||||
lastError = 'Model did not return parseable JSON'
|
||||
console.warn('[interactive-page/plan] unparseable JSON', raw.slice(0, 300))
|
||||
continue
|
||||
}
|
||||
|
||||
const refusal = parsed as { error?: string; reason?: string }
|
||||
if (refusal?.error === 'unsuitable_content') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'unsuitable_content',
|
||||
reason: refusal.reason || 'Contenu inadapté à une page interactive',
|
||||
attempts: attempt,
|
||||
}
|
||||
}
|
||||
|
||||
const result = pagePlanSchema.safeParse(parsed)
|
||||
if (result.success) {
|
||||
return { ok: true, plan: result.data, attempts: attempt }
|
||||
}
|
||||
lastError = result.error.issues
|
||||
.slice(0, 6)
|
||||
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
||||
.join(' | ')
|
||||
console.warn('[interactive-page/plan] schema failed', lastError)
|
||||
}
|
||||
|
||||
return { ok: false, error: 'plan_failed', reason: lastError, attempts: MAX_ATTEMPTS }
|
||||
}
|
||||
|
||||
// ── 2. Section generation ────────────────────────────────────────────────────
|
||||
|
||||
export type GeneratePageSectionInput = {
|
||||
content: string
|
||||
lang?: string
|
||||
provider: AIProvider
|
||||
pageTitle: string
|
||||
sectionId: string
|
||||
section: PagePlanSection
|
||||
}
|
||||
|
||||
export type GeneratePageSectionResult =
|
||||
| { ok: true; section: PageSection; attempts: number }
|
||||
| { ok: false; issues?: PageValidationIssue[]; error?: string; attempts: number }
|
||||
|
||||
const SECTION_NARRATION_RULES = `NARRATION des démos (champ "speak"):
|
||||
- 1–2 phrases, 25 mots max, une seule idée par étape, gras sur le concept clé, KaTeX inline ($...$) pour les formules.
|
||||
- Voix off de prof, jamais de description mécanique.
|
||||
- Jamais annoter ce qu'on pointTo dans la même étape.
|
||||
- Chaque élément d'une scène est désigné au moins une fois dans l'acte.
|
||||
- Scènes denses : svg-scene ≥ 5 nœuds ; heatmap avec valeurs réalistes et variées (intensité ∝ valeur, valeurs affichées).
|
||||
- Étape finale d'acte : reveal ["*"] + pattern overview.
|
||||
- Valeurs illustratives → "disclaimer" obligatoire.`
|
||||
|
||||
function buildSectionSystemPrompt(lang: string, hasMath: boolean): string {
|
||||
return `You generate ONE section of an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
|
||||
|
||||
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
|
||||
|
||||
SCHEMA:
|
||||
{
|
||||
"title": string,
|
||||
"blocks": [ Block, ... ] // 2–6 blocks
|
||||
}
|
||||
Block types (discriminated by "type"):
|
||||
- { "type": "prose", "md": string } // light markdown + $KaTeX$ inline
|
||||
- { "type": "formula", "tex": string, "caption?": string } // KaTeX block for key relations
|
||||
- { "type": "callout", "kind": ${JSON.stringify(CALLOUT_KINDS)}, "title": string, "md": string }
|
||||
- { "type": "chart", "payload": { "chartType": "line"|"bar"|"area", "series": [{ "id": string, "label?": string, "values": number[], "intent?": IntentId }] }, "caption?": string }
|
||||
- { "type": "stats", "items": [{ "value": string, "label": string, "intent?": IntentId }] } // 2–5, REAL figures from the note only
|
||||
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
|
||||
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
|
||||
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
|
||||
IntentId: ${JSON.stringify(INTENT_IDS)}
|
||||
|
||||
SimRef — TWO forms:
|
||||
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
|
||||
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
|
||||
(B) GENERIC formula simulator: { "simId": "generic-formula", "title": string, "params": [{ "id", "symbol", "label", "min", "max", "step", "defaultValue", "unit?", "intent?" }] (1–4), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (1–6), "visual": { "kind": "gauges" } | { "kind": "bars" } | { "kind": "curve", "xParamId", "expr" }, "disclaimer?": string }
|
||||
→ expr = plain math over param ids: + - * / ^ % parentheses, functions sqrt abs exp ln log round min max, constants pi e. NO other identifiers.
|
||||
SIMULATOR CATALOG (pick from this, else generic-formula):
|
||||
${catalogForPrompt(lang)}
|
||||
|
||||
InteractiveDemoV1 (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}):
|
||||
{
|
||||
"schemaVersion": 1, "id": "demo.<slug>", "lang": "${lang}", "disclaimer?": string,
|
||||
"scene": { "id": string, "panels": [Panel] },
|
||||
"acts": [ { "id": "a1", "title": string, "pattern?": Pattern, "steps": [Step] } ]
|
||||
}
|
||||
Panel types: ${PANEL_TYPES.join(', ')} (max 2 panels; heatmap cells ids: r{row}.c{col}, triangular:"lower" when appropriate)
|
||||
Patterns: ${PATTERN_IDS.join(', ')}
|
||||
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?": string[], "reveal?": [{"ids": string[], "scope": "transient|act|scene"}], "annotate?": [...] }
|
||||
svg-scene: nodes[{id,label?,intent?}] (labels may hold $KaTeX$), edges[{id,from,to,style?,weight?,intent?}]
|
||||
|
||||
${SECTION_NARRATION_RULES}
|
||||
|
||||
MÉCANIQUE (the validator rejects otherwise):
|
||||
- Unique semantic ids; NO colors (hex/rgb) anywhere — intents only.
|
||||
- Blocks per section ≤ ${INTERACTIVE_PAGE_CAPS.maxBlocksPerSection}.
|
||||
- Every pointTo/reveal/annotate reference must be an id of the active scene.
|
||||
- "stats"/"table" ONLY with figures actually present in the source.
|
||||
- All human-facing strings in "${lang}".
|
||||
RÈGLE D'OR — AUCUN VISUEL DÉCORATIF (checked after generation, violations are rejected):
|
||||
- Every visual block must TEACH something the text alone cannot. Ask: "what does the learner understand after, that they didn't before?" No answer → no visual block.
|
||||
- "heatmap-matrix" ONLY when the note contains genuinely matrix-shaped data (table of values, correlations, confusion matrix). Otherwise FORBIDDEN.
|
||||
- "chart" ONLY with numeric series actually present in the source note. Otherwise FORBIDDEN.
|
||||
- svg-scene: ≥ 5 nodes, dense, real vocabulary of the note — never 3 generic boxes.
|
||||
- When in doubt: prose/formula/callout only.
|
||||
${hasMath ? '- STEM: reuse formulas from FORMULES_EXTRAITES verbatim in formula blocks, prose and demo speak ($...$).' : ''}`
|
||||
}
|
||||
|
||||
function buildSectionUserPrompt(
|
||||
input: GeneratePageSectionInput,
|
||||
assets: ReturnType<typeof extractSourceAssets>,
|
||||
opts?: { issues?: PageValidationIssue[]; previousJson?: string }
|
||||
): string {
|
||||
const plain = stripToPlain(input.content).slice(0, 6000)
|
||||
const { section } = input
|
||||
let msg = `Generate the section "${section.title}" of the interactive page "${input.pageTitle}".
|
||||
|
||||
SECTION_GOAL: ${section.goal}
|
||||
${
|
||||
section.demoKind === 'simulation'
|
||||
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
|
||||
SIM_GOAL: ${section.demoGoal || section.goal}`
|
||||
: section.demoKind !== 'none'
|
||||
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
|
||||
DEMO_GOAL: ${section.demoGoal || section.goal}`
|
||||
: `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.`
|
||||
}
|
||||
|
||||
EXCERPT_START
|
||||
${plain}
|
||||
EXCERPT_END
|
||||
${assetsBlock(assets)}`
|
||||
|
||||
if (section.demoKind === 'heatmap-matrix' || section.demoKind === 'svg-scene') {
|
||||
const golden = fixtureDemo(section.demoKind)
|
||||
if (golden) {
|
||||
msg += `\nEXEMPLE_D_OR (imite sa densité et sa qualité de narration — jamais moins ; adapte au contenu de CETTE note):\n${golden}\n`
|
||||
}
|
||||
}
|
||||
|
||||
msg += `\nCONTRAINTES: section autoportante, fidèle au contenu réel de la note (jamais un exemple jouet). 2–6 blocks.`
|
||||
|
||||
if (opts?.issues?.length) {
|
||||
msg += `\n\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
|
||||
msg += opts.issues
|
||||
.slice(0, 12)
|
||||
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
|
||||
.join('\n')
|
||||
}
|
||||
if (opts?.previousJson) {
|
||||
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 10000)}\nPREVIOUS_JSON_END`
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
/** Wrap a section candidate in a minimal page so the shared validator runs (incl. demo delegation). */
|
||||
function validateSectionCandidate(
|
||||
candidate: unknown,
|
||||
sectionId: string,
|
||||
lang: string
|
||||
): { ok: true; section: PageSection } | { ok: false; issues: PageValidationIssue[] } {
|
||||
const wrapped = {
|
||||
schemaVersion: 1,
|
||||
id: 'page.section-check',
|
||||
lang,
|
||||
hero: { kicker: 'CHECK', title: 'Section check' },
|
||||
sections: [
|
||||
typeof candidate === 'object' && candidate !== null
|
||||
? { id: sectionId, ...(candidate as Record<string, unknown>) }
|
||||
: candidate,
|
||||
],
|
||||
}
|
||||
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{ code: 'normalize_failed', path: '', message: 'Section not normalizable' },
|
||||
],
|
||||
}
|
||||
}
|
||||
const result = validateInteractivePage(normalized)
|
||||
if (!result.ok) return { ok: false, issues: result.issues }
|
||||
const section = result.page.sections.find((s) => s.id === sectionId)
|
||||
if (!section) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [{ code: 'section_missing', path: 'sections', message: 'Section lost in normalization' }],
|
||||
}
|
||||
}
|
||||
return { ok: true, section }
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality gate (règle d'or): reject decorative visuals AFTER schema validation —
|
||||
* heatmap/chart whose values don't come from the note, svg-scene too sparse.
|
||||
* Issues are fed back to the LLM for a repair attempt.
|
||||
*/
|
||||
function qualityGateSection(
|
||||
section: PageSection,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): PageValidationIssue[] {
|
||||
const out: PageValidationIssue[] = []
|
||||
const sourceValues = assets.numbers.map((n) => n.value)
|
||||
const valueInSource = (v: number) =>
|
||||
sourceValues.some((sv) => sv !== 0 && Math.abs(sv - v) / Math.max(1, Math.abs(sv)) < 0.06)
|
||||
|
||||
for (const [bi, block] of section.blocks.entries()) {
|
||||
const path = `blocks[${bi}]`
|
||||
if (block.type === 'chart') {
|
||||
const values = block.payload.series.flatMap((s) => s.values)
|
||||
const grounded = values.filter(valueInSource).length
|
||||
if (values.length > 0 && grounded / values.length < 0.5) {
|
||||
out.push({
|
||||
code: 'decorative_data',
|
||||
path,
|
||||
message:
|
||||
'Chart values are not from the note (decorative data). Use real series from the source or remove the chart.',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (block.type === 'demo') {
|
||||
for (const panel of block.demo.scene.panels) {
|
||||
if (panel.type === 'heatmap-matrix') {
|
||||
const values = panel.payload.values.flat()
|
||||
const grounded = values.filter(valueInSource).length
|
||||
if (values.length > 0 && grounded / values.length < 0.5) {
|
||||
out.push({
|
||||
code: 'decorative_data',
|
||||
path,
|
||||
message:
|
||||
'Heatmap values are not from the note (decorative data). Only use heatmap-matrix for real matrix-shaped source data.',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (panel.type === 'svg-scene' && panel.payload.nodes.length < 5) {
|
||||
out.push({
|
||||
code: 'scene_too_poor',
|
||||
path,
|
||||
message:
|
||||
'svg-scene has fewer than 5 nodes (too poor pedagogically). Densify: ≥5 nodes with real vocabulary and formulas from the note.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function generatePageSection(
|
||||
input: GeneratePageSectionInput
|
||||
): Promise<GeneratePageSectionResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const system = buildSectionSystemPrompt(
|
||||
lang,
|
||||
assets.hasMath || assets.formulas.length > 0
|
||||
)
|
||||
const model = input.provider.getModel()
|
||||
|
||||
let lastIssues: PageValidationIssue[] = []
|
||||
let lastRaw = ''
|
||||
let lastNormalizedJson = ''
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const user = buildSectionUserPrompt(input, assets, {
|
||||
issues: attempt > 1 ? lastIssues : undefined,
|
||||
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
|
||||
})
|
||||
|
||||
const { text: raw } = await generateText({
|
||||
model,
|
||||
system,
|
||||
prompt: user,
|
||||
temperature: attempt === 1 ? 0.3 : 0.1,
|
||||
})
|
||||
lastRaw = raw
|
||||
|
||||
const parsed = extractJsonObject(raw)
|
||||
if (!parsed) {
|
||||
lastIssues = [
|
||||
{
|
||||
code: 'invalid_json',
|
||||
path: '',
|
||||
message: 'Model did not return parseable JSON',
|
||||
},
|
||||
]
|
||||
console.warn(
|
||||
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: unparseable JSON`,
|
||||
raw.slice(0, 300)
|
||||
)
|
||||
continue
|
||||
}
|
||||
lastNormalizedJson = JSON.stringify(parsed)
|
||||
|
||||
const result = validateSectionCandidate(parsed, input.sectionId, lang)
|
||||
if (result.ok) {
|
||||
const qualityIssues = qualityGateSection(result.section, assets)
|
||||
if (!qualityIssues.length) {
|
||||
return { ok: true, section: result.section, attempts: attempt }
|
||||
}
|
||||
lastIssues = qualityIssues
|
||||
console.warn(
|
||||
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: quality gate`,
|
||||
qualityIssues.slice(0, 6)
|
||||
)
|
||||
continue
|
||||
}
|
||||
lastIssues = result.issues
|
||||
console.warn(
|
||||
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: validation failed`,
|
||||
result.issues.slice(0, 8)
|
||||
)
|
||||
}
|
||||
|
||||
return { ok: false, issues: lastIssues, attempts: MAX_ATTEMPTS }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
|
||||
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
|
||||
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
|
||||
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
|
||||
'interactive-page': `Page immersive avec hero, sections et démos Play/Step (canal dédié — ne pas utiliser ici).`,
|
||||
}
|
||||
|
||||
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */
|
||||
|
||||
@@ -56,7 +56,9 @@ export const CREDIT_COSTS: Record<string, number> = {
|
||||
ai_flashcard: 3,
|
||||
voice_transcribe: 1,
|
||||
excalidraw_generate: 4,
|
||||
slide_generate: 7, // défaut si pas de slideCount (1+6)
|
||||
slide_generate: 7, // défaut si pas de slideCount (1+N)
|
||||
interactive_demo: 10, // génération initiale (brainstorm P12)
|
||||
interactive_page: 20, // page immersive style Kimi
|
||||
}
|
||||
|
||||
export function slideGenerateCreditCost(slideCount?: number | null): number {
|
||||
|
||||
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 }
|
||||
}
|
||||
86
memento-note/lib/interactive-page/constants.ts
Normal file
86
memento-note/lib/interactive-page/constants.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/** Interactive Page schema v1 — caps & allowlists (spec Kimi / AttnRes). */
|
||||
|
||||
export const INTERACTIVE_PAGE_SCHEMA_VERSION = 1 as const
|
||||
|
||||
export const INTERACTIVE_PAGE_CAPS = {
|
||||
maxSections: 8,
|
||||
maxBlocksPerSection: 12,
|
||||
maxDemosPerPage: 5,
|
||||
maxSimsPerPage: 3,
|
||||
maxOverviewCards: 4,
|
||||
minOverviewCards: 2,
|
||||
maxStatsItems: 5,
|
||||
minStatsItems: 2,
|
||||
maxJsonBytes: 128 * 1024,
|
||||
} as const
|
||||
|
||||
export const SIM_CAPS = {
|
||||
maxParams: 4,
|
||||
maxComputed: 6,
|
||||
maxExprChars: 200,
|
||||
} as const
|
||||
|
||||
export const PAGE_BLOCK_TYPES = [
|
||||
'prose',
|
||||
'formula',
|
||||
'callout',
|
||||
'demo',
|
||||
'chart',
|
||||
'stats',
|
||||
'table',
|
||||
'image',
|
||||
'sim',
|
||||
] as const
|
||||
|
||||
export const CALLOUT_KINDS = [
|
||||
'definition',
|
||||
'warning',
|
||||
'tip',
|
||||
'note',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Human / locale strings — may contain hex in prose; skipped by color scan.
|
||||
* Must stay in sync with validate + translate strip.
|
||||
*/
|
||||
export const PAGE_HUMAN_STRING_KEYS = [
|
||||
// page-level
|
||||
'lang',
|
||||
'kicker',
|
||||
'title',
|
||||
'subtitle',
|
||||
'meta',
|
||||
'lead',
|
||||
'badge',
|
||||
'body',
|
||||
'footer',
|
||||
// blocks
|
||||
'md',
|
||||
'tex',
|
||||
'caption',
|
||||
'alt',
|
||||
'value',
|
||||
'label',
|
||||
'columns',
|
||||
'rows',
|
||||
// sim blocks
|
||||
'symbol',
|
||||
'expr',
|
||||
'intro',
|
||||
'xLabel',
|
||||
'yLabel',
|
||||
// inherited from demos (speak etc. scanned via demo validator)
|
||||
'speak',
|
||||
'text',
|
||||
'disclaimer',
|
||||
'rowLabels',
|
||||
'colLabels',
|
||||
] as const
|
||||
|
||||
export type PageHumanStringKey = (typeof PAGE_HUMAN_STRING_KEYS)[number]
|
||||
|
||||
const PAGE_HUMAN_SET = new Set<string>(PAGE_HUMAN_STRING_KEYS)
|
||||
|
||||
export function isPageHumanStringKey(key: string): boolean {
|
||||
return PAGE_HUMAN_SET.has(key)
|
||||
}
|
||||
354
memento-note/lib/interactive-page/fixtures/thermo-page.json
Normal file
354
memento-note/lib/interactive-page/fixtures/thermo-page.json
Normal file
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "page.thermo-test",
|
||||
"lang": "fr",
|
||||
"hero": {
|
||||
"kicker": "EXPLAINER INTERACTIF",
|
||||
"title": "Cycle frigorifique",
|
||||
"subtitle": "Compression, condensation, détente, évaporation",
|
||||
"meta": "Note de cours · valeurs illustratives"
|
||||
},
|
||||
"overview": {
|
||||
"lead": "Le cycle frigorifique déplace de la chaleur du froid vers le chaud grâce à un travail $W$.",
|
||||
"cards": [
|
||||
{
|
||||
"badge": "PROBLEM",
|
||||
"title": "Objectif",
|
||||
"body": "Extraire $Q_e$ à basse température.",
|
||||
"intent": "warning"
|
||||
},
|
||||
{
|
||||
"badge": "APPROACH",
|
||||
"title": "Cycle",
|
||||
"body": "Quatre organes en boucle fermée.",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"badge": "RESULT",
|
||||
"title": "COP",
|
||||
"body": "$\\mathrm{COP}=Q_e/W$",
|
||||
"intent": "output"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"id": "s1",
|
||||
"title": "Le problème",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "prose",
|
||||
"md": "On veut **refroidir** un volume en rejetant la chaleur à l’extérieur."
|
||||
},
|
||||
{
|
||||
"type": "callout",
|
||||
"kind": "definition",
|
||||
"title": "Travail",
|
||||
"md": "Le compresseur fournit $W=h_2-h_1$."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"title": "Échanges",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "formula",
|
||||
"tex": "\\mathrm{COP}=\\frac{Q_e}{W}",
|
||||
"caption": "Coefficient de performance"
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "ts-diagram"
|
||||
},
|
||||
"caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"title": "Isotherme",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "chart",
|
||||
"payload": {
|
||||
"chartType": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "p",
|
||||
"label": "P(V)",
|
||||
"values": [
|
||||
4,
|
||||
2.5,
|
||||
1.8,
|
||||
1.4,
|
||||
1.2
|
||||
],
|
||||
"intent": "flow"
|
||||
}
|
||||
]
|
||||
},
|
||||
"caption": "Pression vs volume (illustratif)"
|
||||
},
|
||||
{
|
||||
"type": "demo",
|
||||
"caption": "Le cycle à compression de vapeur — 4 organes en boucle fermée.",
|
||||
"demo": {
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.vapor-cycle",
|
||||
"lang": "fr",
|
||||
"disclaimer": "Schéma pédagogique — valeurs illustratives.",
|
||||
"scene": {
|
||||
"id": "scene.cycle",
|
||||
"panels": [
|
||||
{
|
||||
"id": "panel.cycle",
|
||||
"type": "svg-scene",
|
||||
"payload": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "comp",
|
||||
"label": "1 · Compresseur\n$W = h_2 - h_1$",
|
||||
"intent": "compute"
|
||||
},
|
||||
{
|
||||
"id": "cond",
|
||||
"label": "2 · Condenseur\n$Q_c = h_2 - h_3$",
|
||||
"intent": "output"
|
||||
},
|
||||
{
|
||||
"id": "exp",
|
||||
"label": "3 · Détente\n$h_3 = h_4$",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "evap",
|
||||
"label": "4 · Évaporateur\n$Q_e = h_1 - h_4$",
|
||||
"intent": "cache"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"label": "Travail fourni $W$",
|
||||
"intent": "highlight"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "e12",
|
||||
"from": "comp",
|
||||
"to": "cond",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e23",
|
||||
"from": "cond",
|
||||
"to": "exp",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e34",
|
||||
"from": "exp",
|
||||
"to": "evap",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "e41",
|
||||
"from": "evap",
|
||||
"to": "comp",
|
||||
"style": "solid",
|
||||
"intent": "flow"
|
||||
},
|
||||
{
|
||||
"id": "ew",
|
||||
"from": "work",
|
||||
"to": "comp",
|
||||
"style": "dashed",
|
||||
"intent": "highlight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"acts": [
|
||||
{
|
||||
"id": "a1",
|
||||
"title": "Cycle",
|
||||
"pattern": "flowTrace",
|
||||
"steps": [
|
||||
{
|
||||
"id": "a1.s1",
|
||||
"speak": "Le compresseur fournit le travail $W$ au fluide.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"comp"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"comp",
|
||||
"work",
|
||||
"ew"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s2",
|
||||
"speak": "Au condenseur, la chaleur $Q_c$ est rejetée vers l'extérieur.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"cond"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"cond",
|
||||
"e12"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s3",
|
||||
"speak": "La détente est isenthalpique : $h_3 = h_4$.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"exp"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"exp",
|
||||
"e23"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s4",
|
||||
"speak": "À l'évaporateur, le fluide absorbe $Q_e$ : c'est le froid utile.",
|
||||
"pattern": "spotlightTour",
|
||||
"pointTo": [
|
||||
"evap"
|
||||
],
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"evap",
|
||||
"e34"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a1.s5",
|
||||
"speak": "Le cycle se boucle : le fluide retourne au compresseur.",
|
||||
"pattern": "overview",
|
||||
"reveal": [
|
||||
{
|
||||
"ids": [
|
||||
"comp",
|
||||
"cond",
|
||||
"exp",
|
||||
"evap",
|
||||
"work",
|
||||
"e12",
|
||||
"e23",
|
||||
"e34",
|
||||
"e41",
|
||||
"ew"
|
||||
],
|
||||
"scope": "act"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "carnot-cycle",
|
||||
"preset": {
|
||||
"t_cold": 260,
|
||||
"t_hot": 300,
|
||||
"q_cold": 100
|
||||
},
|
||||
"disclaimer": "Valeurs illustratives — les COP réels sont inférieurs au COP de Carnot."
|
||||
},
|
||||
"caption": "Manipulez les températures des sources : le COP maximal suit le 2ᵉ principe."
|
||||
},
|
||||
{
|
||||
"type": "sim",
|
||||
"sim": {
|
||||
"simId": "carnot-cycle-anim"
|
||||
},
|
||||
"caption": "Le cycle de Carnot battement par battement — piston et diagramme P–V en direct."
|
||||
},
|
||||
{
|
||||
"type": "stats",
|
||||
"items": [
|
||||
{
|
||||
"value": "3.2",
|
||||
"label": "COP typique",
|
||||
"intent": "output"
|
||||
},
|
||||
{
|
||||
"value": "1.25×",
|
||||
"label": "Gain relatif",
|
||||
"intent": "highlight"
|
||||
},
|
||||
{
|
||||
"value": "<2%",
|
||||
"label": "Pertes",
|
||||
"intent": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s4",
|
||||
"title": "Synthèse",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "table",
|
||||
"columns": [
|
||||
"Organe",
|
||||
"Échange"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"Compresseur",
|
||||
"$W$"
|
||||
],
|
||||
[
|
||||
"Condenseur",
|
||||
"$Q_c$"
|
||||
],
|
||||
[
|
||||
"Évaporateur",
|
||||
"$Q_e$"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "prose",
|
||||
"md": "Le COP mesure l’efficacité du cycle."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footer": "Valeurs pédagogiques — pas des mesures expérimentales."
|
||||
}
|
||||
25
memento-note/lib/interactive-page/index.ts
Normal file
25
memento-note/lib/interactive-page/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||
PAGE_BLOCK_TYPES,
|
||||
CALLOUT_KINDS,
|
||||
PAGE_HUMAN_STRING_KEYS,
|
||||
isPageHumanStringKey,
|
||||
} from './constants'
|
||||
export { pageSpecV1Schema, pageBlockSchema } from './schema'
|
||||
export { validateInteractivePage } from './validate'
|
||||
export { normalizeInteractivePageCandidate } from './normalize'
|
||||
export type {
|
||||
PageSpecV1,
|
||||
PageSection,
|
||||
PageBlock,
|
||||
PageHero,
|
||||
PageOverview,
|
||||
PageValidationIssue,
|
||||
PageValidationResult,
|
||||
IntentId,
|
||||
SimRef,
|
||||
CatalogSimRef,
|
||||
GenericFormulaSim,
|
||||
SimBlock,
|
||||
} from './types'
|
||||
335
memento-note/lib/interactive-page/normalize.ts
Normal file
335
memento-note/lib/interactive-page/normalize.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Deterministic repairs for LLM PageSpecV1 before Zod validation.
|
||||
* Mirrors interactive-demo/normalize — fix common shape/field aliases.
|
||||
*/
|
||||
|
||||
import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize'
|
||||
import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants'
|
||||
|
||||
const CALLOUT_SET = new Set<string>(CALLOUT_KINDS)
|
||||
|
||||
const CALLOUT_ALIASES: Record<string, string> = {
|
||||
definition: 'definition',
|
||||
def: 'definition',
|
||||
warning: 'warning',
|
||||
warn: 'warning',
|
||||
danger: 'warning',
|
||||
alert: 'warning',
|
||||
tip: 'tip',
|
||||
hint: 'tip',
|
||||
advice: 'tip',
|
||||
note: 'note',
|
||||
info: 'note',
|
||||
remark: 'note',
|
||||
}
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === 'object' && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return String(v)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function slugId(title: string, fallback: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : fallback
|
||||
}
|
||||
|
||||
function pickMd(obj: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(obj.md) ||
|
||||
asString(obj.content) ||
|
||||
asString(obj.text) ||
|
||||
asString(obj.body) ||
|
||||
asString(obj.html) ||
|
||||
asString(obj.markdown)
|
||||
)
|
||||
}
|
||||
|
||||
function pickTex(obj: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(obj.tex) ||
|
||||
asString(obj.latex) ||
|
||||
asString(obj.formula) ||
|
||||
asString(obj.math) ||
|
||||
asString(obj.equation)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCalloutKind(v: unknown): string {
|
||||
if (typeof v !== 'string') return 'note'
|
||||
const key = v.trim().toLowerCase()
|
||||
const mapped = CALLOUT_ALIASES[key] || key
|
||||
return CALLOUT_SET.has(mapped) ? mapped : 'note'
|
||||
}
|
||||
|
||||
function normalizeBlock(
|
||||
raw: unknown,
|
||||
index: number
|
||||
): Record<string, unknown> | null {
|
||||
const obj = asRecord(raw)
|
||||
if (!obj) return null
|
||||
const type = asString(obj.type)?.toLowerCase()
|
||||
if (!type) return null
|
||||
|
||||
if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') {
|
||||
const md = pickMd(obj)
|
||||
if (!md) return null
|
||||
return { type: 'prose', md }
|
||||
}
|
||||
|
||||
if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') {
|
||||
const tex = pickTex(obj)
|
||||
if (!tex) return null
|
||||
const out: Record<string, unknown> = { type: 'formula', tex }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'callout' || type === 'aside' || type === 'box') {
|
||||
const md = pickMd(obj) || '—'
|
||||
const title = asString(obj.title) || asString(obj.heading) || 'Note'
|
||||
return {
|
||||
type: 'callout',
|
||||
kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style),
|
||||
title,
|
||||
md,
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'demo' || type === 'interactive' || type === 'animation') {
|
||||
const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo
|
||||
if (!nested) return null
|
||||
const normalized = normalizeInteractiveDemoCandidate(nested)
|
||||
const out: Record<string, unknown> = { type: 'demo', demo: normalized }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'chart' || type === 'graph') {
|
||||
const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj
|
||||
const series = Array.isArray((payload as Record<string, unknown>).series)
|
||||
? (payload as Record<string, unknown>).series
|
||||
: null
|
||||
if (!series) return null
|
||||
const chartType =
|
||||
asString((payload as Record<string, unknown>).chartType) ||
|
||||
asString(obj.chartType) ||
|
||||
'line'
|
||||
const out: Record<string, unknown> = {
|
||||
type: 'chart',
|
||||
payload: {
|
||||
chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line',
|
||||
series,
|
||||
},
|
||||
}
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'stats' || type === 'metrics' || type === 'kpis') {
|
||||
const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null
|
||||
if (!items || items.length < 2) return null
|
||||
return {
|
||||
type: 'stats',
|
||||
items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => {
|
||||
const r = asRecord(it) || {}
|
||||
return {
|
||||
value: asString(r.value) || asString(r.v) || '—',
|
||||
label: asString(r.label) || asString(r.name) || '—',
|
||||
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'table') {
|
||||
const columns = Array.isArray(obj.columns)
|
||||
? obj.columns.map((c) => asString(c) || '—')
|
||||
: []
|
||||
const rows = Array.isArray(obj.rows) ? obj.rows : []
|
||||
if (!columns.length) return null
|
||||
const out: Record<string, unknown> = { type: 'table', columns, rows }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'img' || type === 'figure') {
|
||||
const src = asString(obj.src) || asString(obj.url)
|
||||
const alt = asString(obj.alt) || asString(obj.title) || 'Image'
|
||||
if (!src) return null
|
||||
const out: Record<string, unknown> = { type: 'image', src, alt }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') {
|
||||
const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj
|
||||
const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id)
|
||||
if (!simId) return null
|
||||
const out: Record<string, unknown> = { type: 'sim', sim: { ...sim, simId } }
|
||||
const caption = asString(obj.caption)
|
||||
if (caption) out.caption = caption
|
||||
return out
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeSection(
|
||||
raw: unknown,
|
||||
index: number
|
||||
): Record<string, unknown> | null {
|
||||
const obj = asRecord(raw)
|
||||
if (!obj) return null
|
||||
const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}`
|
||||
const id = asString(obj.id) || `s${index + 1}`
|
||||
const blocksRaw = Array.isArray(obj.blocks)
|
||||
? obj.blocks
|
||||
: Array.isArray(obj.content)
|
||||
? obj.content
|
||||
: []
|
||||
const blocks = blocksRaw
|
||||
.map((b, i) => normalizeBlock(b, i))
|
||||
.filter(Boolean)
|
||||
.slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record<string, unknown>[]
|
||||
if (!blocks.length) {
|
||||
blocks.push({
|
||||
type: 'prose',
|
||||
md: asString(obj.summary) || asString(obj.lead) || title,
|
||||
})
|
||||
}
|
||||
return { id, title, blocks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort normalize of an LLM page candidate.
|
||||
* Returns a plain object ready for validateInteractivePage.
|
||||
*/
|
||||
export function normalizeInteractivePageCandidate(
|
||||
input: unknown,
|
||||
lang = 'fr'
|
||||
): Record<string, unknown> | null {
|
||||
const root = asRecord(input)
|
||||
if (!root) return null
|
||||
|
||||
const heroIn = asRecord(root.hero) || {}
|
||||
const title =
|
||||
asString(heroIn.title) ||
|
||||
asString(root.title) ||
|
||||
'Page interactive'
|
||||
const kicker =
|
||||
asString(heroIn.kicker) ||
|
||||
asString(heroIn.eyebrow) ||
|
||||
asString(heroIn.label) ||
|
||||
(lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER')
|
||||
|
||||
const hero: Record<string, unknown> = {
|
||||
kicker,
|
||||
title,
|
||||
}
|
||||
const subtitle = asString(heroIn.subtitle) || asString(root.subtitle)
|
||||
const meta = asString(heroIn.meta) || asString(root.meta)
|
||||
if (subtitle) hero.subtitle = subtitle
|
||||
if (meta) hero.meta = meta
|
||||
|
||||
let overview: Record<string, unknown> | undefined
|
||||
const overviewIn = asRecord(root.overview)
|
||||
if (overviewIn) {
|
||||
const lead =
|
||||
asString(overviewIn.lead) ||
|
||||
asString(overviewIn.summary) ||
|
||||
asString(overviewIn.text)
|
||||
const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : []
|
||||
const cards = cardsRaw
|
||||
.map((c) => {
|
||||
const r = asRecord(c)
|
||||
if (!r) return null
|
||||
const badge = asString(r.badge) || asString(r.label) || 'IDEA'
|
||||
const cTitle = asString(r.title) || badge
|
||||
const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle
|
||||
return {
|
||||
badge,
|
||||
title: cTitle,
|
||||
body,
|
||||
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
||||
overview = {
|
||||
lead,
|
||||
cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
}
|
||||
} else if (lead) {
|
||||
// Pad to min cards so validation can pass
|
||||
const padded = [...cards]
|
||||
while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
||||
padded.push({
|
||||
badge: `C${padded.length + 1}`,
|
||||
title: title,
|
||||
body: lead.slice(0, 120),
|
||||
})
|
||||
}
|
||||
overview = {
|
||||
lead,
|
||||
cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsRaw = Array.isArray(root.sections) ? root.sections : []
|
||||
let sections = sectionsRaw
|
||||
.map((s, i) => normalizeSection(s, i))
|
||||
.filter(Boolean) as Record<string, unknown>[]
|
||||
|
||||
// Cap demos (speed + reliability): keep first 2
|
||||
let demoCount = 0
|
||||
sections = sections.map((sec) => {
|
||||
const blocks = (sec.blocks as Record<string, unknown>[]).filter((b) => {
|
||||
if (b.type !== 'demo') return true
|
||||
demoCount += 1
|
||||
return demoCount <= 2
|
||||
})
|
||||
return { ...sec, blocks }
|
||||
})
|
||||
|
||||
sections = sections.slice(0, 5)
|
||||
if (!sections.length) {
|
||||
sections = [
|
||||
{
|
||||
id: 's1',
|
||||
title: title,
|
||||
blocks: [{ type: 'prose', md: asString(root.summary) || title }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
id: asString(root.id) || slugId(title, 'page.generated'),
|
||||
lang: asString(root.lang) || lang,
|
||||
hero,
|
||||
sections,
|
||||
}
|
||||
if (overview) out.overview = overview
|
||||
const footer = asString(root.footer)
|
||||
if (footer) out.footer = footer
|
||||
return out
|
||||
}
|
||||
210
memento-note/lib/interactive-page/schema.ts
Normal file
210
memento-note/lib/interactive-page/schema.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { z } from 'zod'
|
||||
import { INTENT_IDS } from '@/lib/interactive-demo/constants'
|
||||
import { interactiveDemoV1Schema } from '@/lib/interactive-demo/schema'
|
||||
import {
|
||||
CALLOUT_KINDS,
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||
PAGE_BLOCK_TYPES,
|
||||
SIM_CAPS,
|
||||
} from './constants'
|
||||
|
||||
const intentSchema = z.enum(INTENT_IDS).optional()
|
||||
|
||||
const langSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
|
||||
|
||||
const chartPayloadSchema = z.object({
|
||||
chartType: z.enum(['line', 'bar', 'area']),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().optional(),
|
||||
values: z.array(z.number()),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
|
||||
const proseBlock = z.object({
|
||||
type: z.literal('prose'),
|
||||
md: z.string().min(1),
|
||||
})
|
||||
|
||||
const formulaBlock = z.object({
|
||||
type: z.literal('formula'),
|
||||
tex: z.string().min(1),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const calloutBlock = z.object({
|
||||
type: z.literal('callout'),
|
||||
kind: z.enum(CALLOUT_KINDS),
|
||||
title: z.string().min(1),
|
||||
md: z.string().min(1),
|
||||
})
|
||||
|
||||
const demoBlock = z.object({
|
||||
type: z.literal('demo'),
|
||||
demo: interactiveDemoV1Schema,
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const chartBlock = z.object({
|
||||
type: z.literal('chart'),
|
||||
payload: chartPayloadSchema,
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const statsBlock = z.object({
|
||||
type: z.literal('stats'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minStatsItems)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxStatsItems),
|
||||
})
|
||||
|
||||
const tableBlock = z.object({
|
||||
type: z.literal('table'),
|
||||
columns: z.array(z.string().min(1)).min(1),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const imageBlock = z.object({
|
||||
type: z.literal('image'),
|
||||
src: z.string().min(1),
|
||||
alt: z.string().min(1),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
const simParamIdSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
|
||||
|
||||
const genericSimParamSchema = z.object({
|
||||
id: simParamIdSchema,
|
||||
symbol: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
step: z.number().positive(),
|
||||
defaultValue: z.number(),
|
||||
unit: z.string().optional(),
|
||||
intent: intentSchema,
|
||||
})
|
||||
|
||||
const genericSimComputedSchema = z.object({
|
||||
id: simParamIdSchema,
|
||||
symbol: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
expr: z.string().min(1).max(SIM_CAPS.maxExprChars),
|
||||
unit: z.string().optional(),
|
||||
intent: intentSchema,
|
||||
})
|
||||
|
||||
const genericSimSchema = z.object({
|
||||
simId: z.literal('generic-formula'),
|
||||
title: z.string().min(1),
|
||||
intro: z.string().optional(),
|
||||
params: z.array(genericSimParamSchema).min(1).max(SIM_CAPS.maxParams),
|
||||
computed: z.array(genericSimComputedSchema).min(1).max(SIM_CAPS.maxComputed),
|
||||
visual: z.union([
|
||||
z.object({ kind: z.literal('gauges') }),
|
||||
z.object({ kind: z.literal('bars') }),
|
||||
z.object({
|
||||
kind: z.literal('curve'),
|
||||
xParamId: simParamIdSchema,
|
||||
expr: z.string().min(1),
|
||||
xLabel: z.string().optional(),
|
||||
yLabel: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
disclaimer: z.string().optional(),
|
||||
})
|
||||
|
||||
const catalogSimSchema = z.object({
|
||||
simId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((s) => s !== 'generic-formula', {
|
||||
message: 'generic-formula must use the full inline schema',
|
||||
}),
|
||||
title: z.string().optional(),
|
||||
preset: z.record(z.string(), z.number()).optional(),
|
||||
disclaimer: z.string().optional(),
|
||||
})
|
||||
|
||||
const simBlock = z.object({
|
||||
type: z.literal('sim'),
|
||||
sim: z.union([genericSimSchema, catalogSimSchema]),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
export const pageBlockSchema = z.discriminatedUnion('type', [
|
||||
proseBlock,
|
||||
formulaBlock,
|
||||
calloutBlock,
|
||||
demoBlock,
|
||||
chartBlock,
|
||||
statsBlock,
|
||||
tableBlock,
|
||||
imageBlock,
|
||||
simBlock,
|
||||
])
|
||||
|
||||
const sectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
blocks: z
|
||||
.array(pageBlockSchema)
|
||||
.min(1)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxBlocksPerSection),
|
||||
})
|
||||
|
||||
const overviewSchema = z.object({
|
||||
lead: z.string().min(1),
|
||||
cards: z
|
||||
.array(
|
||||
z.object({
|
||||
badge: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
intent: intentSchema,
|
||||
})
|
||||
)
|
||||
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
||||
})
|
||||
|
||||
export const pageSpecV1Schema = z.object({
|
||||
schemaVersion: z.literal(INTERACTIVE_PAGE_SCHEMA_VERSION),
|
||||
id: z.string().min(1),
|
||||
lang: langSchema,
|
||||
hero: z.object({
|
||||
kicker: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
meta: z.string().optional(),
|
||||
}),
|
||||
overview: overviewSchema.optional(),
|
||||
sections: z
|
||||
.array(sectionSchema)
|
||||
.min(1)
|
||||
.max(INTERACTIVE_PAGE_CAPS.maxSections),
|
||||
footer: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PageSpecV1Parsed = z.infer<typeof pageSpecV1Schema>
|
||||
|
||||
/** Re-export for callers. */
|
||||
export { PAGE_BLOCK_TYPES }
|
||||
355
memento-note/lib/interactive-page/sim-eval.ts
Normal file
355
memento-note/lib/interactive-page/sim-eval.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Safe math-expression evaluator for the generic-formula simulator.
|
||||
* Tokenizer + recursive-descent parser — NO eval/Function, no object access,
|
||||
* no loops. Identifiers resolve only against an explicit environment;
|
||||
* functions come from a fixed allowlist.
|
||||
*/
|
||||
|
||||
type Token =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'id'; v: string }
|
||||
| { t: 'op'; v: string }
|
||||
| { t: 'lparen' }
|
||||
| { t: 'rparen' }
|
||||
| { t: 'comma' }
|
||||
|
||||
const CONSTANTS: Record<string, number> = {
|
||||
pi: Math.PI,
|
||||
e: Math.E,
|
||||
}
|
||||
|
||||
type Fn = (...args: number[]) => number
|
||||
const FUNCTIONS: Record<string, { fn: Fn; minArgs: number; maxArgs: number }> = {
|
||||
sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 },
|
||||
abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 },
|
||||
exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 },
|
||||
ln: { fn: Math.log, minArgs: 1, maxArgs: 1 },
|
||||
log: { fn: Math.log10, minArgs: 1, maxArgs: 1 },
|
||||
round: { fn: Math.round, minArgs: 1, maxArgs: 1 },
|
||||
floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 },
|
||||
ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 },
|
||||
min: { fn: Math.min, minArgs: 1, maxArgs: 8 },
|
||||
max: { fn: Math.max, minArgs: 1, maxArgs: 8 },
|
||||
}
|
||||
|
||||
const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
export type SimExprError = { message: string }
|
||||
|
||||
type Ast =
|
||||
| { k: 'num'; v: number }
|
||||
| { k: 'id'; v: string }
|
||||
| { k: 'call'; name: string; args: Ast[] }
|
||||
| { k: 'un'; op: '-'; a: Ast }
|
||||
| { k: 'bin'; op: string; a: Ast; b: Ast }
|
||||
|
||||
function tokenize(src: string): Token[] | SimExprError {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const ch = src[i]
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
let j = i
|
||||
while (j < src.length && /[0-9.]/.test(src[j])) j++
|
||||
const raw = src.slice(i, j)
|
||||
const v = Number(raw)
|
||||
if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` }
|
||||
tokens.push({ t: 'num', v })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') {
|
||||
let j = i + 1
|
||||
while (j < src.length && /[0-9]/.test(src[j])) j++
|
||||
tokens.push({ t: 'num', v: Number(src.slice(i, j)) })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i
|
||||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++
|
||||
tokens.push({ t: 'id', v: src.slice(i, j) })
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if ('+-*/^%'.includes(ch)) {
|
||||
tokens.push({ t: 'op', v: ch })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === '(') {
|
||||
tokens.push({ t: 'lparen' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === ')') {
|
||||
tokens.push({ t: 'rparen' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === ',') {
|
||||
tokens.push({ t: 'comma' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return { message: `unexpected character "${ch}"` }
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private pos = 0
|
||||
constructor(private tokens: Token[]) {}
|
||||
|
||||
private peek(): Token | undefined {
|
||||
return this.tokens[this.pos]
|
||||
}
|
||||
private next(): Token | undefined {
|
||||
return this.tokens[this.pos++]
|
||||
}
|
||||
private expectOp(): string | null {
|
||||
const t = this.peek()
|
||||
return t?.t === 'op' ? t.v : null
|
||||
}
|
||||
|
||||
parseExpr(): Ast | SimExprError {
|
||||
let left = this.parseTerm()
|
||||
if ('message' in left) return left
|
||||
for (;;) {
|
||||
const op = this.expectOp()
|
||||
if (op !== '+' && op !== '-') break
|
||||
this.next()
|
||||
const right = this.parseTerm()
|
||||
if ('message' in right) return right
|
||||
left = { k: 'bin', op, a: left, b: right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
private parseTerm(): Ast | SimExprError {
|
||||
let left = this.parseUnary()
|
||||
if ('message' in left) return left
|
||||
for (;;) {
|
||||
const op = this.expectOp()
|
||||
if (op !== '*' && op !== '/' && op !== '%') break
|
||||
this.next()
|
||||
const right = this.parseUnary()
|
||||
if ('message' in right) return right
|
||||
left = { k: 'bin', op, a: left, b: right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
private parseUnary(): Ast | SimExprError {
|
||||
if (this.expectOp() === '-') {
|
||||
this.next()
|
||||
const a = this.parseUnary()
|
||||
if ('message' in a) return a
|
||||
return { k: 'un', op: '-', a }
|
||||
}
|
||||
if (this.expectOp() === '+') {
|
||||
this.next()
|
||||
return this.parseUnary()
|
||||
}
|
||||
return this.parsePower()
|
||||
}
|
||||
|
||||
private parsePower(): Ast | SimExprError {
|
||||
const base = this.parseAtom()
|
||||
if ('message' in base) return base
|
||||
if (this.expectOp() === '^') {
|
||||
this.next()
|
||||
const exp = this.parseUnary() // right-assoc
|
||||
if ('message' in exp) return exp
|
||||
return { k: 'bin', op: '^', a: base, b: exp }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private parseAtom(): Ast | SimExprError {
|
||||
const t = this.next()
|
||||
if (!t) return { message: 'unexpected end of expression' }
|
||||
if (t.t === 'num') return { k: 'num', v: t.v }
|
||||
if (t.t === 'lparen') {
|
||||
const inner = this.parseExpr()
|
||||
if ('message' in inner) return inner
|
||||
const close = this.next()
|
||||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||||
return inner
|
||||
}
|
||||
if (t.t === 'id') {
|
||||
if (this.peek()?.t === 'lparen') {
|
||||
this.next() // consume (
|
||||
const args: Ast[] = []
|
||||
if (this.peek()?.t !== 'rparen') {
|
||||
for (;;) {
|
||||
const arg = this.parseExpr()
|
||||
if ('message' in arg) return arg
|
||||
args.push(arg)
|
||||
if (this.peek()?.t === 'comma') {
|
||||
this.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
const close = this.next()
|
||||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||||
return { k: 'call', name: t.v, args }
|
||||
}
|
||||
return { k: 'id', v: t.v }
|
||||
}
|
||||
return { message: `unexpected token "${'v' in t ? t.v : t.t}"` }
|
||||
}
|
||||
|
||||
parseTop(): Ast | SimExprError {
|
||||
const ast = this.parseExpr()
|
||||
if ('message' in ast) return ast
|
||||
if (this.pos < this.tokens.length) {
|
||||
return { message: 'trailing tokens after expression' }
|
||||
}
|
||||
return ast
|
||||
}
|
||||
}
|
||||
|
||||
function evalAst(ast: Ast, env: Record<string, number>): number {
|
||||
switch (ast.k) {
|
||||
case 'num':
|
||||
return ast.v
|
||||
case 'id': {
|
||||
if (ast.v in CONSTANTS) return CONSTANTS[ast.v]
|
||||
const v = env[ast.v]
|
||||
return typeof v === 'number' ? v : NaN
|
||||
}
|
||||
case 'un':
|
||||
return -evalAst(ast.a, env)
|
||||
case 'bin': {
|
||||
const a = evalAst(ast.a, env)
|
||||
const b = evalAst(ast.b, env)
|
||||
switch (ast.op) {
|
||||
case '+':
|
||||
return a + b
|
||||
case '-':
|
||||
return a - b
|
||||
case '*':
|
||||
return a * b
|
||||
case '/':
|
||||
return b === 0 ? NaN : a / b
|
||||
case '%':
|
||||
return b === 0 ? NaN : a % b
|
||||
case '^':
|
||||
return Math.pow(a, b)
|
||||
default:
|
||||
return NaN
|
||||
}
|
||||
}
|
||||
case 'call': {
|
||||
const def = FUNCTIONS[ast.name]
|
||||
if (!def) return NaN
|
||||
const args = ast.args.map((a) => evalAst(a, env))
|
||||
if (args.some((x) => Number.isNaN(x))) return NaN
|
||||
return def.fn(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectIds(ast: Ast, out: Set<string>): void {
|
||||
switch (ast.k) {
|
||||
case 'num':
|
||||
return
|
||||
case 'id':
|
||||
out.add(ast.v)
|
||||
return
|
||||
case 'un':
|
||||
collectIds(ast.a, out)
|
||||
return
|
||||
case 'bin':
|
||||
collectIds(ast.a, out)
|
||||
collectIds(ast.b, out)
|
||||
return
|
||||
case 'call':
|
||||
ast.args.forEach((a) => collectIds(a, out))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export type ParsedSimExpr = {
|
||||
/** Evaluate against an environment; NaN when uncomputable. */
|
||||
evaluate(env: Record<string, number>): number
|
||||
/** Identifiers used (params/computed refs), excluding constants. */
|
||||
identifiers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a safe math expression. Returns error message on syntax problems.
|
||||
* Unknown function names are rejected at parse time.
|
||||
*/
|
||||
export function parseSimExpr(src: string): ParsedSimExpr | SimExprError {
|
||||
const trimmed = src.trim()
|
||||
if (!trimmed || trimmed.length > 200) {
|
||||
return { message: 'expression empty or too long (max 200 chars)' }
|
||||
}
|
||||
const tokens = tokenize(trimmed)
|
||||
if ('message' in tokens) return tokens
|
||||
const ast = new Parser(tokens).parseTop()
|
||||
if ('message' in ast) return ast
|
||||
|
||||
const ids = new Set<string>()
|
||||
collectIds(ast, ids)
|
||||
for (const id of ids) {
|
||||
if (id in FUNCTIONS) {
|
||||
return { message: `"${id}" is a function name — call it with (...)` }
|
||||
}
|
||||
}
|
||||
// Validate function calls (unknown names, arity)
|
||||
const checkCalls = (node: Ast): SimExprError | null => {
|
||||
if (node.k === 'call') {
|
||||
const def = FUNCTIONS[node.name]
|
||||
if (!def) return { message: `unknown function "${node.name}"` }
|
||||
if (node.args.length < def.minArgs || node.args.length > def.maxArgs) {
|
||||
return { message: `function "${node.name}" expects ${def.minArgs}–${def.maxArgs} args` }
|
||||
}
|
||||
for (const a of node.args) {
|
||||
const err = checkCalls(a)
|
||||
if (err) return err
|
||||
}
|
||||
} else if (node.k === 'un') {
|
||||
return checkCalls(node.a)
|
||||
} else if (node.k === 'bin') {
|
||||
return checkCalls(node.a) ?? checkCalls(node.b)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const callErr = checkCalls(ast)
|
||||
if (callErr) return callErr
|
||||
|
||||
const identifiers = [...ids].filter((id) => !(id in CONSTANTS))
|
||||
return {
|
||||
evaluate: (env) => evalAst(ast, env),
|
||||
identifiers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation helper: parse + require every identifier ∈ allowedIds.
|
||||
* Returns a list of issue messages (empty = OK).
|
||||
*/
|
||||
export function validateSimExprRefs(
|
||||
src: string,
|
||||
allowedIds: Set<string>
|
||||
): string[] {
|
||||
const parsed = parseSimExpr(src)
|
||||
if ('message' in parsed) return [parsed.message]
|
||||
return parsed.identifiers
|
||||
.filter((id) => !allowedIds.has(id))
|
||||
.map((id) => `unknown identifier "${id}"`)
|
||||
}
|
||||
|
||||
/** Type guard helper for zod refinements. */
|
||||
export function isValidSimParamId(id: string): boolean {
|
||||
return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS)
|
||||
}
|
||||
153
memento-note/lib/interactive-page/types.ts
Normal file
153
memento-note/lib/interactive-page/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { ChartPayload, IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||
import type { CALLOUT_KINDS, PAGE_BLOCK_TYPES } from './constants'
|
||||
|
||||
export type { IntentId }
|
||||
export type PageBlockType = (typeof PAGE_BLOCK_TYPES)[number]
|
||||
export type CalloutKind = (typeof CALLOUT_KINDS)[number]
|
||||
|
||||
export type PageHero = {
|
||||
kicker: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
export type OverviewCard = {
|
||||
badge: string
|
||||
title: string
|
||||
body: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type PageOverview = {
|
||||
lead: string
|
||||
cards: OverviewCard[]
|
||||
}
|
||||
|
||||
export type ProseBlock = { type: 'prose'; md: string }
|
||||
export type FormulaBlock = { type: 'formula'; tex: string; caption?: string }
|
||||
export type CalloutBlock = {
|
||||
type: 'callout'
|
||||
kind: CalloutKind
|
||||
title: string
|
||||
md: string
|
||||
}
|
||||
export type DemoBlock = {
|
||||
type: 'demo'
|
||||
demo: InteractiveDemoV1
|
||||
caption?: string
|
||||
}
|
||||
export type ChartBlock = {
|
||||
type: 'chart'
|
||||
payload: ChartPayload
|
||||
caption?: string
|
||||
}
|
||||
export type StatsBlock = {
|
||||
type: 'stats'
|
||||
items: { value: string; label: string; intent?: IntentId }[]
|
||||
}
|
||||
export type TableBlock = {
|
||||
type: 'table'
|
||||
columns: string[]
|
||||
rows: string[][]
|
||||
caption?: string
|
||||
}
|
||||
export type ImageBlock = {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
// ── Simulator block (plugin catalog + generic formula) ──────────────────────
|
||||
|
||||
/** Curated simulator from the plugin registry — AI only picks + presets. */
|
||||
export type CatalogSimRef = {
|
||||
simId: string
|
||||
title?: string
|
||||
preset?: Record<string, number>
|
||||
disclaimer?: string
|
||||
}
|
||||
|
||||
export type GenericSimParam = {
|
||||
id: string
|
||||
symbol: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
defaultValue: number
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type GenericSimComputed = {
|
||||
id: string
|
||||
symbol: string
|
||||
label: string
|
||||
/** Safe math expression (see sim-eval) over params + previous computed. */
|
||||
expr: string
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type GenericSimVisual =
|
||||
| { kind: 'gauges' }
|
||||
| { kind: 'bars' }
|
||||
| { kind: 'curve'; xParamId: string; expr: string; xLabel?: string; yLabel?: string }
|
||||
|
||||
/** Inline custom simulation — expressions validated by sim-eval (no eval). */
|
||||
export type GenericFormulaSim = {
|
||||
simId: 'generic-formula'
|
||||
title: string
|
||||
intro?: string
|
||||
params: GenericSimParam[]
|
||||
computed: GenericSimComputed[]
|
||||
visual: GenericSimVisual
|
||||
disclaimer?: string
|
||||
}
|
||||
|
||||
export type SimRef = CatalogSimRef | GenericFormulaSim
|
||||
|
||||
export type SimBlock = {
|
||||
type: 'sim'
|
||||
sim: SimRef
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export type PageBlock =
|
||||
| ProseBlock
|
||||
| FormulaBlock
|
||||
| CalloutBlock
|
||||
| DemoBlock
|
||||
| ChartBlock
|
||||
| StatsBlock
|
||||
| TableBlock
|
||||
| ImageBlock
|
||||
| SimBlock
|
||||
|
||||
export type PageSection = {
|
||||
id: string
|
||||
title: string
|
||||
blocks: PageBlock[]
|
||||
}
|
||||
|
||||
export type PageSpecV1 = {
|
||||
schemaVersion: 1
|
||||
id: string
|
||||
lang: string
|
||||
hero: PageHero
|
||||
overview?: PageOverview
|
||||
sections: PageSection[]
|
||||
footer?: string
|
||||
}
|
||||
|
||||
export type PageValidationIssue = {
|
||||
code: string
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type PageValidationResult =
|
||||
| { ok: true; page: PageSpecV1 }
|
||||
| { ok: false; issues: PageValidationIssue[] }
|
||||
303
memento-note/lib/interactive-page/validate.ts
Normal file
303
memento-note/lib/interactive-page/validate.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { FORBIDDEN_COLOR_RE } from '@/lib/interactive-demo/constants'
|
||||
import { validateInteractiveDemo } from '@/lib/interactive-demo/validate'
|
||||
import { getPlugin } from '@/lib/simulators'
|
||||
import {
|
||||
INTERACTIVE_PAGE_CAPS,
|
||||
isPageHumanStringKey,
|
||||
} from './constants'
|
||||
import { pageSpecV1Schema } from './schema'
|
||||
import { validateSimExprRefs } from './sim-eval'
|
||||
import type {
|
||||
PageBlock,
|
||||
PageSpecV1,
|
||||
PageValidationIssue,
|
||||
PageValidationResult,
|
||||
} from './types'
|
||||
|
||||
function issue(code: string, path: string, message: string): PageValidationIssue {
|
||||
return { code, path, message }
|
||||
}
|
||||
|
||||
function scanForbiddenColors(
|
||||
value: unknown,
|
||||
path: string,
|
||||
out: PageValidationIssue[]
|
||||
): void {
|
||||
if (typeof value === 'string') {
|
||||
if (FORBIDDEN_COLOR_RE.test(value)) {
|
||||
out.push(
|
||||
issue(
|
||||
'forbidden_color',
|
||||
path,
|
||||
'Free color literals (hex/rgb) are forbidden — use intent enums'
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out))
|
||||
return
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (isPageHumanStringKey(k)) continue
|
||||
// Nested demos are validated separately (incl. their own color scan)
|
||||
if (k === 'demo') continue
|
||||
scanForbiddenColors(v, path ? `${path}.${k}` : k, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function countDemos(page: PageSpecV1): number {
|
||||
let n = 0
|
||||
for (const s of page.sections) {
|
||||
for (const b of s.blocks) {
|
||||
if (b.type === 'demo') n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function countSims(page: PageSpecV1): number {
|
||||
let n = 0
|
||||
for (const s of page.sections) {
|
||||
for (const b of s.blocks) {
|
||||
if (b.type === 'sim') n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Simulator block: catalog ref integrity / generic exprs safety. */
|
||||
function validateSim(
|
||||
block: Extract<PageBlock, { type: 'sim' }>,
|
||||
path: string,
|
||||
out: PageValidationIssue[]
|
||||
): void {
|
||||
const sim = block.sim
|
||||
if (sim.simId === 'generic-formula') {
|
||||
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
|
||||
const paramIds = new Set(generic.params.map((p) => p.id))
|
||||
for (const p of generic.params) {
|
||||
if (p.min >= p.max) {
|
||||
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
|
||||
}
|
||||
if (p.defaultValue < p.min || p.defaultValue > p.max) {
|
||||
out.push(
|
||||
issue('sim_param_default', `${path}.params`, `Param "${p.id}": default outside [min, max]`)
|
||||
)
|
||||
}
|
||||
}
|
||||
const allowed = new Set<string>(paramIds)
|
||||
for (const c of generic.computed) {
|
||||
const errs = validateSimExprRefs(c.expr, allowed)
|
||||
for (const e of errs) {
|
||||
out.push(issue('sim_expr', `${path}.computed.${c.id}`, e))
|
||||
}
|
||||
if (errs.length === 0) allowed.add(c.id) // computed may chain
|
||||
}
|
||||
if (generic.visual.kind === 'curve') {
|
||||
if (!paramIds.has(generic.visual.xParamId)) {
|
||||
out.push(
|
||||
issue('sim_curve_param', `${path}.visual.xParamId`, `Unknown param "${generic.visual.xParamId}"`)
|
||||
)
|
||||
}
|
||||
const errs = validateSimExprRefs(generic.visual.expr, allowed)
|
||||
for (const e of errs) {
|
||||
out.push(issue('sim_expr', `${path}.visual.expr`, e))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Catalog plugin: must exist. Sims: preset within bounds + compute finite.
|
||||
const catalog = sim as Extract<typeof sim, { simId: string }> & {
|
||||
preset?: Record<string, number>
|
||||
}
|
||||
const plugin = getPlugin(catalog.simId)
|
||||
if (!plugin) {
|
||||
out.push(issue('unknown_simulator', `${path}.simId`, `Unknown simulator "${catalog.simId}"`))
|
||||
return
|
||||
}
|
||||
if (plugin.family === 'anim') {
|
||||
if (!plugin.beats.length) {
|
||||
out.push(issue('sim_anim_empty', `${path}`, 'Animation plugin has no beats'))
|
||||
}
|
||||
return
|
||||
}
|
||||
const env: Record<string, number> = {}
|
||||
for (const p of plugin.params) env[p.id] = p.defaultValue
|
||||
if (catalog.preset) {
|
||||
for (const [k, v] of Object.entries(catalog.preset)) {
|
||||
const def = plugin.params.find((p) => p.id === k)
|
||||
if (!def) {
|
||||
out.push(issue('sim_preset_key', `${path}.preset.${k}`, 'Not a parameter of this simulator'))
|
||||
continue
|
||||
}
|
||||
if (v < def.min || v > def.max) {
|
||||
out.push(
|
||||
issue('sim_preset_range', `${path}.preset.${k}`, `Value ${v} outside [${def.min}, ${def.max}]`)
|
||||
)
|
||||
continue
|
||||
}
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = plugin.compute(env)
|
||||
for (const o of plugin.outputs) {
|
||||
if (!Number.isFinite(result[o.id])) {
|
||||
out.push(
|
||||
issue('sim_compute', `${path}`, `Output "${o.id}" not finite at preset values`)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
out.push(issue('sim_compute', `${path}`, 'Simulator compute() threw at preset values'))
|
||||
}
|
||||
}
|
||||
|
||||
function validateTable(
|
||||
block: Extract<PageBlock, { type: 'table' }>,
|
||||
path: string,
|
||||
out: PageValidationIssue[]
|
||||
): void {
|
||||
const cols = block.columns.length
|
||||
for (const [ri, row] of block.rows.entries()) {
|
||||
if (row.length !== cols) {
|
||||
out.push(
|
||||
issue(
|
||||
'table_shape',
|
||||
`${path}.rows[${ri}]`,
|
||||
`Expected ${cols} cells, got ${row.length}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateImageSrc(
|
||||
block: Extract<PageBlock, { type: 'image' }>,
|
||||
path: string,
|
||||
out: PageValidationIssue[]
|
||||
): void {
|
||||
const src = block.src.trim()
|
||||
// Allow relative /uploads, https, and data:image — reject javascript: etc.
|
||||
if (/^\s*javascript:/i.test(src) || /^\s*data:text\/html/i.test(src)) {
|
||||
out.push(
|
||||
issue('unsafe_image_src', `${path}.src`, 'Unsafe image src rejected')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function semanticValidate(page: PageSpecV1): PageValidationIssue[] {
|
||||
const out: PageValidationIssue[] = []
|
||||
|
||||
const jsonBytes = new TextEncoder().encode(JSON.stringify(page)).length
|
||||
if (jsonBytes > INTERACTIVE_PAGE_CAPS.maxJsonBytes) {
|
||||
out.push(
|
||||
issue(
|
||||
'json_too_large',
|
||||
'',
|
||||
`JSON exceeds ${INTERACTIVE_PAGE_CAPS.maxJsonBytes} bytes (${jsonBytes})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const demoCount = countDemos(page)
|
||||
if (demoCount > INTERACTIVE_PAGE_CAPS.maxDemosPerPage) {
|
||||
out.push(
|
||||
issue(
|
||||
'too_many_demos',
|
||||
'sections',
|
||||
`At most ${INTERACTIVE_PAGE_CAPS.maxDemosPerPage} demos per page (found ${demoCount})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const simCount = countSims(page)
|
||||
if (simCount > INTERACTIVE_PAGE_CAPS.maxSimsPerPage) {
|
||||
out.push(
|
||||
issue(
|
||||
'too_many_sims',
|
||||
'sections',
|
||||
`At most ${INTERACTIVE_PAGE_CAPS.maxSimsPerPage} sims per page (found ${simCount})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
scanForbiddenColors(page, '', out)
|
||||
|
||||
const sectionIds = new Set<string>()
|
||||
for (const [si, section] of page.sections.entries()) {
|
||||
const sPath = `sections[${si}]`
|
||||
if (sectionIds.has(section.id)) {
|
||||
out.push(
|
||||
issue(
|
||||
'duplicate_section_id',
|
||||
`${sPath}.id`,
|
||||
`Duplicate section id "${section.id}"`
|
||||
)
|
||||
)
|
||||
}
|
||||
sectionIds.add(section.id)
|
||||
|
||||
if (!/^s\d+$/.test(section.id)) {
|
||||
out.push(
|
||||
issue(
|
||||
'section_id_format',
|
||||
`${sPath}.id`,
|
||||
`Section id should match /^s\\d+$/ (got "${section.id}")`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for (const [bi, block] of section.blocks.entries()) {
|
||||
const bPath = `${sPath}.blocks[${bi}]`
|
||||
|
||||
// Unknown types already hard-rejected by Zod discriminatedUnion.
|
||||
if (block.type === 'table') validateTable(block, bPath, out)
|
||||
if (block.type === 'image') validateImageSrc(block, bPath, out)
|
||||
if (block.type === 'sim') validateSim(block, bPath, out)
|
||||
|
||||
if (block.type === 'demo') {
|
||||
const demoResult = validateInteractiveDemo(block.demo)
|
||||
if (!demoResult.ok) {
|
||||
for (const iss of demoResult.issues) {
|
||||
out.push({
|
||||
code: `demo_${iss.code}`,
|
||||
path: `${bPath}.demo${iss.path ? '.' + iss.path : ''}`,
|
||||
message: iss.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a PageSpecV1 document.
|
||||
* Structural (Zod allowlist) then semantic (caps, colors, demos, tables).
|
||||
*/
|
||||
export function validateInteractivePage(input: unknown): PageValidationResult {
|
||||
const parsed = pageSpecV1Schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
const issues: PageValidationIssue[] = parsed.error.issues.map((e) => ({
|
||||
code: e.code,
|
||||
path: e.path.join('.'),
|
||||
message: e.message,
|
||||
}))
|
||||
return { ok: false, issues }
|
||||
}
|
||||
|
||||
const page = parsed.data as PageSpecV1
|
||||
const semantic = semanticValidate(page)
|
||||
if (semantic.length > 0) {
|
||||
return { ok: false, issues: semantic }
|
||||
}
|
||||
return { ok: true, page }
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
ai_flashcard: 5,
|
||||
voice_transcribe: 20,
|
||||
publish_enhance: 2,
|
||||
interactive_demo: 5,
|
||||
interactive_page: 2,
|
||||
},
|
||||
PRO: {
|
||||
semantic_search: 200,
|
||||
@@ -42,6 +44,8 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
ai_flashcard: 100,
|
||||
voice_transcribe: 500,
|
||||
publish_enhance: 15,
|
||||
interactive_demo: 40,
|
||||
interactive_page: 20,
|
||||
},
|
||||
BUSINESS: {
|
||||
semantic_search: 1000,
|
||||
@@ -59,6 +63,8 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
publish_enhance: 100,
|
||||
interactive_demo: 200,
|
||||
interactive_page: 80,
|
||||
},
|
||||
ENTERPRISE: {
|
||||
semantic_search: 'unlimited',
|
||||
@@ -75,6 +81,8 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
publish_enhance: 'unlimited',
|
||||
interactive_demo: 'unlimited',
|
||||
interactive_page: 'unlimited',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
|
||||
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay', 'interactive-page'] as const
|
||||
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
|
||||
|
||||
/** Métadonnées éditoriales IA — corps = HTML source original. */
|
||||
@@ -19,3 +19,9 @@ export interface PublishRewriteSpec {
|
||||
export function isPublishTemplateId(value: string): value is PublishTemplateId {
|
||||
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function isInteractivePageTemplate(
|
||||
value: string | null | undefined
|
||||
): boolean {
|
||||
return value === 'interactive-page'
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const VALID_FEATURES = [
|
||||
'ai_flashcard',
|
||||
'voice_transcribe',
|
||||
'publish_enhance',
|
||||
'interactive_demo',
|
||||
'interactive_page',
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof VALID_FEATURES)[number];
|
||||
|
||||
59
memento-note/lib/simulators/README.md
Normal file
59
memento-note/lib/simulators/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Simulateurs interactifs (plugins)
|
||||
|
||||
Bibliothèque de simulateurs pédagogiques pour les **pages interactives** (`/p/{slug}`).
|
||||
L'IA ne code jamais une simulation : elle **choisit** un simulateur de cette liste et le
|
||||
**configure** (`preset` = valeurs de la note), ou utilise `generic-formula` si aucun ne
|
||||
correspond au sujet.
|
||||
|
||||
## Liste des simulateurs disponibles
|
||||
|
||||
| `simId` | Famille | Sujet | Interaction |
|
||||
|---|---|---|---|
|
||||
| `carnot-cycle` | `sim` | Machine frigorifique / PAC / moteur de Carnot (2ᵉ principe) | Curseurs T_c, T_h, Q_c → COP, η, W min, Q_h en direct |
|
||||
| `carnot-cycle-anim` | `anim` | Cycle de Carnot animé (piston + diagramme P–V live, 5 étapes) | Play / Pause / Step / Reset / vitesse + narration |
|
||||
| `generic-formula` | `sim` | (générique) toute relation chiffrée y = f(paramètres) | Curseurs définis par l'IA, expressions sûres (aucun `eval`) |
|
||||
|
||||
Deux familles de plugins : `sim` (manipulation de paramètres, calcul en direct) et
|
||||
`anim` (scène animée codée en dur, pilotée par le player Play/Step — narration par
|
||||
battements). Les deux sont choisis par l'IA via le même bloc `{ "type": "sim" }`.
|
||||
|
||||
## Comment l'IA les utilise
|
||||
|
||||
1. Le prompt de génération de section injecte `catalogForPrompt(lang)` (registry.ts) —
|
||||
id + résumé + mots-clés + bornes de chaque simulateur.
|
||||
2. Si le contenu de la note correspond (`keywords`), l'IA émet
|
||||
`{ "type": "sim", "sim": { "simId": "carnot-cycle", "preset": { "t_hot": 300 } } }`.
|
||||
3. `validateInteractivePage` vérifie : `simId` connu, preset dans les bornes,
|
||||
`compute(preset)` fini — sinon rejet (renvoyé au LLM ou fallback).
|
||||
|
||||
## Ajouter un plugin (5 étapes)
|
||||
|
||||
Pour un **`sim`** (curseurs) :
|
||||
1. **`lib/simulators/<id>.ts`** — implémenter `SimulatorPlugin` (`family: 'sim'`) :
|
||||
`id`, `title`/`summary` fr+en (le summary sert au matching IA), `keywords`,
|
||||
`params` (curseurs : bornes, pas, défaut, unité, intent), `outputs` (symbole KaTeX,
|
||||
unité), et `compute(env)` **pur et déterministe**.
|
||||
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
|
||||
3. **`components/simulators/<id>-view.tsx`** — composant sur mesure
|
||||
(props : `preset`, `title`, `disclaimer`, `lang`). Réutiliser
|
||||
`SimSlider` / `SimOutputCard` / `SimHeading` / `SimKaTeX` de `sim-controls.tsx`.
|
||||
4. **`components/simulators/index.ts`** — enregistrer dans `SIMULATOR_VIEWS`.
|
||||
5. Vérifier : `compute` fini aux valeurs par défaut, `npx tsc --noEmit`, tester sur
|
||||
`/dev/interactive-page`.
|
||||
|
||||
Pour une **`anim`** (scène animée Play/Step) :
|
||||
1. **`lib/simulators/<id>.ts`** — implémenter `AnimPlugin` (`family: 'anim'`) :
|
||||
`id`, `title`/`summary` fr+en, `keywords`, `disclaimer?`, `beats` (narration
|
||||
fr+en par étape — KaTeX inline `$…$` dans `speak`).
|
||||
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
|
||||
3. **`components/simulators/<id>-anim-view.tsx`** — scène SVG/React pilotée par
|
||||
`step` (props : `step`, `lang`). Transitions CSS sur `transform`/`opacity`
|
||||
uniquement. Le chrome (Play/Pause/Step/Reset/vitesse + panneau de narration +
|
||||
clavier Espace/←/→/R) est fourni par `AnimPlayerShell` — ne pas le réécrire.
|
||||
4. **`components/simulators/index.ts`** — enregistrer dans `ANIM_VIEWS`.
|
||||
5. Vérifier : chaque `step` rend un état cohérent (y compris état final sans JS),
|
||||
`npx tsc --noEmit`, tester sur `/dev/interactive-page`.
|
||||
|
||||
Règles : pas de couleurs en dur (intents / tokens `--pp-*`), labels fr+en dans le
|
||||
plugin, tout calcul côté `compute` ou scène codée (jamais de logique LLM), SSR =
|
||||
état lisible sans JS.
|
||||
76
memento-note/lib/simulators/carnot-cycle-anim.ts
Normal file
76
memento-note/lib/simulators/carnot-cycle-anim.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { AnimPlugin } from './types'
|
||||
|
||||
/**
|
||||
* Carnot cycle — animated piston + live P-V diagram, 5 beats.
|
||||
* Scene rendered by components/simulators/carnot-cycle-anim-view.tsx.
|
||||
*/
|
||||
export const carnotCycleAnim: AnimPlugin = {
|
||||
family: 'anim',
|
||||
id: 'carnot-cycle-anim',
|
||||
title: {
|
||||
fr: 'Le cycle de Carnot en mouvement',
|
||||
en: 'The Carnot cycle in motion',
|
||||
},
|
||||
summary: {
|
||||
fr: 'Animation du cycle de Carnot : piston, détentes/compressions isothermes et adiabatiques, diagramme P-V tracé en direct, échanges Q et W. 2ᵉ principe, machine thermique, réfrigérateur.',
|
||||
en: 'Animated Carnot cycle: piston, isothermal and adiabatic expansion/compression, live P-V diagram, Q and W exchanges. Second law, heat engine, refrigerator.',
|
||||
},
|
||||
keywords: [
|
||||
'carnot',
|
||||
'thermodynamique',
|
||||
'thermodynamics',
|
||||
'cycle',
|
||||
'piston',
|
||||
'isotherme',
|
||||
'adiabatique',
|
||||
'isothermal',
|
||||
'adiabatic',
|
||||
'entropie',
|
||||
'entropy',
|
||||
'machine thermique',
|
||||
'heat engine',
|
||||
'deuxième principe',
|
||||
'second law',
|
||||
],
|
||||
disclaimer: {
|
||||
fr: 'Schéma pédagogique — grandeurs illustratives, gaz parfait.',
|
||||
en: 'Teaching schematic — illustrative values, ideal gas.',
|
||||
},
|
||||
beats: [
|
||||
{
|
||||
id: 'b1',
|
||||
speak: {
|
||||
fr: '**Détente isotherme** à $T_h$ : le gaz pousse le piston, la chaleur $Q_h$ entre depuis la source chaude.',
|
||||
en: '**Isothermal expansion** at $T_h$: the gas pushes the piston, heat $Q_h$ flows in from the hot reservoir.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
speak: {
|
||||
fr: '**Détente adiabatique** : isolé, le gaz continue de se détendre et se refroidit de $T_h$ à $T_c$.',
|
||||
en: '**Adiabatic expansion**: insulated, the gas keeps expanding and cools from $T_h$ down to $T_c$.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b3',
|
||||
speak: {
|
||||
fr: '**Compression isotherme** à $T_c$ : on travaille sur le gaz, la chaleur $Q_c$ sort vers la source froide.',
|
||||
en: '**Isothermal compression** at $T_c$: work is done on the gas, heat $Q_c$ flows out to the cold reservoir.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b4',
|
||||
speak: {
|
||||
fr: '**Compression adiabatique** : le gaz remonte de $T_c$ à $T_h$ — le cycle se referme.',
|
||||
en: '**Adiabatic compression**: the gas warms back from $T_c$ to $T_h$ — the cycle closes.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b5',
|
||||
speak: {
|
||||
fr: "Le **travail net** $W = Q_h - Q_c$ est l'aire du cycle sur le diagramme $P$–$V$. Le rendement $\eta = 1 - T_c/T_h$ est le maximum permis par le 2ᵉ principe.",
|
||||
en: '**Net work** $W = Q_h - Q_c$ is the cycle area on the $P$–$V$ diagram. Efficiency $\eta = 1 - T_c/T_h$ is the second-law maximum.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
254
memento-note/lib/simulators/carnot-cycle.ts
Normal file
254
memento-note/lib/simulators/carnot-cycle.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { SimulatorPlugin } from './types'
|
||||
|
||||
/**
|
||||
* Ideal Carnot machine between two reservoirs (2nd law).
|
||||
*
|
||||
* Physics (absolute temperatures only):
|
||||
* - Refrigerator: COP_R = Tc/(Th−Tc), W_min = Qc/COP_R, Qh = Qc+W
|
||||
* - Heat pump: COP_HP = Th/(Th−Tc) = COP_R+1, W_min = Qh/COP_HP
|
||||
* - Engine: η = 1−Tc/Th, W_out = η·Qh, Qc = Qh−W
|
||||
*
|
||||
* `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only).
|
||||
* For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin.
|
||||
*
|
||||
* Refs: COP_R Carnot = Tc/(Th−Tc); 1st law Qh=Qc+W; energy↔power interchangeable
|
||||
* if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators).
|
||||
*/
|
||||
export const carnotCycleSimulator: SimulatorPlugin = {
|
||||
family: 'sim',
|
||||
id: 'carnot-cycle',
|
||||
title: {
|
||||
fr: 'Machine de Carnot (frigo / PAC / moteur)',
|
||||
en: 'Carnot machine (fridge / heat pump / engine)',
|
||||
},
|
||||
summary: {
|
||||
fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(Th−Tc), COP pompe à chaleur Th/(Th−Tc), rendement moteur η=1−Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.',
|
||||
en: 'Carnot limits between two reservoirs: fridge COP Tc/(Th−Tc), heat-pump COP Th/(Th−Tc), engine η=1−Tc/Th, minimum work or power. 1st and 2nd laws.',
|
||||
},
|
||||
keywords: [
|
||||
'carnot',
|
||||
'thermodynamique',
|
||||
'thermodynamics',
|
||||
'cop',
|
||||
'réfrigérateur',
|
||||
'frigo',
|
||||
'refrigerator',
|
||||
'pompe à chaleur',
|
||||
'heat pump',
|
||||
'moteur',
|
||||
'engine',
|
||||
'rendement',
|
||||
'efficiency',
|
||||
'watt',
|
||||
'puissance',
|
||||
'power',
|
||||
'deuxième principe',
|
||||
'second law',
|
||||
],
|
||||
params: [
|
||||
{
|
||||
id: 't_cold',
|
||||
symbol: 'T_c',
|
||||
label: { fr: 'Source froide', en: 'Cold reservoir' },
|
||||
min: 200,
|
||||
max: 320,
|
||||
step: 1,
|
||||
defaultValue: 260,
|
||||
unit: 'K',
|
||||
intent: 'cache',
|
||||
},
|
||||
{
|
||||
id: 't_hot',
|
||||
symbol: 'T_h',
|
||||
label: { fr: 'Source chaude', en: 'Hot reservoir' },
|
||||
min: 273,
|
||||
max: 400,
|
||||
step: 1,
|
||||
defaultValue: 300,
|
||||
unit: 'K',
|
||||
intent: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'q_cold',
|
||||
symbol: 'Q_c',
|
||||
label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' },
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 5,
|
||||
defaultValue: 100,
|
||||
unit: 'kJ',
|
||||
intent: 'flow',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'cop_fridge',
|
||||
symbol: '\\mathrm{COP}_{R}',
|
||||
label: { fr: 'COP réfrigérateur', en: 'Fridge COP' },
|
||||
intent: 'output',
|
||||
digits: 2,
|
||||
},
|
||||
{
|
||||
id: 'cop_hp',
|
||||
symbol: '\\mathrm{COP}_{HP}',
|
||||
label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' },
|
||||
intent: 'output',
|
||||
digits: 2,
|
||||
},
|
||||
{
|
||||
id: 'eta',
|
||||
symbol: '\\eta',
|
||||
label: { fr: 'Rendement moteur', en: 'Engine efficiency' },
|
||||
unit: '%',
|
||||
intent: 'highlight',
|
||||
digits: 1,
|
||||
},
|
||||
{
|
||||
id: 'w_min',
|
||||
symbol: 'W',
|
||||
label: { fr: 'Travail (énergie)', en: 'Work (energy)' },
|
||||
unit: 'kJ',
|
||||
intent: 'compute',
|
||||
digits: 1,
|
||||
},
|
||||
{
|
||||
id: 'q_hot',
|
||||
symbol: 'Q_h',
|
||||
label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' },
|
||||
unit: 'kJ',
|
||||
intent: 'flow',
|
||||
digits: 1,
|
||||
},
|
||||
],
|
||||
compute(env) {
|
||||
const tc = env.t_cold
|
||||
const th = env.t_hot
|
||||
const qc = env.q_cold
|
||||
if (!(th > tc) || !(qc > 0)) {
|
||||
return {
|
||||
cop_fridge: NaN,
|
||||
cop_hp: NaN,
|
||||
eta: NaN,
|
||||
w_min: NaN,
|
||||
q_hot: NaN,
|
||||
}
|
||||
}
|
||||
const copFridge = tc / (th - tc)
|
||||
const copHp = th / (th - tc)
|
||||
const eta = 1 - tc / th
|
||||
const wMin = qc / copFridge
|
||||
return {
|
||||
cop_fridge: copFridge,
|
||||
cop_hp: copHp,
|
||||
eta: eta * 100,
|
||||
w_min: wMin,
|
||||
q_hot: qc + wMin,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** Operating mode for the interactive view (UI-only; physics shared). */
|
||||
export type CarnotMode = 'fridge' | 'heat_pump' | 'engine'
|
||||
|
||||
/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */
|
||||
export type CarnotQuantity = 'energy' | 'power'
|
||||
|
||||
export type CarnotPhysics = {
|
||||
ok: boolean
|
||||
tc: number
|
||||
th: number
|
||||
copR: number
|
||||
copHP: number
|
||||
eta: number
|
||||
/** Heat exchanged with cold reservoir (magnitude > 0). */
|
||||
qc: number
|
||||
/** Heat exchanged with hot reservoir (magnitude > 0). */
|
||||
qh: number
|
||||
/** Work magnitude > 0 (input for fridge/PAC, output for engine). */
|
||||
w: number
|
||||
/** Reversible check: Qc/Tc ≈ Qh/Th */
|
||||
entropyOk: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve magnitudes for the selected mode.
|
||||
* `load` is the primary useful quantity:
|
||||
* - fridge: Qc extracted from cold
|
||||
* - heat_pump: Qh delivered to hot
|
||||
* - engine: Qh absorbed from hot
|
||||
*/
|
||||
export function resolveCarnotPhysics(
|
||||
tc: number,
|
||||
th: number,
|
||||
load: number,
|
||||
mode: CarnotMode
|
||||
): CarnotPhysics {
|
||||
if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) {
|
||||
return {
|
||||
ok: false,
|
||||
tc,
|
||||
th,
|
||||
copR: NaN,
|
||||
copHP: NaN,
|
||||
eta: NaN,
|
||||
qc: NaN,
|
||||
qh: NaN,
|
||||
w: NaN,
|
||||
entropyOk: false,
|
||||
}
|
||||
}
|
||||
const copR = tc / (th - tc)
|
||||
const copHP = th / (th - tc)
|
||||
const eta = 1 - tc / th
|
||||
|
||||
let qc: number
|
||||
let qh: number
|
||||
let w: number
|
||||
if (mode === 'fridge') {
|
||||
qc = load
|
||||
w = qc / copR
|
||||
qh = qc + w
|
||||
} else if (mode === 'heat_pump') {
|
||||
qh = load
|
||||
w = qh / copHP
|
||||
qc = qh - w
|
||||
} else {
|
||||
qh = load
|
||||
w = eta * qh
|
||||
qc = qh - w
|
||||
}
|
||||
|
||||
const ratioC = qc / tc
|
||||
const ratioH = qh / th
|
||||
const entropyOk =
|
||||
Number.isFinite(ratioC) &&
|
||||
Number.isFinite(ratioH) &&
|
||||
Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6
|
||||
|
||||
return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk }
|
||||
}
|
||||
|
||||
/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */
|
||||
export function fridgeLoadFromModeLoad(
|
||||
tc: number,
|
||||
th: number,
|
||||
modeLoad: number,
|
||||
mode: CarnotMode
|
||||
): number {
|
||||
if (!(th > tc) || !(modeLoad > 0)) return modeLoad
|
||||
if (mode === 'fridge') return modeLoad
|
||||
if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th
|
||||
return modeLoad * (tc / th) // engine: Qc = Qh · (1−η) = Qh · Tc/Th
|
||||
}
|
||||
|
||||
export function modeLoadFromFridgeLoad(
|
||||
tc: number,
|
||||
th: number,
|
||||
fridgeQc: number,
|
||||
mode: CarnotMode
|
||||
): number {
|
||||
if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc
|
||||
if (mode === 'fridge') return fridgeQc
|
||||
if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc
|
||||
return fridgeQc * (th / tc) // engine Qh = Qc / (1−η) = Qc · Th/Tc
|
||||
}
|
||||
65
memento-note/lib/simulators/index.ts
Normal file
65
memento-note/lib/simulators/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { carnotCycleSimulator } from './carnot-cycle'
|
||||
import { carnotCycleAnim } from './carnot-cycle-anim'
|
||||
import { tsDiagramAnim } from './ts-diagram'
|
||||
import type { AnimPlugin, AnyPlugin, SimulatorPlugin } from './types'
|
||||
|
||||
export const GENERIC_SIM_ID = 'generic-formula' as const
|
||||
|
||||
const REGISTRY: Record<string, AnyPlugin> = {
|
||||
[carnotCycleSimulator.id]: carnotCycleSimulator,
|
||||
[carnotCycleAnim.id]: carnotCycleAnim,
|
||||
[tsDiagramAnim.id]: tsDiagramAnim,
|
||||
}
|
||||
|
||||
export function getPlugin(id: string): AnyPlugin | null {
|
||||
return REGISTRY[id] ?? null
|
||||
}
|
||||
|
||||
export function getSimulator(id: string): SimulatorPlugin | null {
|
||||
const p = REGISTRY[id]
|
||||
return p?.family === 'sim' ? p : null
|
||||
}
|
||||
|
||||
export function getAnimPlugin(id: string): AnimPlugin | null {
|
||||
const p = REGISTRY[id]
|
||||
return p?.family === 'anim' ? p : null
|
||||
}
|
||||
|
||||
export function isCatalogSimId(id: string): boolean {
|
||||
return id !== GENERIC_SIM_ID && id in REGISTRY
|
||||
}
|
||||
|
||||
export function listSimulators(): AnyPlugin[] {
|
||||
return Object.values(REGISTRY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact catalog injected into the section-generation prompt so the LLM
|
||||
* can pick a curated plugin (sim or anim) and bind note values into a preset.
|
||||
*/
|
||||
export function catalogForPrompt(lang: string): string {
|
||||
const fr = lang.startsWith('fr')
|
||||
const catalog = listSimulators().map((plugin) => ({
|
||||
simId: plugin.id,
|
||||
family: plugin.family,
|
||||
summary: fr ? plugin.summary.fr : plugin.summary.en,
|
||||
keywords: plugin.keywords.slice(0, 10),
|
||||
...(plugin.family === 'sim'
|
||||
? {
|
||||
params: plugin.params.map((p) => ({
|
||||
id: p.id,
|
||||
symbol: p.symbol,
|
||||
range: [p.min, p.max],
|
||||
default: p.defaultValue,
|
||||
unit: p.unit,
|
||||
})),
|
||||
outputs: plugin.outputs.map((o) => o.symbol),
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
return JSON.stringify(catalog, null, 1)
|
||||
}
|
||||
|
||||
export type { SimulatorPlugin, AnimPlugin, AnyPlugin, AnimBeat, SimI18n, SimParamDef, SimOutputDef } from './types'
|
||||
export type { IntentId }
|
||||
74
memento-note/lib/simulators/ts-diagram.ts
Normal file
74
memento-note/lib/simulators/ts-diagram.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AnimPlugin } from './types'
|
||||
|
||||
/**
|
||||
* T–s diagram of the Carnot cycle — the canonical 2nd-law diagram:
|
||||
* isotherms are horizontal, adiabatics vertical, heat = area.
|
||||
* Same 5 phases as carnot-cycle-anim (piston) so both tell one story.
|
||||
*/
|
||||
export const tsDiagramAnim: AnimPlugin = {
|
||||
family: 'anim',
|
||||
id: 'ts-diagram',
|
||||
title: {
|
||||
fr: 'Le cycle de Carnot sur le diagramme T–s',
|
||||
en: 'The Carnot cycle on the T–s diagram',
|
||||
},
|
||||
summary: {
|
||||
fr: 'Diagramme température–entropie (T–s) du cycle de Carnot : isothermes horizontales, adiabatiques verticales, aires = chaleurs Q_h et Q_c, aire du cycle = travail W. 2ᵉ principe, entropie, rendement.',
|
||||
en: 'Temperature–entropy (T–s) diagram of the Carnot cycle: horizontal isotherms, vertical adiabatics, areas = heats Q_h and Q_c, cycle area = work W. Second law, entropy, efficiency.',
|
||||
},
|
||||
keywords: [
|
||||
'carnot',
|
||||
'entropie',
|
||||
'entropy',
|
||||
'diagramme t-s',
|
||||
't-s diagram',
|
||||
'thermodynamique',
|
||||
'thermodynamics',
|
||||
'deuxième principe',
|
||||
'second law',
|
||||
'rendement',
|
||||
'efficiency',
|
||||
'cycle',
|
||||
],
|
||||
disclaimer: {
|
||||
fr: 'Diagramme pédagogique — grandeurs illustratives.',
|
||||
en: 'Teaching diagram — illustrative values.',
|
||||
},
|
||||
beats: [
|
||||
{
|
||||
id: 'b1',
|
||||
speak: {
|
||||
fr: '**Détente isotherme** à $T_h$ : l’entropie croît, la chaleur $Q_h = T_h \\Delta s$ est l’aire sous l’isotherme.',
|
||||
en: '**Isothermal expansion** at $T_h$: entropy grows, heat $Q_h = T_h \\Delta s$ is the area under the isotherm.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
speak: {
|
||||
fr: '**Détente adiabatique** : verticale — l’entropie est constante, la température chute de $T_h$ à $T_c$.',
|
||||
en: '**Adiabatic expansion**: vertical line — entropy is constant, temperature drops from $T_h$ to $T_c$.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b3',
|
||||
speak: {
|
||||
fr: '**Compression isotherme** à $T_c$ : l’entropie décroît, la chaleur $Q_c = T_c \\Delta s$ est rejetée — l’aire bleue.',
|
||||
en: '**Isothermal compression** at $T_c$: entropy decreases, heat $Q_c = T_c \\Delta s$ is rejected — the blue area.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b4',
|
||||
speak: {
|
||||
fr: '**Compression adiabatique** : remontée verticale de $T_c$ à $T_h$ — le rectangle se referme.',
|
||||
en: '**Adiabatic compression**: vertical climb from $T_c$ back to $T_h$ — the rectangle closes.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b5',
|
||||
speak: {
|
||||
fr: 'Le **travail net** $W = (T_h - T_c)\\Delta s$ est l’aire du rectangle. Rapport des aires = rendement $\\eta = 1 - T_c/T_h$ — le maximum du 2ᵉ principe.',
|
||||
en: '**Net work** $W = (T_h - T_c)\\Delta s$ is the rectangle area. Area ratio = efficiency $\\eta = 1 - T_c/T_h$ — the second-law maximum.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
72
memento-note/lib/simulators/types.ts
Normal file
72
memento-note/lib/simulators/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
|
||||
/** Bilingual label — page content follows the note's language, not the UI locale. */
|
||||
export type SimI18n = { fr: string; en: string }
|
||||
|
||||
export type SimParamDef = {
|
||||
id: string
|
||||
/** KaTeX symbol (without $…$), e.g. "T_c" */
|
||||
symbol: string
|
||||
label: SimI18n
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
defaultValue: number
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
}
|
||||
|
||||
export type SimOutputDef = {
|
||||
id: string
|
||||
/** KaTeX symbol (without $…$), e.g. "\\mathrm{COP}" */
|
||||
symbol: string
|
||||
label: SimI18n
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
/** Fraction digits for display (default 2). */
|
||||
digits?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A curated interactive simulator plugin (catalog).
|
||||
* The AI never writes simulation code — it picks a plugin and a preset.
|
||||
* `compute` must be pure and deterministic; it runs client-side on every
|
||||
* slider move and once server-side at validation time.
|
||||
*/
|
||||
export type SimulatorPlugin = {
|
||||
family: 'sim'
|
||||
id: string
|
||||
title: SimI18n
|
||||
/** Shown to the LLM for content matching. */
|
||||
summary: SimI18n
|
||||
keywords: string[]
|
||||
params: SimParamDef[]
|
||||
outputs: SimOutputDef[]
|
||||
compute(env: Record<string, number>): Record<string, number>
|
||||
}
|
||||
|
||||
// ── Animated pedagogical scenes (Play/Step narration over a coded scene) ────
|
||||
|
||||
export type AnimBeat = {
|
||||
id: string
|
||||
speak: SimI18n
|
||||
}
|
||||
|
||||
/**
|
||||
* A curated ANIMATION plugin: a hand-coded parametric scene (like the demos
|
||||
* on distill.pub / the Kimi AttnRes page) driven beat-by-beat by the shared
|
||||
* player chrome. The view component receives `stepIndex` and renders the
|
||||
* scene state — transitions are CSS (transform/opacity) only.
|
||||
*/
|
||||
export type AnimPlugin = {
|
||||
family: 'anim'
|
||||
id: string
|
||||
title: SimI18n
|
||||
summary: SimI18n
|
||||
keywords: string[]
|
||||
disclaimer?: SimI18n
|
||||
/** Narration beats; step N of the player = beat N. */
|
||||
beats: AnimBeat[]
|
||||
}
|
||||
|
||||
export type AnyPlugin = SimulatorPlugin | AnimPlugin
|
||||
@@ -38,7 +38,24 @@
|
||||
"createYourSpaceSubtitle": "Join the new era of smart note-taking.",
|
||||
"forgot": "Forgot?",
|
||||
"backToSite": "Back to site",
|
||||
"privacyTerms": "© 2025 Memento Labs — Privacy · Terms"
|
||||
"privacyTerms": "© 2025 Memento Labs — Privacy · Terms",
|
||||
"checkEmailTitle": "Check your email",
|
||||
"checkEmailDescription": "We sent a confirmation link to {email}. Open it to activate your account before signing in.",
|
||||
"checkEmailDescriptionGeneric": "We sent a confirmation link to your email. Open it to activate your account before signing in.",
|
||||
"resendVerification": "Resend confirmation email",
|
||||
"verifyResent": "Confirmation email sent. Check your inbox.",
|
||||
"verifyResendFailed": "Could not send the confirmation email. Try again later.",
|
||||
"verifyMissingEmail": "Enter your email address.",
|
||||
"verifyLoading": "Confirming your email…",
|
||||
"verifySuccessTitle": "Email confirmed",
|
||||
"verifySuccessDescription": "Your account is ready. You can now sign in.",
|
||||
"verifyExpiredTitle": "Link expired",
|
||||
"verifyExpiredDescription": "This confirmation link has expired. Request a new one.",
|
||||
"verifyInvalidTitle": "Invalid link",
|
||||
"verifyInvalidDescription": "This confirmation link is invalid or already used.",
|
||||
"emailNotVerified": "Please confirm your email before signing in.",
|
||||
"emailVerifiedBanner": "Email confirmed. You can sign in now.",
|
||||
"invalidCredentials": "Invalid email or password."
|
||||
},
|
||||
"sidebar": {
|
||||
"notes": "Notes",
|
||||
@@ -792,6 +809,18 @@
|
||||
"formal": "Formal",
|
||||
"casual": "Casual"
|
||||
},
|
||||
"interactiveDemo": {
|
||||
"invalid": "Invalid interactive demo",
|
||||
"empty": "Interactive demo unavailable",
|
||||
"step": "step",
|
||||
"speed": "Playback speed",
|
||||
"needMoreText": "Select at least ~20 words (or write more content) to generate a demo",
|
||||
"generating": "Generating interactive demo…",
|
||||
"generateSuccess": "Interactive demo inserted",
|
||||
"generateFailed": "Demo generation failed",
|
||||
"insertFailed": "Could not insert the demo",
|
||||
"quotaExceeded": "Not enough AI quota"
|
||||
},
|
||||
"memoryEcho": {
|
||||
"title": "I noticed something...",
|
||||
"description": "Proactive connections between your notes",
|
||||
@@ -1602,7 +1631,58 @@
|
||||
"notSynced": "Not synced yet (cron /api/cron/sync-usage)",
|
||||
"byFeature": "By feature (PostgreSQL)",
|
||||
"topUsers": "Top users",
|
||||
"noUsageData": "No usage data for this period yet"
|
||||
"noUsageData": "No usage data for this period yet",
|
||||
"healthTitle": "Stripe health check",
|
||||
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||
"healthSecret": "Secret key (server)",
|
||||
"healthPublishable": "Publishable key",
|
||||
"healthWebhook": "Webhook secret",
|
||||
"healthBillingFlag": "Billing enabled",
|
||||
"healthTrial": "Free trial",
|
||||
"trialDaysValue": "{days} days on first checkout",
|
||||
"modeTest": "Test mode (sk_test_…)",
|
||||
"modeLive": "Live mode (sk_live_…)",
|
||||
"modePlaceholder": "Placeholder / invalid key",
|
||||
"modeMissing": "Not configured",
|
||||
"configured": "Configured",
|
||||
"missing": "Missing",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"priceStatusTitle": "Price IDs vs Stripe",
|
||||
"colKey": "Plan",
|
||||
"colPriceId": "Price ID",
|
||||
"colSource": "Source",
|
||||
"colStripe": "Stripe amount",
|
||||
"priceError": "Lookup failed",
|
||||
"inactive": "inactive",
|
||||
"notChecked": "Not checked (no Stripe key)",
|
||||
"subsTitle": "Subscriptions overview",
|
||||
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||
"statPaid": "Active + trial",
|
||||
"statTrialing": "On trial",
|
||||
"statPastDue": "Past due",
|
||||
"statCanceling": "Cancel at period end",
|
||||
"byTier": "By tier",
|
||||
"byStatus": "By status",
|
||||
"usersWithoutSub": "Users with no Subscription row",
|
||||
"noSubs": "No subscriptions yet",
|
||||
"recentSubs": "Recent paid / trial accounts",
|
||||
"colUser": "User",
|
||||
"colTier": "Tier",
|
||||
"colStatus": "Status",
|
||||
"colPeriod": "Period / trial end",
|
||||
"canceling": "canceling",
|
||||
"manualTier": "manual (no Stripe sub)",
|
||||
"trialUntil": "Trial until {date}",
|
||||
"testGuideTitle": "How to test Stripe locally",
|
||||
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1812,6 +1892,8 @@
|
||||
"featureBrainstormCreate": "Brainstorm creations",
|
||||
"featureBrainstormSessions": "Brainstorm sessions",
|
||||
"featureCharts": "AI Charts",
|
||||
"featureInteractiveDemo": "Interactive demos",
|
||||
"featureInteractivePage": "Interactive pages",
|
||||
"featurePublishEnhance": "AI Publishing",
|
||||
"featureBrainstormExpand": "Brainstorm expansions",
|
||||
"featureBrainstormEnrich": "Brainstorm enrichments",
|
||||
@@ -2579,6 +2661,8 @@
|
||||
"slashTableDesc": "Insert a simple grid",
|
||||
"slashDatabase": "Structured View",
|
||||
"slashDatabaseDesc": "Embed your notebook's structured data",
|
||||
"slashInteractiveDemo": "Interactive Demo",
|
||||
"slashInteractiveDemoDesc": "AI step-by-step teaching demo (Play / Step)",
|
||||
"slashToggle": "Toggle Section",
|
||||
"slashToggleDesc": "Create a collapsible section",
|
||||
"slashCallout": "Callout",
|
||||
@@ -2726,6 +2810,21 @@
|
||||
"publishTemplateMagazine": "Magazine article",
|
||||
"publishTemplateBrief": "Expert brief",
|
||||
"publishTemplateEssay": "Essay",
|
||||
"publishTemplateInteractivePage": "Interactive page",
|
||||
"publishInteractivePage": "Interactive page",
|
||||
"publishInteractivePageHint": "AI-generated page: hero, sections, Play/Step demos — 20 credits",
|
||||
"publishInteractivePageGenerating": "Building interactive page…",
|
||||
"publishInteractivePageGeneratingWait": "Almost ready…",
|
||||
"publishInteractivePageGeneratingLong": "Still working…",
|
||||
"publishInteractivePagePlanning": "Analyzing content — planning the page…",
|
||||
"publishInteractivePageSectionProgress": "Section {current}/{total}: {title}",
|
||||
"publishInteractivePageFallback": "AI generation unavailable — showing simplified page",
|
||||
"publishInteractivePagePartialFallback": "Some sections were generated in simplified mode",
|
||||
"publishInteractivePageTooShort": "Note too short — add more content and try again",
|
||||
"publishInteractivePageSuccess": "Interactive page published!",
|
||||
"publishInteractivePageFailed": "Interactive page generation failed",
|
||||
"publishInteractivePagePreviewHint": "Preview — review, then publish to the public URL",
|
||||
"publishInteractivePageConfirm": "Publish page",
|
||||
"publishAiSuccess": "AI-enhanced page published!",
|
||||
"publishRewriteLabel": "Rewrite for the web",
|
||||
"publishRewriteOnHint": "Structures your editor blocks (exercises, toggles, callouts) for the web — AI writes only the intro",
|
||||
@@ -3381,7 +3480,11 @@
|
||||
"fetchStatusFailed": "Failed to fetch billing status",
|
||||
"fetchQuotasFailed": "Failed to load credit usage",
|
||||
"fetchInvoicesFailed": "Failed to load billing history.",
|
||||
"savePercent": "Save ~17%"
|
||||
"savePercent": "Save ~17%",
|
||||
"startTrialCta": "Start {days}-day free trial",
|
||||
"trialFeature": "{days}-day free trial (card required)",
|
||||
"trialEndsOn": "Your free trial ends on {date}. You will then be billed automatically.",
|
||||
"trialEndsLabel": "Trial ends"
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Out of AI credits",
|
||||
@@ -3528,6 +3631,12 @@
|
||||
"perMonthAnnual": "/mo, billed yearly",
|
||||
"perUser": "+ €3.90/user",
|
||||
"perUserAnnual": "+ €2.90/user, yearly",
|
||||
"savePercent": "Save ~17%",
|
||||
"proMonthly": "€9.90",
|
||||
"proAnnualMonthly": "€8.25",
|
||||
"businessMonthly": "€29.90",
|
||||
"businessAnnualMonthly": "€24.92",
|
||||
"enterprisePrice": "Custom",
|
||||
"popular": "Most chosen",
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
@@ -3572,7 +3681,10 @@
|
||||
"feature4": "Dedicated support",
|
||||
"feature5": "Live onboarding"
|
||||
},
|
||||
"basicPrice": "Free"
|
||||
"basicPrice": "Free",
|
||||
"trialBadge": "{days}-day free trial",
|
||||
"trialFeature": "{days}-day free trial (card required)",
|
||||
"trialCta": "Start {days}-day free trial"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Stop losing your best ideas.",
|
||||
|
||||
@@ -38,7 +38,24 @@
|
||||
"createYourSpaceSubtitle": "Rejoignez la nouvelle ère de la prise de notes intelligente.",
|
||||
"forgot": "Oublié ?",
|
||||
"backToSite": "Retour",
|
||||
"privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions"
|
||||
"privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions",
|
||||
"checkEmailTitle": "Vérifiez votre e-mail",
|
||||
"checkEmailDescription": "Nous avons envoyé un lien de confirmation à {email}. Ouvrez-le pour activer votre compte avant de vous connecter.",
|
||||
"checkEmailDescriptionGeneric": "Nous avons envoyé un lien de confirmation à votre e-mail. Ouvrez-le pour activer votre compte avant de vous connecter.",
|
||||
"resendVerification": "Renvoyer l’e-mail de confirmation",
|
||||
"verifyResent": "E-mail de confirmation envoyé. Vérifiez votre boîte de réception.",
|
||||
"verifyResendFailed": "Impossible d’envoyer l’e-mail de confirmation. Réessayez plus tard.",
|
||||
"verifyMissingEmail": "Saisissez votre adresse e-mail.",
|
||||
"verifyLoading": "Confirmation de votre e-mail…",
|
||||
"verifySuccessTitle": "E-mail confirmé",
|
||||
"verifySuccessDescription": "Votre compte est prêt. Vous pouvez vous connecter.",
|
||||
"verifyExpiredTitle": "Lien expiré",
|
||||
"verifyExpiredDescription": "Ce lien de confirmation a expiré. Demandez-en un nouveau.",
|
||||
"verifyInvalidTitle": "Lien invalide",
|
||||
"verifyInvalidDescription": "Ce lien de confirmation est invalide ou déjà utilisé.",
|
||||
"emailNotVerified": "Confirmez votre e-mail avant de vous connecter.",
|
||||
"emailVerifiedBanner": "E-mail confirmé. Vous pouvez vous connecter.",
|
||||
"invalidCredentials": "E-mail ou mot de passe incorrect."
|
||||
},
|
||||
"sidebar": {
|
||||
"notes": "Notes",
|
||||
@@ -798,6 +815,18 @@
|
||||
"formal": "Formel",
|
||||
"casual": "Décontracté"
|
||||
},
|
||||
"interactiveDemo": {
|
||||
"invalid": "Démo interactive invalide",
|
||||
"empty": "Démo interactive indisponible",
|
||||
"step": "étape",
|
||||
"speed": "Vitesse de lecture",
|
||||
"needMoreText": "Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo",
|
||||
"generating": "Génération de la démo interactive…",
|
||||
"generateSuccess": "Démo interactive insérée",
|
||||
"generateFailed": "Échec de la génération",
|
||||
"insertFailed": "Impossible d’insérer la démo",
|
||||
"quotaExceeded": "Quota IA insuffisant"
|
||||
},
|
||||
"memoryEcho": {
|
||||
"title": "💡 J'ai remarqué quelque chose...",
|
||||
"description": "Connexions proactives entre vos notes",
|
||||
@@ -1608,7 +1637,58 @@
|
||||
"notSynced": "Pas encore synchronisé (cron /api/cron/sync-usage)",
|
||||
"byFeature": "Par fonctionnalité (PostgreSQL)",
|
||||
"topUsers": "Utilisateurs les plus actifs",
|
||||
"noUsageData": "Aucune donnée pour cette période"
|
||||
"noUsageData": "Aucune donnée pour cette période",
|
||||
"healthTitle": "État Stripe",
|
||||
"healthDescription": "Statut des clés, webhooks, price IDs et flag facturation (les secrets ne sont jamais affichés).",
|
||||
"healthSecret": "Clé secrète (serveur)",
|
||||
"healthPublishable": "Clé publique",
|
||||
"healthWebhook": "Secret webhook",
|
||||
"healthBillingFlag": "Facturation activée",
|
||||
"healthTrial": "Essai gratuit",
|
||||
"trialDaysValue": "{days} jours au premier checkout",
|
||||
"modeTest": "Mode test (sk_test_…)",
|
||||
"modeLive": "Mode live (sk_live_…)",
|
||||
"modePlaceholder": "Clé placeholder / invalide",
|
||||
"modeMissing": "Non configurée",
|
||||
"configured": "Configuré",
|
||||
"missing": "Manquant",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé",
|
||||
"priceStatusTitle": "Price IDs vs Stripe",
|
||||
"colKey": "Offre",
|
||||
"colPriceId": "Price ID",
|
||||
"colSource": "Source",
|
||||
"colStripe": "Montant Stripe",
|
||||
"priceError": "Échec lecture",
|
||||
"inactive": "inactif",
|
||||
"notChecked": "Non vérifié (pas de clé Stripe)",
|
||||
"subsTitle": "Vue des abonnements",
|
||||
"subsDescription": "Compteurs depuis la base locale (synchronisée via webhooks Stripe).",
|
||||
"statPaid": "Actifs + essai",
|
||||
"statTrialing": "En essai",
|
||||
"statPastDue": "Impayés",
|
||||
"statCanceling": "Résiliation fin de période",
|
||||
"byTier": "Par tier",
|
||||
"byStatus": "Par statut",
|
||||
"usersWithoutSub": "Utilisateurs sans ligne Subscription",
|
||||
"noSubs": "Aucun abonnement pour l’instant",
|
||||
"recentSubs": "Comptes payants / essai récents",
|
||||
"colUser": "Utilisateur",
|
||||
"colTier": "Tier",
|
||||
"colStatus": "Statut",
|
||||
"colPeriod": "Période / fin d’essai",
|
||||
"canceling": "résiliation",
|
||||
"manualTier": "manuel (pas de sub Stripe)",
|
||||
"trialUntil": "Essai jusqu’au {date}",
|
||||
"testGuideTitle": "Comment tester Stripe en local",
|
||||
"testGuideDescription": "Checklist pour valider checkout, webhooks et essai.",
|
||||
"testStep1": "Stripe Dashboard → mode Test ON. Créer produits Pro/Business + prix mensuel/annuel + packs crédits.",
|
||||
"testStep2": "Mettre sk_test_…, pk_test_… dans .env. Mettre les price_… dans Admin → Facturation (ou env) et activer la facturation.",
|
||||
"testStep3": "Copier le whsec_… dans STRIPE_WEBHOOK_SECRET et redémarrer l’app.",
|
||||
"testStep4": "npm run dev → ouvrir /settings/billing avec un compte BASIC.",
|
||||
"testStep5": "Lancer le checkout Pro. Carte : 4242 4242 4242 4242, date future, CVC quelconque. Essai 7 jours attendu.",
|
||||
"testStep6": "Vérifier Admin → Facturation (TRIALING) et /settings/billing (date de fin d’essai).",
|
||||
"testCardHint": "Autres cartes : 4000000000009995 = échec paiement · 4000002500003155 = 3D Secure. Jamais de vraie carte en mode test."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1818,6 +1898,8 @@
|
||||
"featureBrainstormCreate": "Créations brainstorm",
|
||||
"featureBrainstormSessions": "Sessions brainstorm",
|
||||
"featureCharts": "Graphiques IA",
|
||||
"featureInteractiveDemo": "Démos interactives",
|
||||
"featureInteractivePage": "Pages interactives",
|
||||
"featurePublishEnhance": "Publication IA",
|
||||
"featureBrainstormExpand": "Extensions brainstorm",
|
||||
"featureBrainstormEnrich": "Enrichissements brainstorm",
|
||||
@@ -2585,6 +2667,8 @@
|
||||
"slashTableDesc": "Insérer un tableau simple",
|
||||
"slashDatabase": "Vue structurée",
|
||||
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
|
||||
"slashInteractiveDemo": "Démo interactive",
|
||||
"slashInteractiveDemoDesc": "Démo pédagogique IA étape par étape (Play / Step)",
|
||||
"slashToggle": "Section repliable",
|
||||
"slashToggleDesc": "Créer une section dépliable",
|
||||
"slashCallout": "Encadré",
|
||||
@@ -2732,6 +2816,21 @@
|
||||
"publishTemplateMagazine": "Article magazine",
|
||||
"publishTemplateBrief": "Fiche expert",
|
||||
"publishTemplateEssay": "Essai",
|
||||
"publishTemplateInteractivePage": "Page interactive",
|
||||
"publishInteractivePage": "Page interactive",
|
||||
"publishInteractivePageHint": "Page générée par IA : hero, sections, démos Play/Step — 20 crédits",
|
||||
"publishInteractivePageGenerating": "Construction de la page…",
|
||||
"publishInteractivePageGeneratingWait": "Presque prêt…",
|
||||
"publishInteractivePageGeneratingLong": "Toujours en cours…",
|
||||
"publishInteractivePagePlanning": "Analyse du contenu — plan de la page…",
|
||||
"publishInteractivePageSectionProgress": "Section {current}/{total} : {title}",
|
||||
"publishInteractivePageFallback": "Génération IA indisponible — page simplifiée affichée",
|
||||
"publishInteractivePagePartialFallback": "Certaines sections ont été générées en mode simplifié",
|
||||
"publishInteractivePageTooShort": "Note trop courte — ajoutez du contenu puis réessayez",
|
||||
"publishInteractivePageSuccess": "Page interactive publiée !",
|
||||
"publishInteractivePageFailed": "Échec de la page interactive",
|
||||
"publishInteractivePagePreviewHint": "Aperçu — vérifiez puis publiez sur l’URL publique",
|
||||
"publishInteractivePageConfirm": "Publier la page",
|
||||
"publishAiSuccess": "Page publiée avec mise en page IA !",
|
||||
"publishRewriteLabel": "Reformuler pour le web",
|
||||
"publishRewriteOnHint": "Structure vos blocs éditeur (exercices, toggles, encadrés) en page web — l'IA rédige seulement le chapô",
|
||||
@@ -3387,7 +3486,11 @@
|
||||
"fetchStatusFailed": "Échec du chargement des informations de facturation",
|
||||
"fetchQuotasFailed": "Échec du chargement des crédits",
|
||||
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
|
||||
"savePercent": "Économisez ~17%"
|
||||
"savePercent": "Économisez ~17%",
|
||||
"startTrialCta": "Essai gratuit {days} jours",
|
||||
"trialFeature": "Essai gratuit {days} jours (carte requise)",
|
||||
"trialEndsOn": "Votre essai gratuit se termine le {date}. Vous serez ensuite facturé automatiquement.",
|
||||
"trialEndsLabel": "Fin de l'essai"
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Plus de crédits IA",
|
||||
@@ -3534,6 +3637,12 @@
|
||||
"perMonthAnnual": "/mois, facturé à l'année",
|
||||
"perUser": "+ 3,90€/user",
|
||||
"perUserAnnual": "+ 2,90€/user, à l'année",
|
||||
"savePercent": "~17 %",
|
||||
"proMonthly": "9,90€",
|
||||
"proAnnualMonthly": "8,25€",
|
||||
"businessMonthly": "29,90€",
|
||||
"businessAnnualMonthly": "24,92€",
|
||||
"enterprisePrice": "Sur devis",
|
||||
"popular": "Le plus choisi",
|
||||
"basic": {
|
||||
"name": "Basic",
|
||||
@@ -3578,7 +3687,10 @@
|
||||
"feature4": "Support dédié",
|
||||
"feature5": "Onboarding live"
|
||||
},
|
||||
"basicPrice": "Gratuit"
|
||||
"basicPrice": "Gratuit",
|
||||
"trialBadge": "Essai gratuit {days} jours",
|
||||
"trialFeature": "Essai gratuit {days} jours (carte requise)",
|
||||
"trialCta": "Essayer {days} jours gratuitement"
|
||||
},
|
||||
"cta": {
|
||||
"title": "Arrêtez de perdre vos meilleures idées.",
|
||||
|
||||
274
memento-note/tests/unit/interactive-demo-validate.test.ts
Normal file
274
memento-note/tests/unit/interactive-demo-validate.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
assertTranslatePreservesStructure,
|
||||
heatmapCellIds,
|
||||
resolveActFinalState,
|
||||
validateInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '../../lib/interactive-demo'
|
||||
|
||||
const fixturePath = join(
|
||||
__dirname,
|
||||
'../../lib/interactive-demo/fixtures/attnres.demo.json'
|
||||
)
|
||||
|
||||
function loadFixture(): InteractiveDemoV1 {
|
||||
return JSON.parse(readFileSync(fixturePath, 'utf8')) as InteractiveDemoV1
|
||||
}
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T
|
||||
}
|
||||
|
||||
describe('validateInteractiveDemo', () => {
|
||||
test('AttnRes fixture passes (positive)', () => {
|
||||
const result = validateInteractiveDemo(loadFixture())
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.demo.id).toBe('demo.attnres')
|
||||
expect(result.demo.lang).toBe('fr')
|
||||
expect(result.demo.schemaVersion).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('allows hex mentioned in human speak (no false positive)', () => {
|
||||
const demo = clone(loadFixture())
|
||||
demo.acts[0].steps[0].speak =
|
||||
'La couleur #FF0000 signale l\'erreur ; aussi rgb(20,20,20).'
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects hex color in non-human structural field', () => {
|
||||
const demo = clone(loadFixture())
|
||||
// Force a structural string via chartType bypass — use node id? ids can't be hex easily.
|
||||
// Inject forbidden color into a future-proof structural key by mutating after parse path:
|
||||
// Put hex in edge style is enum-only; use disclaimer is human.
|
||||
// Put on a custom field that round-trips through JSON under panels payload as unknown — Zod strips unknown.
|
||||
// Use annotate text is human. So inject via re-validate raw object with hex in `id` of a node:
|
||||
demo.scene.panels[0] = {
|
||||
id: 'panel.trunk',
|
||||
type: 'svg-scene',
|
||||
payload: {
|
||||
nodes: [
|
||||
{ id: 'residualTrunk', label: 'ok' },
|
||||
{ id: '#ff0000', label: 'bad id that is also hex-like' },
|
||||
],
|
||||
},
|
||||
}
|
||||
// Wait — id "#ff0000" matches FORBIDDEN_COLOR_RE when scanning the id string.
|
||||
// But refs will also break. Better: add hex only as scanned string on a kept structural key.
|
||||
// chartType is enum. triangular is enum.
|
||||
// Scan walks all non-human strings — node `id` is structural.
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'forbidden_color')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unknown pattern', () => {
|
||||
const demo = clone(loadFixture())
|
||||
// @ts-expect-error intentional invalid pattern
|
||||
demo.acts[0].steps[0].pattern = 'zoomIntoDetail'
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.path.includes('pattern'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unknown chartType', () => {
|
||||
const demo = clone(loadFixture())
|
||||
const panel = demo.scene.panels[1]
|
||||
if (panel.type === 'chart') {
|
||||
// @ts-expect-error intentional
|
||||
panel.payload.chartType = 'pie3D'
|
||||
}
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.path.includes('chartType'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects pointTo to missing element id', () => {
|
||||
const demo = clone(loadFixture())
|
||||
demo.acts[0].steps[0].pointTo = ['doesNotExist']
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'unknown_element_id')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects step id not matching act.id.sN', () => {
|
||||
const demo = clone(loadFixture())
|
||||
demo.acts[0].steps[0].id = 's1'
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'step_id_format')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects more than 5 scenes', () => {
|
||||
const demo = clone(loadFixture())
|
||||
const extraScene = clone(demo.scene)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
demo.acts.push({
|
||||
id: `a${10 + i}`,
|
||||
title: `Extra ${i}`,
|
||||
scene: { ...extraScene, id: `scene.extra${i}` },
|
||||
steps: [
|
||||
{
|
||||
id: `a${10 + i}.s1`,
|
||||
speak: 'Extra step for scene cap.',
|
||||
pattern: 'overview',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'too_many_scenes')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects more than 12 steps per act', () => {
|
||||
const demo = clone(loadFixture())
|
||||
const act = demo.acts[0]
|
||||
while (act.steps.length < 13) {
|
||||
const n = act.steps.length + 1
|
||||
act.steps.push({
|
||||
id: `a1.s${n}`,
|
||||
speak: `Étape de remplissage ${n}.`,
|
||||
pattern: 'overview',
|
||||
})
|
||||
}
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.issues.some(
|
||||
(i) =>
|
||||
i.path.includes('steps') ||
|
||||
i.message.toLowerCase().includes('array') ||
|
||||
i.code === 'too_big'
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects annotate that overlaps pointTo in same step', () => {
|
||||
const demo = clone(loadFixture())
|
||||
demo.acts[0].steps[0].annotate = [
|
||||
{
|
||||
kind: 'arrow',
|
||||
targetIds: ['e.L1.h'],
|
||||
scope: 'act',
|
||||
},
|
||||
]
|
||||
const result = validateInteractiveDemo(demo)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'annotate_pointto_overlap')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertTranslatePreservesStructure', () => {
|
||||
test('accepts lang + speak change with same ids', () => {
|
||||
const source = loadFixture()
|
||||
const translated = clone(source)
|
||||
translated.lang = 'en'
|
||||
translated.acts[0].title = 'The Problem — Depth Dilution'
|
||||
translated.acts[0].steps[0].speak =
|
||||
'Each layer enters the residual trunk with a **fixed ×1 coefficient**.'
|
||||
const result = assertTranslatePreservesStructure(source, translated)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects translated variant that mutates an element id', () => {
|
||||
const source = loadFixture()
|
||||
const translated = clone(source)
|
||||
translated.lang = 'en'
|
||||
translated.scene.panels[0] = {
|
||||
...translated.scene.panels[0],
|
||||
type: 'svg-scene',
|
||||
payload: {
|
||||
nodes: [
|
||||
{ id: 'residualTrunkMUTATED', label: 'Residual trunk' },
|
||||
{ id: 'L1', label: 'Layer 1' },
|
||||
{ id: 'L2', label: 'Layer 2' },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e.L1.h',
|
||||
from: 'L1',
|
||||
to: 'residualTrunkMUTATED',
|
||||
style: 'solid',
|
||||
weight: 1,
|
||||
intent: 'flow',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
translated.acts[0].steps[0].pointTo = ['residualTrunkMUTATED', 'e.L1.h']
|
||||
const result = assertTranslatePreservesStructure(source, translated)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects mutation of unknown structural string key (default-keep)', () => {
|
||||
const source = clone(loadFixture()) as InteractiveDemoV1 & {
|
||||
scene: InteractiveDemoV1['scene'] & { layout?: string }
|
||||
}
|
||||
const translated = clone(source) as typeof source
|
||||
// Simulate a future structural field present on both, then mutated on translate
|
||||
;(source.scene as { layout?: string }).layout = 'horizontal'
|
||||
;(translated.scene as { layout?: string }).layout = 'vertical'
|
||||
translated.lang = 'en'
|
||||
const result = assertTranslatePreservesStructure(
|
||||
source as InteractiveDemoV1,
|
||||
translated as InteractiveDemoV1
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInteractiveDemo', () => {
|
||||
test('heatmap lower-triangular has 36 addressable cells', () => {
|
||||
expect(heatmapCellIds(8, 8, 'lower')).toHaveLength(36)
|
||||
})
|
||||
|
||||
test('a2 final state after s7: 36 cells revealed + 4 badges', () => {
|
||||
const demo = loadFixture()
|
||||
const final = resolveActFinalState(demo, 'a2')
|
||||
expect(final).toBeDefined()
|
||||
const revealedIds = Object.keys(final!.revealed)
|
||||
expect(revealedIds).toHaveLength(36)
|
||||
expect(revealedIds.every((id) => /^r\d+\.c\d+$/.test(id))).toBe(true)
|
||||
|
||||
const badges = final!.annotations.filter((a) => a.kind === 'badge')
|
||||
expect(badges).toHaveLength(4)
|
||||
expect(badges.map((b) => b.badgeIndex)).toEqual([1, 2, 3, 4])
|
||||
expect(badges.map((b) => b.targetIds[0]).sort()).toEqual(
|
||||
['r2.c2', 'r5.c4', 'r7.c1', 'r8.c1'].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
124
memento-note/tests/unit/interactive-page-validate.test.ts
Normal file
124
memento-note/tests/unit/interactive-page-validate.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSpecV1,
|
||||
} from '../../lib/interactive-page'
|
||||
|
||||
const fixturePath = join(
|
||||
__dirname,
|
||||
'../../lib/interactive-page/fixtures/thermo-page.json'
|
||||
)
|
||||
|
||||
function loadFixture(): PageSpecV1 {
|
||||
return JSON.parse(readFileSync(fixturePath, 'utf8')) as PageSpecV1
|
||||
}
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T
|
||||
}
|
||||
|
||||
describe('validateInteractivePage', () => {
|
||||
test('thermo fixture passes (positive)', () => {
|
||||
const result = validateInteractivePage(loadFixture())
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.page.id).toBe('page.thermo-test')
|
||||
expect(result.page.schemaVersion).toBe(1)
|
||||
expect(result.page.sections.length).toBeGreaterThanOrEqual(3)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unknown block type', () => {
|
||||
const page = clone(loadFixture()) as Record<string, unknown>
|
||||
const sections = page.sections as Array<{ blocks: unknown[] }>
|
||||
sections[0].blocks.push({ type: 'carousel', items: [] })
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.issues.some(
|
||||
(i) =>
|
||||
i.path.includes('blocks') ||
|
||||
i.code === 'invalid_union_discriminator' ||
|
||||
i.message.toLowerCase().includes('discriminat')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects hex color in non-human structural field', () => {
|
||||
const page = clone(loadFixture())
|
||||
// intent is enum — inject hex via overview card by forcing raw object
|
||||
const raw = clone(loadFixture()) as Record<string, unknown>
|
||||
const overview = raw.overview as {
|
||||
cards: Array<Record<string, unknown>>
|
||||
}
|
||||
overview.cards[0].intent = '#ff0000'
|
||||
const result = validateInteractivePage(raw)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects duplicate section id', () => {
|
||||
const page = clone(loadFixture())
|
||||
page.sections[1].id = page.sections[0].id
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'duplicate_section_id')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid embedded demo (delegation)', () => {
|
||||
const page = clone(loadFixture())
|
||||
const demoBlock = page.sections
|
||||
.flatMap((s) => s.blocks)
|
||||
.find((b) => b.type === 'demo')
|
||||
expect(demoBlock?.type).toBe('demo')
|
||||
if (demoBlock?.type === 'demo') {
|
||||
// @ts-expect-error intentional
|
||||
demoBlock.demo.acts[0].steps[0].pattern = 'notARealPattern'
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.issues.some(
|
||||
(i) =>
|
||||
i.path.includes('demo') ||
|
||||
i.code.startsWith('demo_') ||
|
||||
i.path.includes('pattern')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects table row width mismatch', () => {
|
||||
const page = clone(loadFixture())
|
||||
const table = page.sections
|
||||
.flatMap((s) => s.blocks)
|
||||
.find((b) => b.type === 'table')
|
||||
expect(table?.type).toBe('table')
|
||||
if (table?.type === 'table') {
|
||||
table.rows[0] = ['only-one']
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'table_shape')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('allows hex mentioned in human prose', () => {
|
||||
const page = clone(loadFixture())
|
||||
const prose = page.sections[0].blocks.find((b) => b.type === 'prose')
|
||||
if (prose?.type === 'prose') {
|
||||
prose.md = 'La couleur #FF0000 est un signal d’alarme.'
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user