Files
Momento/memento-note/components/interactive-page/page-sticky-nav.tsx
Antigravity 69c99e4f4f 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>
2026-07-24 17:51:43 +00:00

152 lines
4.6 KiB
TypeScript

'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>
)
}