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,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')
? 'Lessentiel 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>
)
}