feat: page interactive, démos Play/Step et simulateur Carnot

Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,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
}