From 69c99e4f4f71d95b234cb4ca62a2761bb6e2e730 Mon Sep 17 00:00:00 2001
From: Antigravity
Date: Fri, 24 Jul 2026 17:51:43 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20page=20interactive,=20d=C3=A9mos=20Play?=
=?UTF-8?q?/Step=20et=20simulateur=20Carnot?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
AGENTS.md | 16 +-
.../app/(main)/dev/interactive-page/page.tsx | 23 +
memento-note/app/(public)/p/[slug]/page.tsx | 23 +-
.../app/api/ai/interactive-demo/route.ts | 125 +++
.../app/api/ai/interactive-page/route.ts | 181 ++++
memento-note/app/api/notes/publish/route.ts | 97 +-
.../interactive-demo/demo-rich-label.tsx | 81 ++
.../interactive-demo/demo-scene-view.tsx | 948 ++++++++++++++++++
.../interactive-demo/demo-speak.tsx | 62 ++
.../interactive-demo-player.tsx | 387 +++++++
.../interactive-page-publish-dialog.tsx | 412 ++++++++
.../interactive-published-page.tsx | 48 +
.../interactive-page/page-blocks.tsx | 382 +++++++
.../components/interactive-page/page-md.tsx | 91 ++
.../interactive-page/page-sticky-nav.tsx | 151 +++
.../components/interactive-page/page-view.tsx | 270 +++++
.../components/interactive-page/sim-block.tsx | 104 ++
.../note-editor/note-editor-toolbar.tsx | 63 +-
memento-note/components/rich-text-editor.tsx | 122 ++-
.../components/settings/usage-breakdown.tsx | 2 +
.../simulators/anim-player-shell.tsx | 231 +++++
.../simulators/carnot-cycle-anim-view.tsx | 153 +++
.../simulators/carnot-cycle-view.tsx | 672 +++++++++++++
.../simulators/generic-formula-view.tsx | 205 ++++
memento-note/components/simulators/index.ts | 27 +
.../components/simulators/sim-controls.tsx | 144 +++
.../components/simulators/ts-diagram-view.tsx | 114 +++
.../tiptap-interactive-demo-extension.tsx | 131 +++
memento-note/components/usage-meter.tsx | 2 +
memento-note/docs/PUBLISHING.md | 29 +
.../interactive-demo-client.service.ts | 74 ++
.../interactive-demo-generate.service.ts | 291 ++++++
.../interactive-page-client.service.ts | 211 ++++
.../interactive-page-generate.service.ts | 398 ++++++++
.../services/interactive-page-llm.service.ts | 547 ++++++++++
.../ai/services/publish-enhance.service.ts | 1 +
memento-note/lib/credits.ts | 4 +-
.../lib/interactive-demo/constants.ts | 76 ++
.../fixtures/attnres.demo.json | 469 +++++++++
memento-note/lib/interactive-demo/index.ts | 40 +
.../lib/interactive-demo/intent-colors.ts | 62 ++
.../lib/interactive-demo/normalize.ts | 561 +++++++++++
memento-note/lib/interactive-demo/resolve.ts | 227 +++++
memento-note/lib/interactive-demo/schema.ts | 136 +++
memento-note/lib/interactive-demo/types.ts | 116 +++
memento-note/lib/interactive-demo/validate.ts | 452 +++++++++
.../lib/interactive-page/constants.ts | 86 ++
.../fixtures/thermo-page.json | 354 +++++++
memento-note/lib/interactive-page/index.ts | 25 +
.../lib/interactive-page/normalize.ts | 335 +++++++
memento-note/lib/interactive-page/schema.ts | 210 ++++
memento-note/lib/interactive-page/sim-eval.ts | 355 +++++++
memento-note/lib/interactive-page/types.ts | 153 +++
memento-note/lib/interactive-page/validate.ts | 303 ++++++
memento-note/lib/plan-entitlements.ts | 8 +
memento-note/lib/publish/types.ts | 8 +-
memento-note/lib/quota-utils.ts | 2 +
memento-note/lib/simulators/README.md | 59 ++
.../lib/simulators/carnot-cycle-anim.ts | 76 ++
memento-note/lib/simulators/carnot-cycle.ts | 254 +++++
memento-note/lib/simulators/index.ts | 65 ++
memento-note/lib/simulators/ts-diagram.ts | 74 ++
memento-note/lib/simulators/types.ts | 72 ++
memento-note/locales/en.json | 120 ++-
memento-note/locales/fr.json | 120 ++-
.../unit/interactive-demo-validate.test.ts | 274 +++++
.../unit/interactive-page-validate.test.ts | 124 +++
67 files changed, 12005 insertions(+), 33 deletions(-)
create mode 100644 memento-note/app/(main)/dev/interactive-page/page.tsx
create mode 100644 memento-note/app/api/ai/interactive-demo/route.ts
create mode 100644 memento-note/app/api/ai/interactive-page/route.ts
create mode 100644 memento-note/components/interactive-demo/demo-rich-label.tsx
create mode 100644 memento-note/components/interactive-demo/demo-scene-view.tsx
create mode 100644 memento-note/components/interactive-demo/demo-speak.tsx
create mode 100644 memento-note/components/interactive-demo/interactive-demo-player.tsx
create mode 100644 memento-note/components/interactive-page/interactive-page-publish-dialog.tsx
create mode 100644 memento-note/components/interactive-page/interactive-published-page.tsx
create mode 100644 memento-note/components/interactive-page/page-blocks.tsx
create mode 100644 memento-note/components/interactive-page/page-md.tsx
create mode 100644 memento-note/components/interactive-page/page-sticky-nav.tsx
create mode 100644 memento-note/components/interactive-page/page-view.tsx
create mode 100644 memento-note/components/interactive-page/sim-block.tsx
create mode 100644 memento-note/components/simulators/anim-player-shell.tsx
create mode 100644 memento-note/components/simulators/carnot-cycle-anim-view.tsx
create mode 100644 memento-note/components/simulators/carnot-cycle-view.tsx
create mode 100644 memento-note/components/simulators/generic-formula-view.tsx
create mode 100644 memento-note/components/simulators/index.ts
create mode 100644 memento-note/components/simulators/sim-controls.tsx
create mode 100644 memento-note/components/simulators/ts-diagram-view.tsx
create mode 100644 memento-note/components/tiptap-interactive-demo-extension.tsx
create mode 100644 memento-note/docs/PUBLISHING.md
create mode 100644 memento-note/lib/ai/services/interactive-demo-client.service.ts
create mode 100644 memento-note/lib/ai/services/interactive-demo-generate.service.ts
create mode 100644 memento-note/lib/ai/services/interactive-page-client.service.ts
create mode 100644 memento-note/lib/ai/services/interactive-page-generate.service.ts
create mode 100644 memento-note/lib/ai/services/interactive-page-llm.service.ts
create mode 100644 memento-note/lib/interactive-demo/constants.ts
create mode 100644 memento-note/lib/interactive-demo/fixtures/attnres.demo.json
create mode 100644 memento-note/lib/interactive-demo/index.ts
create mode 100644 memento-note/lib/interactive-demo/intent-colors.ts
create mode 100644 memento-note/lib/interactive-demo/normalize.ts
create mode 100644 memento-note/lib/interactive-demo/resolve.ts
create mode 100644 memento-note/lib/interactive-demo/schema.ts
create mode 100644 memento-note/lib/interactive-demo/types.ts
create mode 100644 memento-note/lib/interactive-demo/validate.ts
create mode 100644 memento-note/lib/interactive-page/constants.ts
create mode 100644 memento-note/lib/interactive-page/fixtures/thermo-page.json
create mode 100644 memento-note/lib/interactive-page/index.ts
create mode 100644 memento-note/lib/interactive-page/normalize.ts
create mode 100644 memento-note/lib/interactive-page/schema.ts
create mode 100644 memento-note/lib/interactive-page/sim-eval.ts
create mode 100644 memento-note/lib/interactive-page/types.ts
create mode 100644 memento-note/lib/interactive-page/validate.ts
create mode 100644 memento-note/lib/simulators/README.md
create mode 100644 memento-note/lib/simulators/carnot-cycle-anim.ts
create mode 100644 memento-note/lib/simulators/carnot-cycle.ts
create mode 100644 memento-note/lib/simulators/index.ts
create mode 100644 memento-note/lib/simulators/ts-diagram.ts
create mode 100644 memento-note/lib/simulators/types.ts
create mode 100644 memento-note/tests/unit/interactive-demo-validate.test.ts
create mode 100644 memento-note/tests/unit/interactive-page-validate.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index d01e2f2..83c2172 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,18 +2,18 @@
## Learned User Preferences
-- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
-- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
+- Préfère les échanges en **français correct** (pas d'anglais par défaut — l'utilisateur n'est pas à l'aise pour corriger en anglais), avec des explications détaillées et claires (éviter le jargon flou) ; **rester sur le sujet demandé** (ne pas digresser vers git/ops hors scope quand la demande porte sur une feature métier) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
+- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **jamais de marque tierce** dans les libellés utilisateur (ex. référence design « Kimi » → UI/i18n = « Page interactive » / « Interactive page ») ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `` obligatoire pour la navigation interne, pas ``) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
-- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
-- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents…) — pas un slideshow landing seul ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
+- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; inscription **email/mdp** : **email de confirmation obligatoire** (`lib/auth/email-verification.ts`, envoi à l'inscription) — login bloqué tant que `emailVerified` est null ; OAuth Google marque l'email comme vérifié automatiquement ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
+- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **Interactive Demo** (player TipTap) : sélecteur de vitesse **visible** (`0.5x` / `1x` / `2x` / `4x` — pas un bouton cycle cryptique) ; scènes **dignes du contenu** (pas de boîtes jouet 2 nœuds / texte plat) — labels et équations issus de la note (TipTap math `data-latex` / KaTeX) ; génération IA via le **modèle slides** (`getSlidesProvider`), **pas** la lane chat ; **Page interactive** (explainer publié, **≠** Interactive Demo TipTap) : pipeline note → PageSpec → `/p/{slug}` ; priorité **vitesse/fiabilité** de génération (chemin **déterministe** `buildPageFromNote` / démo déterministe plutôt que LLM lent qui timeoute ~150 s vs abort client) ; simulateurs (ex. cycle de Carnot) : flèches correctement orientées, têtes SVG non déformées, flux branchés sur la machine (pas de flèches flottantes) ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents, menu `/` éditeur…) — pas un slideshow landing seul ; démos note/éditeur : **masquer sidebar/carnets** pour focaliser la note ; style kinetic OK (coupes rapides + texte à l'écran) mais **pas de fade-to-black** entre plans (clignote) — **crossfades** ; après saisie, montrer le **sparkle** titre IA ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
-- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
+- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **essai gratuit abonnement = 7 jours** (`SUBSCRIPTION_TRIAL_DAYS` dans `lib/billing/trial-constants.ts` — pas 14) : afficher clairement sur **landing** et UI facturation (**i18n partout**) ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
## Learned Workspace Facts
@@ -22,10 +22,10 @@
- i18n : 15 fichiers sous `memento-note/locales/` (de, en, es, fr, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) ; logique sous `memento-note/lib/i18n/` ; référence `en.json` (~2218 clés) ; auditer les « non traduits » par flatten EN vs locale (souvent valeurs identiques à l'EN).
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
-- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA et config Stripe métier en base, effet ~60 s ; guide `memento-note/docs/admin-billing-quotas-guide.md`.
+- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA, config Stripe métier en base (effet ~60 s), santé/stats ops ; essai **7 jours** via `lib/billing/trial-constants.ts` ; guide `memento-note/docs/admin-billing-quotas-guide.md` ; settings user `/settings/billing` (`billing-plans.tsx`).
- **Hosts** : machine agent/dev (**192.168.1.83**, Vibe/annotations locales) ≠ prod (**192.168.1.190**, dépôt `/opt/memento`) — ne jamais diagnostiquer l'un pour l'autre. Production : conteneur `memento-note` port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; auth prod requiert `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `.env.docker` (sinon OAuth `invalid_client`) et `ALLOW_REGISTRATION=true` pour l'inscription email ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; monitoring Grafana via `GRAFANA_BIND` / `GRAFANA_URL` dans `.env.docker` (pas d'IP en dur dans le compose) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
-- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
+- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **Interactive Demo** : JSON déclaratif allowlisté + player TipTap (`lib/interactive-demo/` — `validate.ts` / `resolve.ts` / schéma ; `components/interactive-demo/` ; extension `tiptap-interactive-demo-extension` ; API `/api/ai/interactive-demo` via `getSlidesProvider` + extraction formules type slides, labels KaTeX) — le player **consomme** `resolve.ts` (pas de logique de portée dans React) ; **Page interactive** (explainer) : `lib/interactive-page/` (PageSpecV1, schema/validate/normalize, fixtures thermo/Carnot) + `components/interactive-page/` (`page-view`, `sim-block`, publish dialog) + API `/api/ai/interactive-page` ; preview `/dev/interactive-page` ; publication publique `/p/{slug}` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
-- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom.
+- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `make_promo_slash.py`, `make_promo_slash_kinetic.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom (crossfades si kinetic).
diff --git a/memento-note/app/(main)/dev/interactive-page/page.tsx b/memento-note/app/(main)/dev/interactive-page/page.tsx
new file mode 100644
index 0000000..027a639
--- /dev/null
+++ b/memento-note/app/(main)/dev/interactive-page/page.tsx
@@ -0,0 +1,23 @@
+import { notFound } from 'next/navigation'
+import { PageView } from '@/components/interactive-page/page-view'
+import { validateInteractivePage } from '@/lib/interactive-page'
+import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
+
+/**
+ * Dev preview of PageView + thermo fixture (spec §8.3).
+ * Only available outside production.
+ */
+export default function InteractivePagePreviewPage() {
+ if (process.env.NODE_ENV === 'production') notFound()
+
+ const parsed = validateInteractivePage(thermoFixture)
+ if (!parsed.ok) {
+ return (
+
+ Fixture invalide : {parsed.issues[0]?.message}
+
+ )
+ }
+
+ return
+}
diff --git a/memento-note/app/(public)/p/[slug]/page.tsx b/memento-note/app/(public)/p/[slug]/page.tsx
index 110b1bb..39bbdcc 100644
--- a/memento-note/app/(public)/p/[slug]/page.tsx
+++ b/memento-note/app/(public)/p/[slug]/page.tsx
@@ -8,6 +8,8 @@ import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
import { ReadingProgress } from '@/components/publish/reading-progress'
import { CopyLinkButton } from '@/components/publish/copy-link-button'
+import { InteractivePublishedPage } from '@/components/interactive-page/interactive-published-page'
+import { isInteractivePageTemplate } from '@/lib/publish/types'
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
@@ -1075,16 +1077,29 @@ export default async function PublishedNotePage({ params }: { params: Promise<{
const note = await getPublishedNote(slug)
if (!note) notFound()
+ const isStale = Boolean(
+ note.publishedSourceHash
+ && computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
+ )
+
+ if (
+ isInteractivePageTemplate(note.publishedTemplate) &&
+ note.publishedContent
+ ) {
+ return (
+
+ )
+ }
+
const usesAiLayout = Boolean(note.publishedContent)
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
const bodyHtml = processNoteHtmlForPublish(rawHtml)
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
const readingTime = estimateReadingTime(readingSource)
- const isStale = Boolean(
- note.publishedSourceHash
- && computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
- )
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
diff --git a/memento-note/app/api/ai/interactive-demo/route.ts b/memento-note/app/api/ai/interactive-demo/route.ts
new file mode 100644
index 0000000..831985a
--- /dev/null
+++ b/memento-note/app/api/ai/interactive-demo/route.ts
@@ -0,0 +1,125 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { auth } from '@/auth'
+import { getSystemConfig } from '@/lib/config'
+import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
+import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
+import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
+import { getSlidesProvider } from '@/lib/ai/factory'
+import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
+
+export const maxDuration = 180
+
+const requestSchema = z.object({
+ content: z.string().min(20),
+ selection: z.string().optional().nullable(),
+ lang: z.string().optional(),
+ noteId: z.string().optional(),
+})
+
+function stripHtml(html: string): string {
+ return html
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ if (!(await hasUserAiConsent())) {
+ return aiConsentForbiddenResponse()
+ }
+
+ const body = await req.json()
+ const parsed = requestSchema.parse(body)
+ // Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
+ const sourceRaw = parsed.selection?.trim() || parsed.content
+ const sourcePlain = stripHtml(sourceRaw)
+ const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
+ if (wordCount < 20) {
+ return NextResponse.json(
+ {
+ error:
+ 'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
+ },
+ { status: 400 }
+ )
+ }
+
+ try {
+ await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
+ lane: 'chat',
+ })
+ } catch (err) {
+ if (err instanceof QuotaExceededError) {
+ return NextResponse.json(err.toJSON(), { status: 402 })
+ }
+ if (
+ err instanceof QuotaServiceUnavailableError ||
+ process.env.NODE_ENV === 'production'
+ ) {
+ return NextResponse.json(
+ { error: 'QUOTA_SERVICE_UNAVAILABLE' },
+ { status: 503 }
+ )
+ }
+ console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
+ }
+
+ const config = await getSystemConfig()
+ const lang = parsed.lang || 'fr'
+ // Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
+ const provider = getSlidesProvider(config)
+
+ const result = await generateInteractiveDemoFromContent({
+ content: sourceRaw,
+ lang,
+ provider,
+ })
+
+ if (!result.ok) {
+ const first = result.issues[0]
+ const detail = first
+ ? `${first.path ? first.path + ': ' : ''}${first.message}`
+ : ''
+ console.error(
+ '[/api/ai/interactive-demo] validation failed after repairs',
+ result.issues.slice(0, 10)
+ )
+ return NextResponse.json(
+ {
+ error: detail
+ ? `Démo invalide après correction — ${detail}`
+ : 'La génération a produit un JSON invalide après correction',
+ issues: result.issues.slice(0, 10),
+ attempts: result.attempts,
+ },
+ { status: 422 }
+ )
+ }
+
+ return NextResponse.json({
+ demo: result.demo,
+ attempts: result.attempts,
+ })
+ } catch (error: unknown) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.issues }, { status: 400 })
+ }
+ const message =
+ error instanceof Error ? error.message : 'Erreur génération interactive demo'
+ console.error('[/api/ai/interactive-demo]', error)
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}
diff --git a/memento-note/app/api/ai/interactive-page/route.ts b/memento-note/app/api/ai/interactive-page/route.ts
new file mode 100644
index 0000000..209d397
--- /dev/null
+++ b/memento-note/app/api/ai/interactive-page/route.ts
@@ -0,0 +1,181 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { auth } from '@/auth'
+import { getSystemConfig } from '@/lib/config'
+import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
+import { getSlidesProvider } from '@/lib/ai/factory'
+import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
+import {
+ generatePagePlan,
+ generatePageSection,
+} from '@/lib/ai/services/interactive-page-llm.service'
+import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
+import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
+
+export const maxDuration = 60
+
+const sectionPlanSchema = z.object({
+ title: z.string().min(1),
+ goal: z.string().min(1),
+ demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
+ demoGoal: z.string().optional(),
+})
+
+const requestSchema = z.object({
+ /** undefined = legacy deterministic full-page (fallback path) */
+ action: z.enum(['plan', 'section']).optional(),
+ content: z.string().min(40),
+ lang: z.string().optional(),
+ noteId: z.string().optional(),
+ notebookId: z.string().optional(),
+ pageTitle: z.string().optional(),
+ sectionId: z.string().optional(),
+ section: sectionPlanSchema.optional(),
+})
+
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ if (!(await hasUserAiConsent())) {
+ return aiConsentForbiddenResponse()
+ }
+
+ const body = await req.json()
+ const parsed = requestSchema.parse(body)
+ const wordCount = parsed.content
+ .replace(/<[^>]+>/g, ' ')
+ .split(/\s+/)
+ .filter(Boolean).length
+ if (wordCount < 40) {
+ return NextResponse.json(
+ { error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
+ { status: 400 }
+ )
+ }
+
+ const config = await getSystemConfig()
+ const provider = getSlidesProvider(config)
+ const lang = parsed.lang || 'fr'
+
+ // ── LLM plan (billed: page = 20 crédits, spec §8.5) ──────────────────
+ if (parsed.action === 'plan') {
+ try {
+ await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
+ lane: 'chat',
+ })
+ } catch (err) {
+ if (err instanceof QuotaExceededError) {
+ return NextResponse.json(err.toJSON(), { status: 402 })
+ }
+ if (
+ err instanceof QuotaServiceUnavailableError ||
+ process.env.NODE_ENV === 'production'
+ ) {
+ return NextResponse.json(
+ { error: 'QUOTA_SERVICE_UNAVAILABLE' },
+ { status: 503 }
+ )
+ }
+ console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
+ }
+
+ const result = await generatePagePlan({
+ content: parsed.content,
+ lang,
+ provider,
+ })
+ if (!result.ok) {
+ return NextResponse.json(
+ { error: result.error, reason: result.reason, attempts: result.attempts },
+ { status: result.error === 'unsuitable_content' ? 422 : 502 }
+ )
+ }
+ return NextResponse.json({ plan: result.plan, attempts: result.attempts })
+ }
+
+ // ── LLM single section (already billed at plan time) ────────────────
+ if (parsed.action === 'section') {
+ if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
+ return NextResponse.json(
+ { error: 'section, sectionId and pageTitle are required' },
+ { status: 400 }
+ )
+ }
+ const result = await generatePageSection({
+ content: parsed.content,
+ lang,
+ provider,
+ pageTitle: parsed.pageTitle,
+ sectionId: parsed.sectionId,
+ section: parsed.section,
+ })
+ if (!result.ok) {
+ return NextResponse.json(
+ {
+ error: 'section_generation_failed',
+ issues: result.issues?.slice(0, 12),
+ attempts: result.attempts,
+ },
+ { status: 422 }
+ )
+ }
+ return NextResponse.json({ section: result.section, attempts: result.attempts })
+ }
+
+ // ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
+ const result = await generateInteractivePageFromContent({
+ content: parsed.content,
+ lang,
+ provider,
+ })
+
+ if (!result.ok) {
+ if (result.error === 'unsuitable_content') {
+ return NextResponse.json(
+ {
+ error: 'unsuitable_content',
+ reason: result.reason,
+ attempts: result.attempts,
+ },
+ { status: 422 }
+ )
+ }
+ const first = result.issues?.[0]
+ const reason =
+ result.reason ||
+ (first
+ ? `${first.path ? first.path + ': ' : ''}${first.message}`
+ : undefined)
+ return NextResponse.json(
+ {
+ error:
+ result.error === 'timeout'
+ ? 'La génération a pris trop de temps — réessayez'
+ : reason ||
+ 'La génération a produit une page invalide',
+ reason,
+ issues: result.issues?.slice(0, 12),
+ attempts: result.attempts,
+ },
+ { status: 422 }
+ )
+ }
+
+ return NextResponse.json({
+ page: result.page,
+ attempts: result.attempts,
+ })
+ } catch (error: unknown) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: error.issues }, { status: 400 })
+ }
+ const message =
+ error instanceof Error ? error.message : 'Erreur génération interactive page'
+ console.error('[/api/ai/interactive-page]', error)
+ return NextResponse.json({ error: message }, { status: 500 })
+ }
+}
diff --git a/memento-note/app/api/notes/publish/route.ts b/memento-note/app/api/notes/publish/route.ts
index 57e32bd..ec7feb4 100644
--- a/memento-note/app/api/notes/publish/route.ts
+++ b/memento-note/app/api/notes/publish/route.ts
@@ -5,8 +5,13 @@ import { contentModerationService, type ModerationResult } from '@/lib/ai/servic
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
-import { isPublishTemplateId } from '@/lib/publish/types'
+import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
+import { validateInteractivePage } from '@/lib/interactive-page'
+import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
+import { getSystemConfig } from '@/lib/config'
+import { getSlidesProvider } from '@/lib/ai/factory'
+import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
const MODERATION_TIMEOUT_MS = 12_000
@@ -93,13 +98,14 @@ export async function POST(request: NextRequest) {
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
- const { noteId, action, mode, template, language, rewrite } = body as {
+ const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
- mode?: 'simple' | 'ai'
+ mode?: 'simple' | 'ai' | 'interactive-page'
template?: string
language?: string
rewrite?: boolean
+ pageSpec?: unknown
}
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
@@ -111,13 +117,96 @@ export async function POST(request: NextRequest) {
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') {
+ // ── Interactive page (PageSpecV1 JSON snapshot) ─────────────────────
+ if (mode === 'interactive-page' || isInteractivePageTemplate(template)) {
+ if (!(await hasUserAiConsent())) {
+ return aiConsentForbiddenResponse()
+ }
+
+ let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
+
+ if (!validatedPage?.ok) {
+ // Deterministic generate (no LLM quota)
+ const config = await getSystemConfig()
+ const provider = getSlidesProvider(config)
+ const generated = await generateInteractivePageFromContent({
+ content: note.content || '',
+ lang: language || 'fr',
+ provider,
+ })
+ if (!generated.ok) {
+ return NextResponse.json(
+ {
+ error: generated.error || 'interactive_page_generation_failed',
+ reason: generated.reason,
+ issues: generated.issues?.slice(0, 12),
+ },
+ { status: 422 }
+ )
+ }
+ validatedPage = { ok: true, page: generated.page }
+ }
+
+ // Guaranteed valid PageSpec after generate-or-validate above
+ if (!validatedPage?.ok) {
+ return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
+ }
+
+ const textForModeration = [
+ validatedPage.page.hero.title,
+ validatedPage.page.hero.subtitle,
+ validatedPage.page.overview?.lead,
+ ...validatedPage.page.sections.map((s) => s.title),
+ ]
+ .filter(Boolean)
+ .join('\n')
+
+ const moderation = await moderateWithFallback(
+ note.title || '',
+ textForModeration
+ )
+ if (moderation.verdict === 'blocked') {
+ return NextResponse.json(
+ {
+ error: 'blocked',
+ reason: moderation.reason,
+ categories: moderation.categories,
+ },
+ { status: 403 }
+ )
+ }
+ if (moderation.verdict === 'flagged') {
+ await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
+ }
+
+ const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
+ const sourceHash = computePublishedSourceHash(note.content || '')
+
+ await updateNotePublishState(noteId, {
+ isPublic: true,
+ publicSlug: slug,
+ publishedAt: new Date(),
+ publishedContent: JSON.stringify(validatedPage.page),
+ publishedTemplate: 'interactive-page',
+ publishedSourceHash: sourceHash,
+ })
+
+ return NextResponse.json({
+ success: true,
+ slug,
+ mode: 'interactive-page',
+ template: 'interactive-page',
+ moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
+ })
+ }
+
const publishMode = mode === 'ai' ? 'ai' : 'simple'
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
- if (!template || !isPublishTemplateId(template)) {
+ if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
}
diff --git a/memento-note/components/interactive-demo/demo-rich-label.tsx b/memento-note/components/interactive-demo/demo-rich-label.tsx
new file mode 100644
index 0000000..381d4d2
--- /dev/null
+++ b/memento-note/components/interactive-demo/demo-rich-label.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import { useMemo } from 'react'
+import katex from 'katex'
+import 'katex/dist/katex.min.css'
+import { cn } from '@/lib/utils'
+
+/** Render node/callout label: plain lines + inline $KaTeX$. */
+export function DemoRichLabel({
+ text,
+ className,
+ lit,
+}: {
+ text: string
+ className?: string
+ lit?: boolean
+}) {
+ const lines = useMemo(() => {
+ return text.split(/\\n|\n/).map((line) => {
+ const parts: Array<{ type: 'text' | 'math'; value: string }> = []
+ const re = /\$([^$]+)\$/g
+ let last = 0
+ let m: RegExpExecArray | null
+ while ((m = re.exec(line)) !== null) {
+ if (m.index > last) {
+ parts.push({ type: 'text', value: line.slice(last, m.index) })
+ }
+ parts.push({ type: 'math', value: m[1] || '' })
+ last = m.index + m[0].length
+ }
+ if (last < line.length) parts.push({ type: 'text', value: line.slice(last) })
+ if (parts.length === 0) parts.push({ type: 'text', value: line })
+ return parts
+ })
+ }, [text])
+
+ return (
+
+ {lines.map((parts, i) => (
+
+ {parts.map((p, j) => {
+ if (p.type === 'math') {
+ let html = p.value
+ try {
+ html = katex.renderToString(p.value, {
+ displayMode: false,
+ throwOnError: false,
+ })
+ } catch {
+ /* keep raw */
+ }
+ return (
+
+ )
+ }
+ return {p.value}
+ })}
+
+ ))}
+
+ )
+}
diff --git a/memento-note/components/interactive-demo/demo-scene-view.tsx b/memento-note/components/interactive-demo/demo-scene-view.tsx
new file mode 100644
index 0000000..69eebd7
--- /dev/null
+++ b/memento-note/components/interactive-demo/demo-scene-view.tsx
@@ -0,0 +1,948 @@
+'use client'
+
+import { useId, useMemo } from 'react'
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts'
+import {
+ badgeGlyph,
+ dimOpacity,
+ intentColor,
+ spotlightColor,
+} from '@/lib/interactive-demo/intent-colors'
+import type { DemoScene, Panel } from '@/lib/interactive-demo/types'
+import type {
+ ResolvedAnnotation,
+ StepResolvedState,
+} from '@/lib/interactive-demo/resolve'
+import { useDarkMode } from '@/components/interactive-demo/demo-speak'
+import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label'
+import { cn } from '@/lib/utils'
+
+type AnnKind = ResolvedAnnotation['kind']
+
+export type DemoSceneViewProps = {
+ scene: DemoScene
+ state: StepResolvedState
+ reducedMotion?: boolean
+ className?: string
+}
+
+type NodePos = {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+function elementOpacity(
+ id: string,
+ state: StepResolvedState,
+ dim: number
+): number {
+ if (!(id in state.revealed)) return 0
+ if (state.overview || state.spotlight.length === 0) return 1
+ return state.spotlight.includes(id) ? 1 : dim
+}
+
+function estimateNodeSize(label: string): { w: number; h: number } {
+ const lines = label.split(/\\n|\n/)
+ const hasMath = /\$/.test(label)
+ const visualLen = (s: string) =>
+ s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45))))
+ .length
+ const longest = Math.max(...lines.map(visualLen), 6)
+ const w = Math.min(280, Math.max(128, longest * 7.4 + 40))
+ const h = Math.max(
+ hasMath ? 58 : 48,
+ 30 + lines.length * (hasMath ? 24 : 18)
+ )
+ return { w, h }
+}
+
+/** Prefer circular layout when edges form a closed loop covering most nodes. */
+function findCycleOrder(
+ ids: string[],
+ edges: { from: string; to: string }[]
+): string[] | null {
+ if (ids.length < 3) return null
+ const idSet = new Set(ids)
+ const outs = new Map()
+ for (const id of ids) outs.set(id, [])
+ for (const e of edges) {
+ if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
+ outs.get(e.from)!.push(e.to)
+ }
+
+ for (const start of ids) {
+ const path = [start]
+ const seen = new Set([start])
+ let cur = start
+ while (path.length < ids.length) {
+ const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t))
+ if (nexts.length === 0) break
+ // Prefer continuing a simple cycle (single unused neighbor)
+ const n = nexts[0]!
+ path.push(n)
+ seen.add(n)
+ cur = n
+ }
+ const closes = (outs.get(cur) ?? []).includes(start)
+ if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) {
+ return path
+ }
+ }
+ return null
+}
+
+/**
+ * Layout strategies:
+ * 1. AttnRes trunk pattern (residualTrunk + layers)
+ * 2. Closed cycle → circular
+ * 3. DAG layered (top → bottom)
+ * 4. Fallback grid
+ */
+function layoutNodes(
+ nodes: { id: string; label?: string }[],
+ edges: { from: string; to: string }[]
+): { positions: Map; width: number; height: number } {
+ const sizes = new Map(
+ nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
+ )
+ const positions = new Map()
+ const padX = 40
+ const padY = 36
+ const gapX = 52
+ const gapY = 32
+
+ const trunk = nodes.find((n) => n.id === 'residualTrunk')
+ const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk')
+
+ if (trunk && layersOnly.length >= 2) {
+ const layerSizes = layersOnly.map((n) => sizes.get(n.id)!)
+ const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96)
+ const trunkSize = sizes.get(trunk.id)!
+ const contentH =
+ padY * 2 +
+ layerSizes.reduce((acc, s) => acc + s.h, 0) +
+ gapY * Math.max(0, layersOnly.length - 1)
+ const height = Math.max(240, contentH)
+ const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24
+
+ let y = padY
+ layersOnly.forEach((n) => {
+ const s = sizes.get(n.id)!
+ positions.set(n.id, {
+ x: padX + maxLayerW / 2,
+ y: y + s.h / 2,
+ w: s.w,
+ h: s.h,
+ })
+ y += s.h + gapY
+ })
+ positions.set(trunk.id, {
+ x: padX + maxLayerW + gapX + trunkSize.w / 2,
+ y: height / 2,
+ w: trunkSize.w,
+ h: trunkSize.h,
+ })
+ return { positions, width, height }
+ }
+
+ const cycle = findCycleOrder(
+ nodes.map((n) => n.id),
+ edges
+ )
+ if (cycle) {
+ const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128)
+ const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48)
+ const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35)
+ const leftovers = nodes.filter((n) => !cycle.includes(n.id))
+ const leftoverRowH = leftovers.length ? maxH + gapY : 0
+ const width = Math.max(
+ 420,
+ padX * 2 + radius * 2 + maxW,
+ padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1)
+ )
+ const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH)
+ const cx = width / 2
+ const cy = padY + radius + maxH / 2
+ cycle.forEach((id, i) => {
+ const s = sizes.get(id)!
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length
+ positions.set(id, {
+ x: cx + radius * Math.cos(angle),
+ y: cy + radius * Math.sin(angle),
+ w: s.w,
+ h: s.h,
+ })
+ })
+ // Leftover nodes: horizontal row BELOW the ring (never overlapping it)
+ if (leftovers.length) {
+ const totalW =
+ leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) +
+ gapX * (leftovers.length - 1)
+ let x = cx - totalW / 2
+ const y = cy + radius + maxH / 2 + gapY + maxH / 2
+ for (const n of leftovers) {
+ const s = sizes.get(n.id)!
+ positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h })
+ x += s.w + gapX
+ }
+ }
+ return { positions, width, height }
+ }
+
+ // Topological layers (sources at top)
+ const ids = nodes.map((n) => n.id)
+ const idSet = new Set(ids)
+ const indeg = new Map(ids.map((id) => [id, 0]))
+ const outs = new Map(ids.map((id) => [id, [] as string[]]))
+ for (const e of edges) {
+ if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
+ indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1)
+ outs.get(e.from)!.push(e.to)
+ }
+
+ const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0)
+ const order: string[] = []
+ const depth = new Map()
+ queue.forEach((id) => depth.set(id, 0))
+ const q = [...queue]
+ while (q.length) {
+ const u = q.shift()!
+ order.push(u)
+ for (const v of outs.get(u) ?? []) {
+ depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1))
+ indeg.set(v, (indeg.get(v) ?? 1) - 1)
+ if ((indeg.get(v) ?? 0) === 0) q.push(v)
+ }
+ }
+
+ const isDag = order.length === ids.length && edges.length > 0
+
+ if (isDag) {
+ const byDepth = new Map()
+ for (const id of ids) {
+ const d = depth.get(id) ?? 0
+ if (!byDepth.has(d)) byDepth.set(d, [])
+ byDepth.get(d)!.push(id)
+ }
+ const maxDepth = Math.max(...byDepth.keys(), 0)
+ const rowHeights: number[] = []
+ let width = padX * 2
+ for (let d = 0; d <= maxDepth; d++) {
+ const row = byDepth.get(d) ?? []
+ const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36)
+ rowHeights.push(rowH)
+ const rowW =
+ row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
+ gapX * Math.max(0, row.length - 1)
+ width = Math.max(width, padX * 2 + rowW)
+ }
+ const height =
+ padY * 2 +
+ rowHeights.reduce((a, b) => a + b, 0) +
+ gapY * Math.max(0, maxDepth)
+
+ let y = padY
+ for (let d = 0; d <= maxDepth; d++) {
+ const row = byDepth.get(d) ?? []
+ const rowH = rowHeights[d]!
+ const rowW =
+ row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
+ gapX * Math.max(0, row.length - 1)
+ let x = (width - rowW) / 2
+ for (const id of row) {
+ const s = sizes.get(id)!
+ positions.set(id, {
+ x: x + s.w / 2,
+ y: y + rowH / 2,
+ w: s.w,
+ h: s.h,
+ })
+ x += s.w + gapX
+ }
+ y += rowH + gapY
+ }
+ return { positions, width: Math.max(400, width), height: Math.max(220, height) }
+ }
+
+ // Grid fallback
+ const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length))))
+ const rows = Math.ceil(nodes.length / cols)
+ const colW: number[] = Array.from({ length: cols }, (_, c) => {
+ let max = 120
+ nodes.forEach((n, i) => {
+ if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w)
+ })
+ return max
+ })
+ const rowH: number[] = Array.from({ length: rows }, (_, r) => {
+ let max = 48
+ nodes.forEach((n, i) => {
+ if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h)
+ })
+ return max
+ })
+ const width =
+ padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1)
+ const height =
+ padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1)
+
+ nodes.forEach((n, i) => {
+ const c = i % cols
+ const r = Math.floor(i / cols)
+ const s = sizes.get(n.id)!
+ const xOff =
+ padX +
+ colW.slice(0, c).reduce((a, b) => a + b, 0) +
+ gapX * c +
+ colW[c]! / 2
+ const yOff =
+ padY +
+ rowH.slice(0, r).reduce((a, b) => a + b, 0) +
+ gapY * r +
+ rowH[r]! / 2
+ positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h })
+ })
+
+ return { positions, width: Math.max(400, width), height: Math.max(220, height) }
+}
+
+/** Border intersection: line from center A → center B, clipped to rect A. */
+function borderPoint(
+ from: NodePos,
+ to: NodePos
+): { x: number; y: number } {
+ const dx = to.x - from.x
+ const dy = to.y - from.y
+ if (dx === 0 && dy === 0) return { x: from.x, y: from.y }
+ const hw = from.w / 2
+ const hh = from.h / 2
+ const ax = Math.abs(dx) / (hw || 1)
+ const ay = Math.abs(dy) / (hh || 1)
+ const t = 1 / Math.max(ax, ay)
+ return { x: from.x + dx * t, y: from.y + dy * t }
+}
+
+function edgePath(a: NodePos, b: NodePos, tipInset = 10): string {
+ const p0 = borderPoint(a, b)
+ const p1 = borderPoint(b, a)
+ const dx = p1.x - p0.x
+ const dy = p1.y - p0.y
+ const len = Math.hypot(dx, dy) || 1
+ // Stop short of the target so markerEnd tip lands on the node border, not under the card
+ const inset = Math.min(tipInset, len * 0.35)
+ const endX = p1.x - (dx / len) * inset
+ const endY = p1.y - (dy / len) * inset
+ const mx = (p0.x + endX) / 2
+ const my = (p0.y + endY) / 2
+ const bend = Math.min(36, len * 0.22)
+ const cx = mx - (dy / len) * bend
+ const cy = my + (dx / len) * bend
+ return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}`
+}
+
+function AnnotationsOverlay({
+ annotations,
+ getAnchor,
+ dark,
+ markerId,
+ shadowId,
+}: {
+ annotations: ResolvedAnnotation[]
+ getAnchor: (
+ id: string,
+ kind: AnnKind
+ ) => { x: number; y: number } | null
+ dark: boolean
+ markerId: string
+ shadowId?: string
+}) {
+ const outline = spotlightColor(dark)
+ return (
+
+ {annotations.map((ann, i) => {
+ const target = ann.targetIds[0]
+ if (!target) return null
+ const pos = getAnchor(target, ann.kind)
+ if (!pos) return null
+ const color = intentColor(ann.intent, dark)
+ const key = `${ann.kind}-${target}-${i}`
+
+ if (ann.kind === 'circle') {
+ return (
+
+ )
+ }
+ if (ann.kind === 'badge') {
+ const n = ann.badgeIndex ?? i + 1
+ return (
+
+
+
+ {badgeGlyph(n)}
+
+
+ )
+ }
+ if (ann.kind === 'callout') {
+ const text = ann.text ?? ''
+ const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
+ return (
+
+
+
+ {text}
+
+
+ )
+ }
+ if (ann.kind === 'arrow') {
+ return (
+
+ )
+ }
+ return null
+ })}
+
+ )
+}
+
+function SvgScenePanel({
+ panel,
+ state,
+ reducedMotion,
+ dark,
+}: {
+ panel: Extract
+ state: StepResolvedState
+ reducedMotion?: boolean
+ dark: boolean
+}) {
+ const uid = useId().replace(/:/g, '')
+ const nodes = panel.payload.nodes
+ const edges = panel.payload.edges ?? []
+ const dim = dimOpacity(dark)
+ const outline = spotlightColor(dark)
+ const markerId = `demo-arrow-${uid}`
+ const dotsId = `demo-dots-${uid}`
+
+ const { positions, width, height } = useMemo(
+ () => layoutNodes(nodes, edges),
+ [nodes, edges]
+ )
+
+ const transition = reducedMotion
+ ? 'none'
+ : 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease'
+
+ const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)'
+ const boardBg = dark ? '#0c0e12' : '#f7f5f0'
+
+ return (
+
+
+
+ {nodes.map((n) => {
+ const p = positions.get(n.id)
+ if (!p) return null
+ const op = elementOpacity(n.id, state, dim)
+ if (op === 0) return null
+ const lit = state.overview || state.spotlight.includes(n.id)
+ const accent = intentColor(n.intent, dark)
+ const label = n.label ?? n.id
+ return (
+
+
+
+ )
+ })}
+
+ )
+}
+
+function ChartPanel({
+ panel,
+ state,
+ reducedMotion,
+ dark,
+}: {
+ panel: Extract
+ state: StepResolvedState
+ reducedMotion?: boolean
+ dark: boolean
+}) {
+ const series = panel.payload.series
+ const dim = dimOpacity(dark)
+ const maxLen = Math.max(...series.map((s) => s.values.length), 0)
+ const data = useMemo(() => {
+ return Array.from({ length: maxLen }, (_, i) => {
+ const row: Record = { i: String(i + 1) }
+ for (const s of series) {
+ if (!(s.id in state.revealed)) continue
+ row[s.id] = s.values[i] ?? 0
+ }
+ return row
+ })
+ }, [maxLen, series, state.revealed])
+
+ const Chart =
+ panel.payload.chartType === 'bar'
+ ? BarChart
+ : panel.payload.chartType === 'area'
+ ? AreaChart
+ : LineChart
+
+ return (
+
+
+
+
+
+
+
+ {series.map((s) => {
+ if (!(s.id in state.revealed)) return null
+ const op = elementOpacity(s.id, state, dim)
+ const stroke = intentColor(s.intent, dark)
+ if (panel.payload.chartType === 'bar') {
+ return (
+
+ )
+ }
+ if (panel.payload.chartType === 'area') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+function HeatmapPanel({
+ panel,
+ state,
+ reducedMotion,
+ dark,
+}: {
+ panel: Extract
+ state: StepResolvedState
+ reducedMotion?: boolean
+ dark: boolean
+}) {
+ const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload
+ const cell = 34
+ const labelW = 44
+ const labelH = 26
+ const w = labelW + cols * cell + 8
+ const h = labelH + rows * cell + 8
+ const transition = reducedMotion ? 'none' : 'opacity 220ms ease'
+ const dim = dimOpacity(dark)
+ const fillIntent = spotlightColor(dark)
+ const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
+
+ const cellRect = (r: number, c: number) => ({
+ x: labelW + (c - 1) * cell,
+ y: labelH + (r - 1) * cell,
+ })
+
+ const positions = useMemo(() => {
+ const map = new Map<
+ string,
+ { cx: number; cy: number; trX: number; trY: number }
+ >()
+ for (let r = 1; r <= rows; r++) {
+ for (let c = 1; c <= cols; c++) {
+ if (triangular === 'lower' && c > r) continue
+ if (triangular === 'upper' && c < r) continue
+ const id = `r${r}.c${c}`
+ const { x, y } = cellRect(r, c)
+ map.set(id, {
+ cx: x + cell / 2,
+ cy: y + cell / 2,
+ trX: x + cell - 2,
+ trY: y + 2,
+ })
+ }
+ }
+ return map
+ }, [rows, cols, triangular])
+
+ return (
+
+ )
+}
+
+export function DemoSceneView({
+ scene,
+ state,
+ reducedMotion,
+ className,
+}: DemoSceneViewProps) {
+ const dark = useDarkMode()
+
+ return (
+
+ {scene.panels.map((panel) => (
+
+ {panel.type === 'svg-scene' && (
+
+ )}
+ {panel.type === 'chart' && (
+
+
+
+ )}
+ {panel.type === 'heatmap-matrix' && (
+
+
+
+ )}
+
+ ))}
+
+ )
+}
diff --git a/memento-note/components/interactive-demo/demo-speak.tsx b/memento-note/components/interactive-demo/demo-speak.tsx
new file mode 100644
index 0000000..1f6635f
--- /dev/null
+++ b/memento-note/components/interactive-demo/demo-speak.tsx
@@ -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 (
+
+ )
+}
+
+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
+}
diff --git a/memento-note/components/interactive-demo/interactive-demo-player.tsx b/memento-note/components/interactive-demo/interactive-demo-player.tsx
new file mode 100644
index 0000000..dd3564f
--- /dev/null
+++ b/memento-note/components/interactive-demo/interactive-demo-player.tsx
@@ -0,0 +1,387 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Pause,
+ Play,
+ RotateCcw,
+ SkipBack,
+ SkipForward,
+ StepBack,
+ StepForward,
+} from 'lucide-react'
+import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
+import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
+import {
+ resolveInteractiveDemo,
+ type InteractiveDemoV1,
+} from '@/lib/interactive-demo'
+import { cn } from '@/lib/utils'
+import { useLanguage } from '@/lib/i18n'
+
+const SPEEDS = [0.5, 1, 2, 4] as const
+const WPM = 220
+const STEP_FLOOR_MS = 1500
+const STEP_CEIL_MS = 6000
+const ACT_CHANGE_PAUSE_MS = 800
+
+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 stepDurationMs(speak: string, speed: number): number {
+ const words = speak.trim().split(/\s+/).filter(Boolean).length
+ const raw = words * (60_000 / WPM)
+ const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
+ return clamped / speed
+}
+
+export type InteractiveDemoPlayerProps = {
+ demo: InteractiveDemoV1
+ mode?: 'interactive' | 'static'
+ className?: string
+}
+
+export function InteractiveDemoPlayer({
+ demo,
+ mode = 'interactive',
+ className,
+}: InteractiveDemoPlayerProps) {
+ const { t } = useLanguage()
+ const reducedMotion = usePrefersReducedMotion()
+ const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
+
+ const [actIndex, setActIndex] = useState(0)
+ const [stepIndex, setStepIndex] = useState(0)
+ const [playing, setPlaying] = useState(false)
+ const [speedIdx, setSpeedIdx] = useState(1)
+ const speed = SPEEDS[speedIdx] ?? 1
+ const timerRef = useRef | null>(null)
+ const actPauseRef = useRef(false)
+ /** Keyboard shortcuts only fire for the hovered/focused player. */
+ const activeRef = useRef(false)
+
+ const act = resolved.acts[actIndex] ?? resolved.acts[0]
+ const scenes = useMemo(() => {
+ let scene = demo.scene
+ const map: (typeof demo.scene)[] = []
+ for (const a of demo.acts) {
+ if (a.scene) scene = a.scene
+ map.push(scene)
+ }
+ return map
+ }, [demo])
+
+ const scene = scenes[actIndex] ?? demo.scene
+
+ const state =
+ mode === 'static'
+ ? act?.final
+ : act?.steps[stepIndex] ?? act?.final
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+ }, [])
+
+ const goStep = useCallback(
+ (next: number) => {
+ if (!act) return
+ if (next < 0) {
+ setStepIndex(0)
+ return
+ }
+ if (next >= act.steps.length) {
+ setPlaying(false)
+ setStepIndex(act.steps.length - 1)
+ return
+ }
+ setStepIndex(next)
+ },
+ [act]
+ )
+
+ const resetAct = useCallback(() => {
+ clearTimer()
+ actPauseRef.current = false
+ setPlaying(false)
+ setStepIndex(0)
+ }, [clearTimer])
+
+ const resetDemo = useCallback(() => {
+ clearTimer()
+ actPauseRef.current = false
+ setPlaying(false)
+ setActIndex(0)
+ setStepIndex(0)
+ }, [clearTimer])
+
+ const skipAct = useCallback(() => {
+ clearTimer()
+ actPauseRef.current = false
+ setPlaying(false)
+ if (actIndex >= resolved.acts.length - 1) {
+ setStepIndex((act?.steps.length ?? 1) - 1)
+ return
+ }
+ setActIndex((i) => i + 1)
+ setStepIndex(0)
+ }, [actIndex, act, resolved.acts.length, clearTimer])
+
+ const prevAct = useCallback(() => {
+ clearTimer()
+ actPauseRef.current = false
+ setPlaying(false)
+ if (actIndex <= 0) {
+ setStepIndex(0)
+ return
+ }
+ setActIndex((i) => i - 1)
+ setStepIndex(0)
+ }, [actIndex, clearTimer])
+
+ // Auto-play: duration recalculates immediately when speed/step changes
+ useEffect(() => {
+ clearTimer()
+ if (!playing || mode !== 'interactive' || !act || !state) return
+
+ const atLastStep = stepIndex >= act.steps.length - 1
+ const ms = stepDurationMs(state.speak, speed)
+
+ timerRef.current = setTimeout(() => {
+ if (atLastStep) {
+ // End of act while playing
+ if (actIndex < resolved.acts.length - 1) {
+ actPauseRef.current = true
+ timerRef.current = setTimeout(() => {
+ actPauseRef.current = false
+ setActIndex((i) => i + 1)
+ setStepIndex(0)
+ }, ACT_CHANGE_PAUSE_MS)
+ } else {
+ // End of demo — pause on final state
+ setPlaying(false)
+ }
+ return
+ }
+ setStepIndex((s) => s + 1)
+ }, ms)
+
+ return clearTimer
+ }, [
+ playing,
+ stepIndex,
+ speed,
+ act,
+ state,
+ mode,
+ actIndex,
+ resolved.acts.length,
+ clearTimer,
+ ])
+
+ useEffect(() => {
+ if (mode !== 'interactive') return
+ const onKey = (e: KeyboardEvent) => {
+ const tag = (e.target as HTMLElement)?.tagName
+ if (
+ tag === 'INPUT' ||
+ tag === 'TEXTAREA' ||
+ (e.target as HTMLElement)?.isContentEditable
+ ) {
+ return
+ }
+ // Multi-player pages: only the hovered/focused player answers the keyboard
+ if (!activeRef.current) return
+ if (e.code === 'Space') {
+ e.preventDefault()
+ setPlaying((p) => !p)
+ } else if (e.code === 'ArrowRight' && e.shiftKey) {
+ e.preventDefault()
+ skipAct()
+ } else if (e.code === 'ArrowLeft' && e.shiftKey) {
+ e.preventDefault()
+ prevAct()
+ } else if (e.code === 'ArrowRight') {
+ e.preventDefault()
+ setPlaying(false)
+ goStep(stepIndex + 1)
+ } else if (e.code === 'ArrowLeft') {
+ e.preventDefault()
+ setPlaying(false)
+ goStep(stepIndex - 1)
+ } else if (e.code === 'KeyR' && e.shiftKey) {
+ e.preventDefault()
+ resetDemo()
+ } else if (e.code === 'KeyR') {
+ e.preventDefault()
+ resetAct()
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
+
+ if (!act || !state || !scene) {
+ return (
+
+ {t('interactiveDemo.empty') || 'Interactive demo unavailable'}
+
+ )
+ }
+
+ const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
+
+ return (
+ {
+ activeRef.current = true
+ }}
+ onPointerLeave={() => {
+ activeRef.current = false
+ }}
+ onFocusCapture={() => {
+ activeRef.current = true
+ }}
+ onBlurCapture={() => {
+ activeRef.current = false
+ }}
+ >
+ {demo.disclaimer && (
+
+ {demo.disclaimer}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {progressLabel}
+
+
+
+ {mode === 'interactive' && (
+
+
+
+
+
+
+
+
+
+
+
+
+ {SPEEDS.map((s, i) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/memento-note/components/interactive-page/interactive-page-publish-dialog.tsx b/memento-note/components/interactive-page/interactive-page-publish-dialog.tsx
new file mode 100644
index 0000000..68097f4
--- /dev/null
+++ b/memento-note/components/interactive-page/interactive-page-publish-dialog.tsx
@@ -0,0 +1,412 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { Clapperboard, Loader2, Globe } from 'lucide-react'
+import { toast } from 'sonner'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { PageView } from '@/components/interactive-page/page-view'
+import {
+ generateInteractivePage,
+ generateInteractivePagePlan,
+ generateInteractivePageSection,
+ type PagePlan,
+} from '@/lib/ai/services/interactive-page-client.service'
+import {
+ validateInteractivePage,
+ type PageSection,
+ type PageSpecV1,
+} from '@/lib/interactive-page'
+import { useLanguage } from '@/lib/i18n'
+
+type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
+
+function slugId(title: string): string {
+ const s = title
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 40)
+ return s ? `page.${s}` : 'page.generated'
+}
+
+/** Degraded section when its LLM call failed — page still completes. */
+function fallbackSection(
+ sectionId: string,
+ plan: PagePlan['sections'][number],
+ lang: string
+): PageSection {
+ const fr = lang.startsWith('fr')
+ return {
+ id: sectionId,
+ title: plan.title,
+ blocks: [
+ { type: 'prose', md: plan.goal },
+ {
+ type: 'callout',
+ kind: 'note',
+ title: fr ? 'En bref' : 'In short',
+ md: plan.demoGoal || plan.goal,
+ },
+ ],
+ }
+}
+
+function assemblePage(
+ plan: PagePlan,
+ sections: PageSection[],
+ lang: string
+): PageSpecV1 {
+ return {
+ schemaVersion: 1,
+ id: slugId(plan.heroTitle),
+ lang,
+ hero: {
+ kicker: lang.startsWith('fr')
+ ? 'EXPLAINER INTERACTIF'
+ : 'INTERACTIVE EXPLAINER',
+ title: plan.heroTitle,
+ subtitle: plan.heroSubtitle,
+ meta: lang.startsWith('fr')
+ ? 'Généré depuis votre note'
+ : 'Generated from your note',
+ },
+ overview: {
+ lead: plan.overviewLead,
+ cards: plan.overviewCards.map((c) => ({
+ badge: c.badge,
+ title: c.title,
+ body: c.body,
+
+ intent: c.intent as any,
+ })),
+ },
+ sections,
+ }
+}
+
+/**
+ * Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
+ * → validate → preview → publish (pageSpec snapshot, no double quota).
+ * Falls back to the deterministic page when the LLM path is unavailable.
+ */
+export function InteractivePagePublishDialog({
+ open,
+ onOpenChange,
+ noteId,
+ content,
+ language,
+ onPublished,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ noteId: string
+ content: string
+ language: string
+ onPublished: (slug: string) => void
+}) {
+ const { t } = useLanguage()
+ const [phase, setPhase] = useState('idle')
+ const [page, setPage] = useState(null)
+ const [error, setError] = useState(null)
+ const [progress, setProgress] = useState(null)
+ const [elapsedSec, setElapsedSec] = useState(0)
+
+ useEffect(() => {
+ if (!open || phase !== 'generating') {
+ if (!open) setElapsedSec(0)
+ return
+ }
+ const t0 = Date.now()
+ const id = window.setInterval(() => {
+ setElapsedSec(Math.floor((Date.now() - t0) / 1000))
+ }, 500)
+ return () => window.clearInterval(id)
+ }, [open, phase])
+
+ useEffect(() => {
+ if (!open) {
+ setPhase('idle')
+ setPage(null)
+ setError(null)
+ setProgress(null)
+ return
+ }
+ // Guard: empty content from editor race
+ const wordCount = content
+ .replace(/<[^>]+>/g, ' ')
+ .split(/\s+/)
+ .filter(Boolean).length
+ if (wordCount < 30) {
+ setPhase('error')
+ setError(
+ t('richTextEditor.publishInteractivePageTooShort') ||
+ 'Note trop courte — ajoutez du contenu puis réessayez'
+ )
+ return
+ }
+
+ let cancelled = false
+ const run = async () => {
+ setPhase('generating')
+ setError(null)
+ setPage(null)
+
+ const legacyFallback = async (notice?: string) => {
+ const result = await generateInteractivePage({
+ content,
+ lang: language,
+ noteId,
+ })
+ if (cancelled) return
+ if (!result.ok) {
+ setPhase('error')
+ setError(
+ result.reason ||
+ result.error ||
+ t('richTextEditor.publishInteractivePageFailed') ||
+ 'Échec de la page interactive'
+ )
+ if (result.quotaExceeded) {
+ toast.error(t('ai.quotaExceeded'))
+ }
+ window.dispatchEvent(new Event('ai-usage-changed'))
+ return
+ }
+ window.dispatchEvent(new Event('ai-usage-changed'))
+ if (notice) toast.info(notice)
+ setPage(result.page)
+ setPhase('preview')
+ }
+
+ // 1. LLM plan (billed)
+ setProgress(
+ t('richTextEditor.publishInteractivePagePlanning') ||
+ 'Analyse du contenu — plan de la page…'
+ )
+ const planResult = await generateInteractivePagePlan({
+ content,
+ lang: language,
+ noteId,
+ })
+ if (cancelled) return
+ if (!planResult.ok) {
+ if (planResult.quotaExceeded) {
+ setPhase('error')
+ setError(planResult.error)
+ toast.error(t('ai.quotaExceeded'))
+ window.dispatchEvent(new Event('ai-usage-changed'))
+ return
+ }
+ if (planResult.error === 'unsuitable_content') {
+ setPhase('error')
+ setError(
+ planResult.reason ||
+ t('richTextEditor.publishInteractivePageFailed') ||
+ 'Contenu inadapté'
+ )
+ window.dispatchEvent(new Event('ai-usage-changed'))
+ return
+ }
+ // LLM plan unavailable → deterministic full page
+ await legacyFallback(
+ t('richTextEditor.publishInteractivePageFallback') ||
+ 'Génération IA indisponible — page simplifiée affichée'
+ )
+ return
+ }
+ window.dispatchEvent(new Event('ai-usage-changed'))
+
+ // 2. One LLM call per section, with real progress
+ const plan = planResult.plan
+ const sections: PageSection[] = []
+ let degraded = 0
+ for (let i = 0; i < plan.sections.length; i++) {
+ const planSection = plan.sections[i]
+ const sectionId = `s${i + 1}`
+ setProgress(
+ (
+ t('richTextEditor.publishInteractivePageSectionProgress') ||
+ 'Section {current}/{total} : {title}'
+ )
+ .replace('{current}', String(i + 1))
+ .replace('{total}', String(plan.sections.length))
+ .replace('{title}', planSection.title)
+ )
+ const sectionResult = await generateInteractivePageSection({
+ content,
+ lang: language,
+ noteId,
+ pageTitle: plan.heroTitle,
+ sectionId,
+ section: planSection,
+ })
+ if (cancelled) return
+ if (sectionResult.ok) {
+ sections.push(sectionResult.section)
+ } else {
+ degraded += 1
+ sections.push(fallbackSection(sectionId, planSection, language))
+ }
+ }
+
+ // 3. Assemble + hard validation client-side
+ const candidate = assemblePage(plan, sections, language)
+ const validated = validateInteractivePage(candidate)
+ if (!validated.ok) {
+ await legacyFallback(
+ t('richTextEditor.publishInteractivePageFallback') ||
+ 'Génération IA indisponible — page simplifiée affichée'
+ )
+ return
+ }
+ if (degraded > 0) {
+ toast.info(
+ t('richTextEditor.publishInteractivePagePartialFallback') ||
+ 'Certaines sections ont été générées en mode simplifié'
+ )
+ }
+ setPage(validated.page)
+ setPhase('preview')
+ }
+ void run()
+ return () => {
+ cancelled = true
+ }
+ // Intentionally omit `t` — unstable identity cancels in-flight generation
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, content, language, noteId])
+
+ const handlePublish = async () => {
+ if (!page || phase === 'publishing') return
+ setPhase('publishing')
+ try {
+ const res = await fetch('/api/notes/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ noteId,
+ action: 'publish',
+ mode: 'interactive-page',
+ template: 'interactive-page',
+ language,
+ pageSpec: page,
+ }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ toast.error(
+ data.reason ||
+ data.error ||
+ t('richTextEditor.publishInteractivePageFailed') ||
+ 'Échec de la publication'
+ )
+ setPhase('preview')
+ return
+ }
+ toast.success(
+ t('richTextEditor.publishInteractivePageSuccess') ||
+ 'Page interactive publiée !'
+ )
+ onPublished(data.slug)
+ onOpenChange(false)
+ } catch {
+ toast.error(
+ t('richTextEditor.publishInteractivePageFailed') ||
+ 'Échec de la publication'
+ )
+ setPhase('preview')
+ }
+ }
+
+ const generatingHint =
+ progress ||
+ (elapsedSec < 15
+ ? t('richTextEditor.publishInteractivePageGenerating') ||
+ 'Génération de la page…'
+ : elapsedSec < 40
+ ? t('richTextEditor.publishInteractivePageGeneratingWait') ||
+ 'Construction des sections et démos…'
+ : t('richTextEditor.publishInteractivePageGeneratingLong') ||
+ 'Encore un instant — au-delà de ~90 s, annulez et réessayez')
+
+ return (
+
+ )
+}
diff --git a/memento-note/components/interactive-page/interactive-published-page.tsx b/memento-note/components/interactive-page/interactive-published-page.tsx
new file mode 100644
index 0000000..bec61cc
--- /dev/null
+++ b/memento-note/components/interactive-page/interactive-published-page.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { PageView } from '@/components/interactive-page/page-view'
+import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
+import { AlertCircle } from 'lucide-react'
+
+/**
+ * Public / preview shell for published interactive pages.
+ * Parses stored PageSpecV1 JSON from `publishedContent`.
+ */
+export function InteractivePublishedPage({
+ publishedContent,
+ isStale,
+}: {
+ publishedContent: string
+ isStale?: boolean
+}) {
+ let page: PageSpecV1 | null = null
+ let error: string | null = null
+ try {
+ const raw = JSON.parse(publishedContent)
+ const result = validateInteractivePage(raw)
+ if (result.ok) page = result.page
+ else error = result.issues[0]?.message || 'PageSpec invalide'
+ } catch {
+ error = 'JSON de page interactive illisible'
+ }
+
+ if (!page) {
+ return (
+
+
+
{error || 'Page interactive indisponible'}
+
+ )
+ }
+
+ return (
+
+ {isStale ? (
+
+ Le contenu source a évolué — cette page interactive est à régénérer.
+
+ ) : null}
+
+
+ )
+}
diff --git a/memento-note/components/interactive-page/page-blocks.tsx b/memento-note/components/interactive-page/page-blocks.tsx
new file mode 100644
index 0000000..8215ff7
--- /dev/null
+++ b/memento-note/components/interactive-page/page-blocks.tsx
@@ -0,0 +1,382 @@
+'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()
+ 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 = {
+ 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 (
+
+
+ {fr ? 'Légende' : 'Legend'}
+
+ {intents.map((id) => (
+
+
+ {fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
+
+ ))}
+
+ )
+}
+
+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 (
+
+ {label}
+
+ )
+}
+
+function ChartBlockView({
+ block,
+}: {
+ block: Extract
+}) {
+ 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 = { 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 (
+
+
+
+
+
+
+
+
+ {series.map((s) => {
+ const stroke = intentColor(s.intent, dark)
+ if (block.payload.chartType === 'bar') {
+ return (
+
+ )
+ }
+ if (block.payload.chartType === 'area') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ })}
+
+
+
+ {block.caption ? (
+
+ {block.caption}
+
+ ) : null}
+
+ )
+}
+
+export function PageBlockView({
+ block,
+ demoMode = 'static',
+ lang = 'fr',
+}: {
+ block: PageBlock
+ demoMode?: 'interactive' | 'static'
+ lang?: string
+}) {
+ if (block.type === 'prose') {
+ return
+ }
+
+ if (block.type === 'formula') {
+ return
+ }
+
+ if (block.type === 'callout') {
+ const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
+ return (
+
+ )
+ }
+
+ if (block.type === 'demo') {
+ return (
+
+ {block.caption ? (
+
+ {block.caption}
+
+ ) : null}
+
+
+ {demoMode === 'static' ? (
+
+ ) : null}
+
+ )
+ }
+
+ if (block.type === 'sim') {
+ return
+ }
+
+ if (block.type === 'chart') {
+ return
+ }
+
+ if (block.type === 'stats') {
+ return (
+
+ {block.items.map((item, i) => (
+
+
+ {item.value}
+
+
+ {item.label}
+
+
+ ))}
+
+ )
+ }
+
+ if (block.type === 'table') {
+ return (
+
+
+ {block.caption ? (
+
+ {block.caption}
+
+ ) : null}
+
+
+ {block.columns.map((c) => (
+ |
+ {c}
+ |
+ ))}
+
+
+
+ {block.rows.map((row, ri) => (
+
+ {row.map((cell, ci) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+ }
+
+ if (block.type === 'image') {
+ return (
+
+ { }
+
+ {block.caption ? (
+
+ {block.caption}
+
+ ) : null}
+
+ )
+ }
+
+ return null
+}
+
+export { IntentBadge }
diff --git a/memento-note/components/interactive-page/page-md.tsx b/memento-note/components/interactive-page/page-md.tsx
new file mode 100644
index 0000000..1aca079
--- /dev/null
+++ b/memento-note/components/interactive-page/page-md.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import { useMemo } from 'react'
+import katex from 'katex'
+import { marked } from 'marked'
+import { sanitizeRichHtml } from '@/lib/sanitize-content'
+import 'katex/dist/katex.min.css'
+import { cn } from '@/lib/utils'
+
+/** Light markdown + inline $KaTeX$ for page prose / callouts / speak. */
+export function PageMd({
+ md,
+ className,
+}: {
+ md: string
+ className?: string
+}) {
+ const html = useMemo(() => {
+ const placeholders: string[] = []
+ const withSlots = md.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 out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
+ placeholders.forEach((frag, i) => {
+ out = out.replace(`%%KATEX${i}%%`, frag)
+ })
+ return sanitizeRichHtml(out)
+ }, [md])
+
+ return (
+
+ )
+}
+
+export function PageFormula({
+ tex,
+ caption,
+}: {
+ tex: string
+ caption?: string
+}) {
+ const html = useMemo(() => {
+ try {
+ return katex.renderToString(tex, {
+ displayMode: true,
+ throwOnError: false,
+ })
+ } catch {
+ return tex
+ }
+ }, [tex])
+
+ return (
+
+
+ {caption ? (
+
+ {caption}
+
+ ) : null}
+
+ )
+}
diff --git a/memento-note/components/interactive-page/page-sticky-nav.tsx b/memento-note/components/interactive-page/page-sticky-nav.tsx
new file mode 100644
index 0000000..6a8a1df
--- /dev/null
+++ b/memento-note/components/interactive-page/page-sticky-nav.tsx
@@ -0,0 +1,151 @@
+'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()
+ 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 = {
+ highlight: 'Focus',
+ flow: 'Flux',
+ cache: 'Mémoire',
+ compute: 'Calcul',
+ output: 'Résultat',
+ warning: 'Attention',
+}
+
+const INTENT_LABELS_EN: Record = {
+ 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 (
+
+
+ {intents.length > 0 ? (
+
+
+ {page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
+
+ {intents.map((id) => {
+ const color = intentColor(id, dark)
+ return (
+
+
+ {page.lang.startsWith('fr')
+ ? INTENT_LABELS_FR[id]
+ : INTENT_LABELS_EN[id]}
+
+ )
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/memento-note/components/interactive-page/page-view.tsx b/memento-note/components/interactive-page/page-view.tsx
new file mode 100644
index 0000000..6208e56
--- /dev/null
+++ b/memento-note/components/interactive-page/page-view.tsx
@@ -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('[data-scroll-init]')
+ .forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
+ return
+ }
+ const nodes = Array.from(
+ document.querySelectorAll('[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 (
+
+
+
+ {/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
+
+
+ {page.hero.kicker}
+
+
+ {page.hero.title}
+
+ {page.hero.subtitle ? (
+
+ {page.hero.subtitle}
+
+ ) : null}
+ {page.hero.meta ? (
+
+ {page.hero.meta}
+
+ ) : null}
+
+
+
+
+
+ {/* ── One-minute overview ── */}
+ {page.overview ? (
+
+
+ {page.lang.startsWith('fr')
+ ? 'L’essentiel en une minute'
+ : 'One-minute overview'}
+
+
+
+ {page.overview.cards.map((card) => (
+
+ ))}
+
+
+ ) : null}
+
+ {/* ── Sections ── */}
+
+ {page.sections.map((section, i) => (
+
+
+
+ {String(i + 1).padStart(2, '0')}
+
+ {section.title}
+
+
+ {section.blocks.map((block, bi) => {
+ const narrow =
+ block.type === 'prose' ||
+ block.type === 'formula' ||
+ block.type === 'callout'
+ return (
+
+ )
+ })}
+
+
+ ))}
+
+
+ {page.footer ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/memento-note/components/interactive-page/sim-block.tsx b/memento-note/components/interactive-page/sim-block.tsx
new file mode 100644
index 0000000..c903b61
--- /dev/null
+++ b/memento-note/components/interactive-page/sim-block.tsx
@@ -0,0 +1,104 @@
+'use client'
+
+import { GENERIC_SIM_ID, getPlugin } from '@/lib/simulators'
+import { ANIM_VIEWS, SIMULATOR_VIEWS } from '@/components/simulators'
+import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
+import { GenericFormulaView } from '@/components/simulators/generic-formula-view'
+import type { SimBlock } from '@/lib/interactive-page'
+
+/** Renders a `sim` block: catalog plugin (bespoke view) or generic formula. */
+export function SimBlockView({
+ block,
+ lang,
+}: {
+ block: SimBlock
+ lang: string
+}) {
+ const sim = block.sim
+ const fr = lang.startsWith('fr')
+ const plugin = sim.simId === GENERIC_SIM_ID ? null : getPlugin(sim.simId)
+ const kindLabel =
+ plugin?.family === 'anim'
+ ? fr
+ ? 'Animation interactive'
+ : 'Interactive animation'
+ : fr
+ ? 'Simulation interactive'
+ : 'Interactive simulation'
+
+ let title: string | undefined
+ let body: React.ReactNode = null
+
+ if (sim.simId === GENERIC_SIM_ID) {
+ title = sim.title
+ body = (
+ }
+ lang={lang}
+ />
+ )
+ } else {
+ if (!plugin) {
+ return (
+
+ {fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
+
+ )
+ }
+ title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
+
+ if (plugin.family === 'anim') {
+ const AnimScene = ANIM_VIEWS[sim.simId]
+ if (!AnimScene) {
+ return (
+
+ {fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
+
+ )
+ }
+ body = (
+
+ {(stepIndex) => }
+
+ )
+ } else {
+ const View = SIMULATOR_VIEWS[sim.simId]
+ if (!View) {
+ return (
+
+ {fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
+
+ )
+ }
+ const preset = (sim as { preset?: Record }).preset
+ body = (
+
+ )
+ }
+ }
+
+ return (
+
+
+
{title}
+
+ {kindLabel}
+
+
+ {body}
+ {block.caption ? (
+
+ {block.caption}
+
+ ) : null}
+
+ )
+}
diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx
index f944588..8aab6c6 100644
--- a/memento-note/components/note-editor/note-editor-toolbar.tsx
+++ b/memento-note/components/note-editor/note-editor-toolbar.tsx
@@ -19,10 +19,11 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
- Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History
+ Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History, Clapperboard
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
+import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
@@ -53,6 +54,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const [publishOpen, setPublishOpen] = useState(false)
const [publishLoading, setPublishLoading] = useState(false)
+ const [interactivePageOpen, setInteractivePageOpen] = useState(false)
+ const [interactivePageContent, setInteractivePageContent] = useState('')
const [publishMeta, setPublishMeta] = useState({
isPublic: Boolean(note.isPublic),
slug: note.publicSlug ?? null,
@@ -343,6 +346,28 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
magazine: t('richTextEditor.publishTemplateMagazine'),
brief: t('richTextEditor.publishTemplateBrief'),
essay: t('richTextEditor.publishTemplateEssay'),
+ 'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
+ }
+
+ const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
+ (tpl) => tpl !== 'interactive-page'
+ )
+
+ const handlePublishInteractivePage = async () => {
+ if (publishLoading) return
+ const consented = await requestAiConsent()
+ if (!consented) return
+ if (state.isDirty && !state.isSaving) {
+ await actions.handleSaveInPlace()
+ }
+ const html =
+ richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
+ state.content ||
+ note.content ||
+ ''
+ setInteractivePageContent(html)
+ setPublishOpen(false)
+ setInteractivePageOpen(true)
}
const handlePublishWithAi = async () => {
@@ -664,6 +689,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.publishSimpleHint')}
+
+
+ {t('richTextEditor.publishInteractivePageHint') ||
+ 'Hero, sections, démos Play/Step — 20 crédits'}
+
+
@@ -679,7 +723,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{/* Sélection template */}
- {PUBLISH_TEMPLATES.map((tpl) => (
+ {classicPublishTemplates.map((tpl) => (
+
{
+ setPublishMeta({
+ isPublic: true,
+ slug,
+ template: 'interactive-page',
+ })
+ }}
+ />
+