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>
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
'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 }
|