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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user