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 ( +
+ + + + + + + + + + + + {edges.map((e) => { + const a = positions.get(e.from) + const b = positions.get(e.to) + if (!a || !b) return null + const revealed = e.id in state.revealed || state.overview + if (!revealed) return null + const op = elementOpacity(e.id, state, dim) + if (op === 0) return null + const lit = state.overview || state.spotlight.includes(e.id) + const stroke = intentColor(e.intent, dark) + const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45 + return ( + + ) + })} + + { + const p = positions.get(id) + if (!p) return null + if (kind === 'badge') { + return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 } + } + if (kind === 'callout') { + return { x: p.x + p.w / 2 - 4, y: p.y } + } + return { x: p.x, y: p.y } + }} + /> + + + {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 ( + + {colLabels?.map((lab, i) => ( + + {lab} + + ))} + {rowLabels?.map((lab, i) => ( + + {lab} + + ))} + {Array.from({ length: rows }, (_, ri) => + Array.from({ length: cols }, (_, ci) => { + const r = ri + 1 + const c = ci + 1 + if (triangular === 'lower' && c > r) return null + if (triangular === 'upper' && c < r) return null + const id = `r${r}.c${c}` + const { x, y } = cellRect(r, c) + const revealed = id in state.revealed + const v = values[ri]?.[ci] ?? 0 + const lit = state.overview || state.spotlight.includes(id) + const op = revealed ? elementOpacity(id, state, dim) : 1 + + if (!revealed) { + return ( + + ) + } + + const fillOpacity = 0.14 + v * 0.78 + const textFill = + v > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111' + + return ( + + + + {v.toFixed(2)} + + + ) + }) + )} + { + const p = positions.get(id) + if (!p) return null + if (kind === 'badge') return { x: p.trX, y: p.trY } + if (kind === 'callout') return { x: p.trX + 4, y: p.cy } + return { x: p.cx, y: p.cy } + }} + /> + + ) +} + +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 ( + + + + + + {t('richTextEditor.publishInteractivePage') || + 'Page interactive'} + + + {phase === 'generating' + ? generatingHint + : t('richTextEditor.publishInteractivePagePreviewHint') || + 'Aperçu — vérifiez puis publiez sur l’URL publique'} + + + +
+ {phase === 'generating' ? ( +
+ +

{generatingHint}

+

+ {elapsedSec}s +

+
+ ) : null} + + {phase === 'error' ? ( +
+

+ {t('richTextEditor.publishInteractivePageFailed') || + 'Échec de la page interactive'} +

+

{error}

+
+ ) : null} + + {phase === 'preview' || phase === 'publishing' ? ( + page ? : null + ) : null} +
+ + + + + +
+
+ ) +} 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 ? ( + + ) : null} + + + {block.columns.map((c) => ( + + ))} + + + + {block.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {block.caption} +
+ {c} +
+ +
+
+ ) + } + + if (block.type === 'image') { + return ( +
+ { } + {block.alt} + {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) => ( +
+ +

+ {card.title} +

+ +
+ ))} +
+
+ ) : 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 ? ( +
+ {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) => (
+ +
+ +
+ + {disclaimer ? ( +

+ {fr ? disclaimer.fr : disclaimer.en} +

+ ) : null} + + +
+ ) +} diff --git a/memento-note/components/simulators/carnot-cycle-anim-view.tsx b/memento-note/components/simulators/carnot-cycle-anim-view.tsx new file mode 100644 index 0000000..341aaa0 --- /dev/null +++ b/memento-note/components/simulators/carnot-cycle-anim-view.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useDarkMode } from '@/components/interactive-demo/demo-speak' + +/** + * Carnot cycle animated scene — piston apparatus + live P-V diagram. + * Driven by AnimPlayerShell via `step` (0..4). Pure SVG, transitions on + * transform/opacity only (GPU-friendly, reduced-motion safe). + */ + +const PISTON_Y = [105, 70, 135, 170, 110] +const GAS_HOT = [1, 0.55, 0.12, 0.6, 0.5] +const GAS_COLD = [0, 0.45, 0.9, 0.4, 0.4] +const T_LABEL = ['T_h', 'T_h → T_c', 'T_c', 'T_c → T_h', 'η'] + +// P–V anchors (illustrative) +const A = { x: 405, y: 50 } +const B = { x: 495, y: 159 } +const C = { x: 630, y: 212 } +const D = { x: 465, y: 175 } +const ANCHORS = [A, B, C, D] +const CYCLE = `${A.x},${A.y} ${B.x},${B.y} ${C.x},${C.y} ${D.x},${D.y}` + +const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease' + +export function CarnotCycleAnimView({ step, lang }: { step: number; lang: string }) { + const fr = lang.startsWith('fr') + const dark = useDarkMode() + const s = Math.min(step, 4) + + const ink = dark ? '#EDE7DB' : '#242422' + const muted = dark ? '#A39C8D' : '#686762' + const plum = dark ? '#D07AA6' : '#9F3F70' + const blue = dark ? '#7FA8CC' : '#3F6F9F' + const line = dark ? '#3B352A' : '#D6D0C6' + const card = dark ? '#221E17' : '#FFFDF8' + const paper = dark ? '#17140F' : '#F4F0E8' + + const pistonY = PISTON_Y[s] + const showHotPlate = s === 0 + const showColdPlate = s === 2 + const showInsulation = s === 1 || s === 3 + const showQh = s === 0 + const showQc = s === 2 + const showWout = s === 0 || s === 1 + const showWin = s === 2 || s === 3 + const marker = ANCHORS[Math.min(s, 3)] + const segDone = s // segments A→B (0), B→C (1), C→D (2), D→A (3) + + return ( + + {/* ══ Piston apparatus ══ */} + + {/* cylinder walls */} + + {/* gas: cold + hot crossfade */} + + + + + {/* piston */} + + + + + + {/* hot plate */} + + {showHotPlate ? ( + + {fr ? 'Source chaude' : 'Hot'} T_h + + ) : null} + {/* cold plate */} + + {showColdPlate ? ( + + {fr ? 'Source froide' : 'Cold'} T_c + + ) : null} + {/* insulation */} + + {showInsulation ? ( + + {fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'} + + ) : null} + {/* Q_h arrow in */} + + + + Q_h + + {/* Q_c arrow out */} + + + + Q_c + + {/* W arrows */} + + + + W + + + + + W + + {/* temperature label inside gas */} + + {T_LABEL[s]} + + + + {/* ══ P–V diagram ══ */} + + + P–V + + {/* axes */} + + + V + P + {/* isotherms */} + + T_h + + T_c + {/* cycle area (final beat) */} + + {s === 4 ? ( + W + ) : null} + {/* cycle segments, revealed progressively */} + = 0 ? 1 : 0.15} /> + = 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + = 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + = 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + {/* current state marker */} + + + + ) +} diff --git a/memento-note/components/simulators/carnot-cycle-view.tsx b/memento-note/components/simulators/carnot-cycle-view.tsx new file mode 100644 index 0000000..137167f --- /dev/null +++ b/memento-note/components/simulators/carnot-cycle-view.tsx @@ -0,0 +1,672 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + carnotCycleSimulator as sim, + fridgeLoadFromModeLoad, + modeLoadFromFridgeLoad, + resolveCarnotPhysics, + type CarnotMode, + type CarnotQuantity, +} from '@/lib/simulators/carnot-cycle' +import { intentColor } from '@/lib/interactive-demo/intent-colors' +import { useDarkMode } from '@/components/interactive-demo/demo-speak' +import { SimHeading, SimOutputCard, SimSlider } from './sim-controls' +import { cn } from '@/lib/utils' + +const SVG_W = 360 +const SVG_H = 270 + +type TempUnit = 'K' | 'C' | 'F' + +const TEMP_UNITS: { id: TempUnit; label: string }[] = [ + { id: 'K', label: 'K' }, + { id: 'C', label: '°C' }, + { id: 'F', label: '°F' }, +] + +const MODES: { id: CarnotMode; fr: string; en: string }[] = [ + { id: 'fridge', fr: 'Frigo', en: 'Fridge' }, + { id: 'heat_pump', fr: 'PAC', en: 'Heat pump' }, + { id: 'engine', fr: 'Moteur', en: 'Engine' }, +] + +const QTY: { id: CarnotQuantity; fr: string; en: string }[] = [ + { id: 'energy', fr: 'Énergie (kJ)', en: 'Energy (kJ)' }, + { id: 'power', fr: 'Puissance (W)', en: 'Power (W)' }, +] + +function kelvinToDisplay(k: number, unit: TempUnit): number { + if (unit === 'C') return k - 273.15 + if (unit === 'F') return (k * 9) / 5 - 459.67 + return k +} + +function displayToKelvin(v: number, unit: TempUnit): number { + if (unit === 'C') return v + 273.15 + if (unit === 'F') return ((v + 459.67) * 5) / 9 + return v +} + +function formatTemp(k: number, unit: TempUnit): string { + const v = kelvinToDisplay(k, unit) + if (unit === 'K') return `${Math.round(v)} K` + if (unit === 'C') { + const r = Math.round(v * 10) / 10 + return `${Number.isInteger(r) ? r : r.toFixed(1)} °C` + } + return `${Math.round(v)} °F` +} + +function tempSliderMeta( + param: { min: number; max: number; step: number }, + unit: TempUnit +): { min: number; max: number; step: number; unitLabel: string } { + if (unit === 'K') { + return { min: param.min, max: param.max, step: param.step, unitLabel: 'K' } + } + if (unit === 'C') { + return { + min: Math.round((param.min - 273.15) * 10) / 10, + max: Math.round((param.max - 273.15) * 10) / 10, + step: 0.5, + unitLabel: '°C', + } + } + return { + min: Math.round(((param.min * 9) / 5 - 459.67) * 10) / 10, + max: Math.round(((param.max * 9) / 5 - 459.67) * 10) / 10, + step: 1, + unitLabel: '°F', + } +} + +function formatQty(v: number): string { + if (!Number.isFinite(v)) return '—' + if (Math.abs(v - Math.round(v)) < 0.05) return String(Math.round(v)) + return v.toFixed(1) +} + +function Segmented({ + value, + onChange, + options, + ariaLabel, +}: { + value: T + onChange: (v: T) => void + options: { id: T; label: string }[] + ariaLabel: string +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +/** Arrowhead + shaft, thickness ∝ |value|/maxRef. */ +function FlowArrow({ + x1, + y1, + x2, + y2, + value, + maxRef, + color, + label, + unit, + labelSide = 'right', +}: { + x1: number + y1: number + x2: number + y2: number + value: number + maxRef: number + color: string + label: string + unit?: string + labelSide?: 'left' | 'right' | 'above' | 'below' +}) { + const mag = Math.max(0, value) + const t = 2.2 + (mag / Math.max(1, maxRef)) * 10 + const dx = x2 - x1 + const dy = y2 - y1 + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const headLen = Math.min(14, Math.max(9, 7 + t * 0.45)) + const headHalf = Math.min(7, 3.2 + t * 0.35) + const bx = x2 - ux * headLen + const by = y2 - uy * headLen + const px = -uy + const py = ux + const mx = (x1 + bx) / 2 + const my = (y1 + by) / 2 + const labelGap = 11 + t * 0.45 + + let lx = mx + let ly = my + let textAnchor: 'start' | 'middle' | 'end' = 'middle' + if (labelSide === 'above') ly = my - labelGap + else if (labelSide === 'below') ly = my + labelGap + else if (labelSide === 'right') { + lx = mx + labelGap + textAnchor = 'start' + } else { + lx = mx - labelGap + textAnchor = 'end' + } + + return ( + + + + + {label} {formatQty(value)} + {unit ? ` ${unit}` : ''} + + + ) +} + +export function CarnotCycleView({ + preset, + disclaimer, + lang, +}: { + preset?: Record + title?: string + disclaimer?: string + lang: string +}) { + const fr = lang.startsWith('fr') + const dark = useDarkMode() + const [tempUnit, setTempUnit] = useState('K') + const [mode, setMode] = useState('fridge') + const [qty, setQty] = useState('energy') + const [values, setValues] = useState>(() => { + const env: Record = {} + for (const p of sim.params) env[p.id] = preset?.[p.id] ?? p.defaultValue + return env + }) + + const phys = useMemo( + () => + resolveCarnotPhysics( + values.t_cold, + values.t_hot, + modeLoadFromFridgeLoad(values.t_cold, values.t_hot, values.q_cold, mode), + mode + ), + [values.t_cold, values.t_hot, values.q_cold, mode] + ) + + const unitE = qty === 'energy' ? 'kJ' : 'W' + const workName = + qty === 'energy' + ? fr + ? 'Travail' + : 'Work' + : fr + ? 'Puissance' + : 'Power' + + const maxRef = Math.max(phys.qh || 0, phys.qc || 0, phys.w || 0, 1) + const cHot = intentColor('warning', dark) + const cCold = intentColor('cache', dark) + const cWork = intentColor('compute', dark) + const text = dark ? '#e4e4e7' : '#27272a' + + const cx = SVG_W / 2 - 20 + const hotY = 28 + const coldY = SVG_H - 42 + const midY = SVG_H / 2 - 4 + const r = 32 + const qOffset = 42 + const isEngine = mode === 'engine' + + const loadMeta = useMemo(() => { + if (mode === 'fridge') { + return { + symbol: qty === 'energy' ? 'Q_c' : '\\dot{Q}_c', + label: fr ? 'Chaleur extraite (froid)' : 'Heat extracted (cold)', + hint: fr + ? 'Charge utile du réfrigérateur' + : 'Useful fridge cooling load', + } + } + if (mode === 'heat_pump') { + return { + symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h', + label: fr ? 'Chaleur fournie (chaud)' : 'Heat delivered (hot)', + hint: fr ? 'Charge utile de la PAC' : 'Useful heat-pump output', + } + } + return { + symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h', + label: fr ? 'Chaleur absorbée (chaud)' : 'Heat absorbed (hot)', + hint: fr ? 'Entrée thermique du moteur' : 'Engine heat input', + } + }, [mode, qty, fr]) + + const modeLoad = modeLoadFromFridgeLoad( + values.t_cold, + values.t_hot, + values.q_cold, + mode + ) + + const workSym = qty === 'energy' ? 'W' : 'P' + const qLabel = (base: 'c' | 'h') => (qty === 'energy' ? `Q_${base}` : `Q̇_${base}`) + + const lawLine = !phys.ok + ? fr + ? 'Il faut T_h > T_c (températures absolues).' + : 'Need T_h > T_c (absolute temperatures).' + : fr + ? `1ᵉʳ principe : ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}` + : `1st law: ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}` + + return ( +
+
+ ({ id: m.id, label: fr ? m.fr : m.en }))} + /> + ({ id: q.id, label: fr ? q.fr : q.en }))} + /> + ({ id: u.id, label: u.label }))} + /> +
+ +
+
+ + + + {fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)} + + + + + {fr ? 'Source froide' : 'Cold'} · T_c ={' '} + {formatTemp(values.t_cold, tempUnit)} + + + + + {fr ? 'Machine' : 'Engine'} + + + {phys.ok && !isEngine ? ( + <> + {/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */} + + + + + ) : null} + + {phys.ok && isEngine ? ( + <> + {/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */} + + + + + ) : null} + + + {fr + ? qty === 'energy' + ? 'Épaisseur ∝ énergie (kJ) — W = travail (pas le watt)' + : 'Épaisseur ∝ puissance — P et W (watt) = même unité ici' + : qty === 'energy' + ? 'Thickness ∝ energy (kJ) — W = work (not the watt)' + : 'Thickness ∝ power — P uses watts'} + + +

{lawLine}

+ {phys.ok && phys.entropyOk ? ( +

+ {fr + ? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h' + : '2nd law (reversible): Q_c/T_c = Q_h/T_h'} +

+ ) : null} +
+ +
+
+ {fr ? 'Paramètres' : 'Parameters'} +
+ {sim.params + .filter((p) => p.id === 't_cold' || p.id === 't_hot') + .map((p) => { + const meta = tempSliderMeta(p, tempUnit) + const displayVal = kelvinToDisplay(values[p.id], tempUnit) + return ( + { + const k = displayToKelvin(v, tempUnit) + const clamped = Math.min(p.max, Math.max(p.min, k)) + setValues((s) => ({ ...s, [p.id]: clamped })) + }} + /> + ) + })} + + { + const fridgeQc = fridgeLoadFromModeLoad( + values.t_cold, + values.t_hot, + v, + mode + ) + setValues((s) => ({ + ...s, + q_cold: Math.min(500, Math.max(10, fridgeQc)), + })) + }} + /> +

{loadMeta.hint}

+
+
+ +
+ + {fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'} + +
+ {mode === 'fridge' || mode === 'heat_pump' ? ( + <> + + + + ) : ( + + )} + + + +
+

+ {qty === 'energy' + ? fr + ? 'W = travail (énergie en kJ), pas le watt. Passe en « Puissance (W) » pour raisonner en watts.' + : 'W = work (energy in kJ), not the watt. Switch to “Power (W)” to use watts.' + : fr + ? 'Mode puissance : P, Q̇_c et Q̇_h sont en watts (W). Les COP / η restent sans unité.' + : 'Power mode: P, Q̇_c and Q̇_h are in watts (W). COP / η stay dimensionless.'} +

+
+
+
+ {disclaimer ? ( +

{disclaimer}

+ ) : null} +
+ ) +} diff --git a/memento-note/components/simulators/generic-formula-view.tsx b/memento-note/components/simulators/generic-formula-view.tsx new file mode 100644 index 0000000..7595ad6 --- /dev/null +++ b/memento-note/components/simulators/generic-formula-view.tsx @@ -0,0 +1,205 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + CartesianGrid, + Line, + LineChart, + ReferenceDot, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Bar, + BarChart, +} from 'recharts' +import type { GenericFormulaSim } from '@/lib/interactive-page' +import { parseSimExpr } from '@/lib/interactive-page/sim-eval' +import { intentColor } from '@/lib/interactive-demo/intent-colors' +import { useDarkMode } from '@/components/interactive-demo/demo-speak' +import { SimHeading, SimOutputCard, SimSlider } from './sim-controls' + +const CURVE_SAMPLES = 60 + +/** Generic slider-driven formula simulator (safe exprs, no eval). */ +export function GenericFormulaView({ + sim, + lang, +}: { + sim: GenericFormulaSim + lang: string +}) { + const fr = lang.startsWith('fr') + const dark = useDarkMode() + const [values, setValues] = useState>(() => { + const env: Record = {} + for (const p of sim.params) env[p.id] = p.defaultValue + return env + }) + + const compiled = useMemo( + () => + sim.computed.map((c) => ({ + def: c, + parsed: parseSimExpr(c.expr), + })), + [sim.computed] + ) + + const results = useMemo(() => { + const env = { ...values } + const out: Record = {} + for (const c of compiled) { + const v = 'message' in c.parsed ? NaN : c.parsed.evaluate(env) + env[c.def.id] = v + out[c.def.id] = v + } + return out + }, [compiled, values]) + + const visual = sim.visual + const curve = useMemo(() => { + if (visual.kind !== 'curve') return null + const xParam = sim.params.find((p) => p.id === visual.xParamId) + const parsed = parseSimExpr(visual.expr) + if (!xParam || 'message' in parsed) return null + const pts: { x: number; y: number }[] = [] + for (let i = 0; i <= CURVE_SAMPLES; i++) { + const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES + const y = parsed.evaluate({ ...values, [xParam.id]: x }) + pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 }) + } + return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) } + }, [visual, sim.params, values]) + + const accent = intentColor('highlight', dark) + const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)' + + return ( +
+ {sim.intro ? ( +

+ {sim.intro} +

+ ) : null} +
+
+ {fr ? 'Paramètres' : 'Parameters'} +
+ {sim.params.map((p) => ( + setValues((s) => ({ ...s, [p.id]: v }))} + /> + ))} +
+
+ +
+ {fr ? 'Résultats' : 'Results'} + {sim.visual.kind === 'gauges' ? ( +
+ {sim.computed.map((c) => { + const v = results[c.id] + const maxRef = Math.max( + ...sim.computed.map((o) => Math.abs(results[o.id] || 0)), + 1 + ) + const color = intentColor(c.intent, dark) + return ( +
+
+ {c.label} + + {Number.isFinite(v) ? v.toFixed(2) : '—'} + {c.unit ? ` ${c.unit}` : ''} + +
+
+
+
+
+ ) + })} +
+ ) : null} + + {sim.visual.kind === 'bars' ? ( +
+ + ({ + name: c.label, + value: Number.isFinite(results[c.id]) ? results[c.id] : 0, + }))} + margin={{ top: 8, right: 8, left: 0, bottom: 4 }} + > + + + + + + + +
+ ) : null} + + {sim.visual.kind === 'curve' && curve ? ( +
+ + + + String(Math.round(v))} + /> + + + + {Number.isFinite(curve.currentY) ? ( + + ) : null} + + +
+ ) : null} + +
+ {sim.computed.map((c) => ( + + ))} +
+
+
+ {sim.disclaimer ? ( +

{sim.disclaimer}

+ ) : null} +
+ ) +} diff --git a/memento-note/components/simulators/index.ts b/memento-note/components/simulators/index.ts new file mode 100644 index 0000000..2e5e423 --- /dev/null +++ b/memento-note/components/simulators/index.ts @@ -0,0 +1,27 @@ +import type { ComponentType } from 'react' +import { CarnotCycleView } from './carnot-cycle-view' +import { CarnotCycleAnimView } from './carnot-cycle-anim-view' +import { TsDiagramView } from './ts-diagram-view' + +export type CatalogSimViewProps = { + preset?: Record + title?: string + disclaimer?: string + lang: string +} + +export type CatalogAnimViewProps = { + step: number + lang: string +} + +/** Registry: catalog simId → bespoke slider-simulation view. */ +export const SIMULATOR_VIEWS: Record> = { + 'carnot-cycle': CarnotCycleView, +} + +/** Registry: catalog simId → bespoke animated scene (step-driven). */ +export const ANIM_VIEWS: Record> = { + 'carnot-cycle-anim': CarnotCycleAnimView, + 'ts-diagram': TsDiagramView, +} diff --git a/memento-note/components/simulators/sim-controls.tsx b/memento-note/components/simulators/sim-controls.tsx new file mode 100644 index 0000000..7097055 --- /dev/null +++ b/memento-note/components/simulators/sim-controls.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useMemo } from 'react' +import katex from 'katex' +import 'katex/dist/katex.min.css' +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' + +export function SimKaTeX({ tex, className }: { tex: string; className?: string }) { + const html = useMemo(() => { + try { + // output:'html' avoids MathML annotation leaking as visible "mathrm{…}" in flex layouts + return katex.renderToString(tex, { throwOnError: false, output: 'html' }) + } catch { + return tex + } + }, [tex]) + return ( + + ) +} + +export function SimSlider({ + symbol, + label, + unit, + min, + max, + step, + value, + intent, + onChange, +}: { + symbol: string + label: string + unit?: string + min: number + max: number + step: number + value: number + intent?: IntentId + onChange: (v: number) => void +}) { + const dark = useDarkMode() + const accent = intentColor(intent, dark) + return ( + + ) +} + +export function SimOutputCard({ + symbol, + label, + value, + unit, + intent, + digits = 2, +}: { + symbol: string + label: string + value: number + unit?: string + intent?: IntentId + digits?: number +}) { + const dark = useDarkMode() + const accent = intentColor(intent, dark) + const finite = Number.isFinite(value) + return ( +
+
{label}
+
+ + + {finite ? value.toFixed(digits) : '—'} + {finite && unit ? ` ${unit}` : ''} + +
+
+ ) +} + +/** Small section heading inside a simulator card. */ +export function SimHeading({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +

+ {children} +

+ ) +} diff --git a/memento-note/components/simulators/ts-diagram-view.tsx b/memento-note/components/simulators/ts-diagram-view.tsx new file mode 100644 index 0000000..667c5da --- /dev/null +++ b/memento-note/components/simulators/ts-diagram-view.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useDarkMode } from '@/components/interactive-demo/demo-speak' + +/** + * T–s diagram of the Carnot cycle — canonical 2nd-law diagram. + * Driven by AnimPlayerShell via `step` (0..4). SVG, transform/opacity only. + * Cycle = rectangle: horizontal isotherms (Th, Tc), vertical adiabatics. + */ + +const X0 = 110 // y-axis x +const Y0 = 280 // x-axis y +const S1 = 190 // entropy left +const S2 = 520 // entropy right +const YH = 80 // T_h line y +const YC = 220 // T_c line y + +const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease' + +// marker position per beat (end of each phase) +const MARKER = [ + { x: S2, y: YH }, // b1 end: right-top + { x: S2, y: YC }, // b2 end: right-bottom + { x: S1, y: YC }, // b3 end: left-bottom + { x: S1, y: YH }, // b4 end: left-top + { x: S1, y: YH }, // b5: stay +] + +export function TsDiagramView({ step, lang }: { step: number; lang: string }) { + const fr = lang.startsWith('fr') + const dark = useDarkMode() + const s = Math.min(step, 4) + + const ink = dark ? '#EDE7DB' : '#242422' + const muted = dark ? '#A39C8D' : '#686762' + const plum = dark ? '#D07AA6' : '#9F3F70' + const blue = dark ? '#7FA8CC' : '#3F6F9F' + const paper = dark ? '#17140F' : '#F4F0E8' + + const m = MARKER[s] + const qhW = S2 - S1 + + return ( + + {/* axes */} + + + s + T + + {/* isotherm T_h */} + + T_h + {/* isotherm T_c */} + + T_c + + {/* Q_h area (beat ≥ 0, shown from beat 0) */} + = 0 ? 0.10 : 0} + style={{ transition: 'opacity 600ms ease' }} + /> + {s === 0 ? ( + Q_h + ) : null} + {/* Q_c area (from beat 2) */} + = 2 ? 0.16 : 0} + style={{ transition: 'opacity 600ms ease' }} + /> + {s >= 2 && s < 4 ? ( + Q_c + ) : null} + {/* W = cycle area (beat 4) */} + + {s === 4 ? ( + W + ) : null} + + {/* cycle edges, drawn progressively */} + {/* top: b1 (isothermal expansion) */} + = 0 ? 1 : 0.15} /> + = 0 ? 1 : 0.15} /> + {/* right: b2 (adiabatic expansion) */} + = 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + = 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + {/* bottom: b3 (isothermal compression) */} + = 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + = 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + {/* left: b4 (adiabatic compression) */} + = 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + = 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} /> + + {/* entropy ticks */} + s₁ + s₂ + + {/* state marker */} + + + ) +} diff --git a/memento-note/components/tiptap-interactive-demo-extension.tsx b/memento-note/components/tiptap-interactive-demo-extension.tsx new file mode 100644 index 0000000..ffb88d5 --- /dev/null +++ b/memento-note/components/tiptap-interactive-demo-extension.tsx @@ -0,0 +1,131 @@ +'use client' + +import { Node, mergeAttributes } from '@tiptap/core' +import { + ReactNodeViewRenderer, + NodeViewWrapper, + type NodeViewProps, +} from '@tiptap/react' +import type { Editor } from '@tiptap/core' +import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player' +import { + validateInteractiveDemo, + type InteractiveDemoV1, +} from '@/lib/interactive-demo' +import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json' +import { AlertCircle } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { useMemo } from 'react' + +function InteractiveDemoView(props: NodeViewProps) { + const { t } = useLanguage() + const raw = props.node.attrs.demoJson as string + + const parsed = useMemo(() => { + try { + const data = JSON.parse(raw || '{}') + return validateInteractiveDemo(data) + } catch { + return { + ok: false as const, + issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }], + } + } + }, [raw]) + + if (!parsed.ok) { + return ( + +
+ +
+

+ {t('interactiveDemo.invalid') || 'Interactive demo invalide'} +

+
    + {parsed.issues.slice(0, 5).map((iss, i) => ( +
  • + {iss.path ? `${iss.path}: ` : ''} + {iss.message} +
  • + ))} +
+
+
+
+ ) + } + + return ( + + + + ) +} + +export const InteractiveDemoExtension = Node.create({ + name: 'interactiveDemo', + group: 'block', + atom: true, + draggable: true, + selectable: true, + + addAttributes() { + return { + demoJson: { + default: '{}', + parseHTML: (el) => el.getAttribute('data-demo-json') || '{}', + renderHTML: (attrs) => ({ + 'data-demo-json': attrs.demoJson || '{}', + }), + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-interactive-demo]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }), + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(InteractiveDemoView) + }, +}) + +export function insertInteractiveDemoAtSelection( + editor: Editor, + demo?: InteractiveDemoV1 +): boolean { + const type = editor.schema.nodes.interactiveDemo + if (!type) return false + + const payload = demo ?? (attnresFixture as InteractiveDemoV1) + const check = validateInteractiveDemo(payload) + // Prefer validated shape; if already-server-validated payload fails client Zod + // (HMR drift), still insert so the block appears — NodeView shows issues if needed. + const attrs = { + demoJson: JSON.stringify(check.ok ? check.demo : payload), + } + const { empty, $from } = editor.state.selection + const pos = empty ? $from.pos : editor.state.selection.from + + return editor + .chain() + .focus() + .insertContentAt(pos, { type: 'interactiveDemo', attrs }) + .run() +} diff --git a/memento-note/components/usage-meter.tsx b/memento-note/components/usage-meter.tsx index efabd76..711f2a5 100644 --- a/memento-note/components/usage-meter.tsx +++ b/memento-note/components/usage-meter.tsx @@ -40,6 +40,8 @@ const FEATURE_LABEL_KEYS: Record = { brainstorm_expand: 'usageMeter.featureBrainstormExpand', brainstorm_enrich: 'usageMeter.featureBrainstormEnrich', suggest_charts: 'usageMeter.featureCharts', + interactive_demo: 'usageMeter.featureInteractiveDemo', + interactive_page: 'usageMeter.featureInteractivePage', publish_enhance: 'usageMeter.featurePublishEnhance', ai_flashcard: 'usageMeter.featureFlashcards', voice_transcribe: 'usageMeter.featureVoice', diff --git a/memento-note/docs/PUBLISHING.md b/memento-note/docs/PUBLISHING.md new file mode 100644 index 0000000..edd2fd9 --- /dev/null +++ b/memento-note/docs/PUBLISHING.md @@ -0,0 +1,29 @@ +# Publication — point de branchement `interactive-page` + +## Canal existant (ne pas dupliquer) + +| Élément | Emplacement | +|---|---| +| API | `app/api/notes/publish/route.ts` — `action: publish \| unpublish`, `mode?: simple \| ai \| interactive-page` | +| UI | `components/note-editor/note-editor-toolbar.tsx` (menu Globe) + `InteractivePagePublishDialog` | +| Page publique | `app/(public)/p/[slug]/page.tsx` → `/p/{publicSlug}` | +| Fetch | `getPublishedNote(slug)` dans `app/actions/notes-publishing.ts` | +| Templates | `lib/publish/types.ts` — `PUBLISH_TEMPLATES` = `magazine \| brief \| essay \| interactive-page` | + +### Champs `Note` (Prisma) + +- `isPublic`, `publicSlug`, `publishedAt` +- `publishedContent` — snapshot opaque (`String?`) : HTML aujourd’hui, **JSON PageSpecV1** pour `interactive-page` +- `publishedTemplate` — discriminant de rendu +- `publishedSourceHash` — détection stale (pas de régénération silencieuse) + +## Branchement `interactive-page` + +1. Étendre `PUBLISH_TEMPLATES` avec `'interactive-page'`. +2. Publish : générer/valider via `lib/interactive-page/` → stocker `JSON.stringify(PageSpecV1)` dans `publishedContent`, `publishedTemplate = 'interactive-page'`. +3. Rendu public : dans `PublishedNotePage`, si `publishedTemplate === 'interactive-page'` → parser → `` (SSR + hydrate démos). +4. Unpublish / stale : **même** cycle de vie que magazine/brief/essay (`publishedSourceHash` → badge « à régénérer »). + +## Hors scope + +- `NotebookSite` `/c/[slug]`, `NoteShare` (collab privée), server actions legacy `publishNote` incomplets. diff --git a/memento-note/lib/ai/services/interactive-demo-client.service.ts b/memento-note/lib/ai/services/interactive-demo-client.service.ts new file mode 100644 index 0000000..aa25c91 --- /dev/null +++ b/memento-note/lib/ai/services/interactive-demo-client.service.ts @@ -0,0 +1,74 @@ +import type { InteractiveDemoV1, ValidationIssue } from '@/lib/interactive-demo' + +export type GenerateInteractiveDemoResponse = + | { ok: true; demo: InteractiveDemoV1; attempts: number } + | { + ok: false + error: string + issues?: ValidationIssue[] + quotaExceeded?: boolean + status?: number + } + +export async function generateInteractiveDemo(params: { + content: string + selection?: string | null + lang?: string + noteId?: string +}): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 170_000) + let res: Response + try { + res = await fetch('/api/ai/interactive-demo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: params.content, + selection: params.selection, + lang: params.lang, + noteId: params.noteId, + }), + signal: controller.signal, + }) + } catch (err) { + clearTimeout(timeout) + const aborted = err instanceof Error && err.name === 'AbortError' + return { + ok: false, + error: aborted + ? 'Génération trop longue — réessaie (ou raccourcis la note)' + : err instanceof Error + ? err.message + : 'Network error', + } + } finally { + clearTimeout(timeout) + } + + const data = await res.json().catch(() => ({})) + + if (res.status === 402) { + return { + ok: false, + error: data.error || 'Quota exceeded', + quotaExceeded: true, + status: 402, + } + } + + if (!res.ok) { + return { + ok: false, + error: data.error || `HTTP ${res.status}`, + issues: data.issues, + status: res.status, + } + } + + return { + ok: true, + demo: data.demo, + attempts: data.attempts ?? 1, + } +} diff --git a/memento-note/lib/ai/services/interactive-demo-generate.service.ts b/memento-note/lib/ai/services/interactive-demo-generate.service.ts new file mode 100644 index 0000000..5f932ab --- /dev/null +++ b/memento-note/lib/ai/services/interactive-demo-generate.service.ts @@ -0,0 +1,291 @@ +import { generateText } from 'ai' +import type { AIProvider } from '@/lib/ai/types' +import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response' +import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets' +import { + INTERACTIVE_DEMO_SCHEMA_VERSION, + PATTERN_IDS, + PANEL_TYPES, + INTENT_IDS, + ANNOTATION_KINDS, + SPEAK_WRITING_RULE, + validateInteractiveDemo, + normalizeInteractiveDemoCandidate, + type InteractiveDemoV1, + type ValidationIssue, +} from '@/lib/interactive-demo' + +const MAX_ATTEMPTS = 2 + +function stripToPlain(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() +} + +function extractJsonObject(raw: string): unknown | null { + if (!raw) return null + const cleaned = cleanAIJsonResponse(raw) + const tryParse = (s: string): unknown | null => { + try { + return JSON.parse(s) + } catch { + return null + } + } + const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1') + + let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned)) + if (parsed) return parsed + + const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i) + const candidate = fence?.[1]?.trim() ?? cleaned + parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate)) + if (parsed) return parsed + + const start = candidate.indexOf('{') + const end = candidate.lastIndexOf('}') + if (start >= 0 && end > start) { + const slice = candidate.slice(start, end + 1) + parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice)) + if (parsed) return parsed + } + return null +} + +/** STEM cycle example — equations in speak + node labels via $...$ */ +const STEM_CYCLE_EXAMPLE = `{ + "schemaVersion": 1, + "id": "demo.refrigeration-cycle", + "lang": "fr", + "disclaimer": "Schéma pédagogique — grandeurs illustratives.", + "scene": { + "id": "scene.cycle", + "panels": [{ + "id": "panel.cycle", + "type": "svg-scene", + "payload": { + "nodes": [ + { "id": "compressor", "label": "1 · Compresseur\\n$W = h_2 - h_1$", "intent": "compute" }, + { "id": "condenser", "label": "2 · Condenseur\\n$Q_c = h_2 - h_3$", "intent": "output" }, + { "id": "expansion", "label": "3 · Détente\\n$h_3 = h_4$", "intent": "flow" }, + { "id": "evaporator", "label": "4 · Évaporateur\\n$Q_e = h_1 - h_4$", "intent": "cache" } + ], + "edges": [ + { "id": "e12", "from": "compressor", "to": "condenser", "style": "solid", "intent": "flow" }, + { "id": "e23", "from": "condenser", "to": "expansion", "style": "solid", "intent": "flow" }, + { "id": "e34", "from": "expansion", "to": "evaporator", "style": "solid", "intent": "flow" }, + { "id": "e41", "from": "evaporator", "to": "compressor", "style": "solid", "intent": "flow" } + ] + } + }] + }, + "acts": [{ + "id": "a1", + "title": "Cycle frigorifique", + "pattern": "flowTrace", + "steps": [ + { "id": "a1.s1", "speak": "Compression : le travail fourni est $W = h_2 - h_1$.", "pattern": "spotlightTour", "pointTo": ["compressor"], "reveal": [{"ids":["compressor"],"scope":"act"}] }, + { "id": "a1.s2", "speak": "Au condenseur, la chaleur rejetée : $Q_c = h_2 - h_3$.", "pattern": "spotlightTour", "pointTo": ["condenser"], "reveal": [{"ids":["condenser","e12"],"scope":"act"}] }, + { "id": "a1.s3", "speak": "Détente isenthalpique : $h_3 = h_4$.", "pattern": "spotlightTour", "pointTo": ["expansion"], "reveal": [{"ids":["expansion","e23"],"scope":"act"}] }, + { "id": "a1.s4", "speak": "Évaporateur : $Q_e = h_1 - h_4$. COP $= Q_e / W$.", "pattern": "overview", "pointTo": ["evaporator"], "reveal": [{"ids":["evaporator","e34","e41"],"scope":"act"}] } + ] + }] +}` + +function buildSystemPrompt(lang: string, hasMath: boolean): string { + return `You generate Interactive Demo JSON for Memento notes (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}). + +OUTPUT: a single JSON object only. No markdown fences. No commentary. No blocks. + +MISSION: teach the ACTUAL content of the note — concepts, quantities, AND equations. +You are NOT allowed to invent a toy 2-node graph ("A" → "B") that ignores the note. + +CONTENT FIDELITY (critical): +- Read EVERY extracted formula. Put the important ones in speak as inline KaTeX: $...$ +- Put key equations on the matching node labels too (use \\n then $latex$). +- Use the note's real names (Compresseur, Condenseur, COP, …) — never vague placeholders. +- For a physical CYCLE / loop (frigo, Carnot, Rankine, feedback…): 4+ stage nodes + cycle edges closing the loop. +- For a derivation: nodes = successive expressions / steps, speak cites the formula at each step. +- Min 3 nodes for svg-scene (4+ for cycles). Min 3 steps. Prefer 4–6 steps. +- NEVER output only 2 boxes with plain words and zero equations when the note has math. + +PANEL CHOICE: +- Process / cycle / architecture → svg-scene (rich graph) +- Time series / comparison of measured values → chart +- Matrix / attention / correlation → heatmap-matrix +- Max 2 panels. May combine svg-scene + chart if useful. + +SCHEMA: +{ + "schemaVersion": 1, + "id": "demo.", + "lang": "${lang}", + "disclaimer?": string, + "scene": { "id?": string, "panels": [Panel] }, + "acts": [ { "id": "a1", "title": string, "pattern?", "steps": [Step] } ] +} +Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?", "reveal?":[{"ids":[],"scope":"transient|act|scene"}], "annotate?":[{"kind","targetIds","scope","text?","intent?"}] } +Panel types: ${PANEL_TYPES.join(', ')} +Patterns: ${PATTERN_IDS.join(', ')} +Intents: ${INTENT_IDS.join(', ')} +Annotation kinds: ${ANNOTATION_KINDS.join(', ')} +chartType: line | bar | area +Heatmap cells: r{row}.c{col}; triangular:"lower" when appropriate. +svg-scene: nodes[{id,label?,intent?}], edges[{id,from,to,style?,weight?,intent?}] +chart: series[{id,label?,values:number[],intent?}] + +${hasMath ? `STEM MODE ON: formulas were extracted from the note. You MUST: +- Include at least 2 distinct equations from FORMULES_EXTRAITES in speak and/or node labels as $latex$ +- Prefer flowTrace / spotlightTour over vague overview-only demos +- Example shape to imitate (adapt to THIS note's real formulas/stages): +${STEM_CYCLE_EXAMPLE}` : `If the note has little math, still build a faithful conceptual diagram (≥3 nodes) from the real vocabulary of the note.`} + +RULES: +- ${SPEAK_WRITING_RULE} +- speak may include light markdown + inline $KaTeX$ (required for STEM). +- Never put hex/rgb colors — intents only. +- Never annotate an id that is also in pointTo on the same step. +- Step ids = a1.s1, a1.s2… matching act id. +- All pointTo/reveal/annotate ids must exist in the active scene. +- Wildcard "*" reveal ONLY for heatmap/chart panels — not svg-scene alone. +- Narration language = lang ("${lang}"). +- disclaimer when values are pedagogical/illustrative.` +} + +function buildUserPrompt( + content: string, + assets: ReturnType, + opts?: { + issues?: ValidationIssue[] + previousJson?: string + } +): string { + const plain = stripToPlain(content).slice(0, 7000) + let msg = `Create an Interactive Demo that teaches THIS note faithfully (equations included). + +EXCERPT_START +${plain} +EXCERPT_END +` + + if (assets.formulas.length) { + msg += `\nFORMULES_EXTRAITES (OBLIGATOIRE — réutilise telles quelles en $...$ dans speak et labels):\n` + msg += assets.formulas + .slice(0, 16) + .map((f, i) => `${i + 1}. ${f}`) + .join('\n') + msg += '\n' + } + + if (assets.keySentences.length) { + msg += `\nPHRASES_CLES:\n` + msg += assets.keySentences + .slice(0, 8) + .map((s) => `- ${s}`) + .join('\n') + msg += '\n' + } + + if (assets.numbers.length) { + msg += `\nDONNEES_NUMERIQUES:\n${JSON.stringify(assets.numbers.slice(0, 8))}\n` + } + + msg += `\nCONTRAINTES: ≥3 nœuds svg (cycle → 4+ et boucle fermée). speak avec $équations$ si FORMULES_EXTRAITES non vide. Interdit: schéma jouet à 2 boîtes sans maths.\n` + + if (opts?.issues?.length) { + msg += `\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n` + msg += opts.issues + .slice(0, 12) + .map((i) => `- [${i.code}] ${i.path}: ${i.message}`) + .join('\n') + } + if (opts?.previousJson) { + msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 12000)}\nPREVIOUS_JSON_END` + } + return msg +} + +export type GenerateInteractiveDemoInput = { + content: string + lang?: string + provider: AIProvider +} + +export type GenerateInteractiveDemoResult = + | { ok: true; demo: InteractiveDemoV1; attempts: number } + | { ok: false; issues: ValidationIssue[]; raw?: string; attempts: number } + +/** + * LLM → JSON → normalize → validateInteractiveDemo, with repair passes. + * Harvests formulas like slide generation before prompting. + */ +export async function generateInteractiveDemoFromContent( + input: GenerateInteractiveDemoInput +): Promise { + const lang = input.lang || 'fr' + const assets = extractSourceAssets(input.content) + const system = buildSystemPrompt(lang, assets.hasMath || assets.formulas.length > 0) + const model = input.provider.getModel() + let lastIssues: ValidationIssue[] = [] + let lastRaw = '' + let lastNormalizedJson = '' + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const user = buildUserPrompt(input.content, assets, { + issues: attempt > 1 ? lastIssues : undefined, + previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined, + }) + + const { text: raw } = await generateText({ + model, + system, + prompt: user, + temperature: attempt === 1 ? 0.25 : 0.1, + }) + lastRaw = raw + + const parsed = extractJsonObject(raw) + if (!parsed) { + lastIssues = [ + { + code: 'invalid_json', + path: '', + message: 'Model did not return parseable JSON', + }, + ] + console.warn( + `[interactive-demo] attempt ${attempt}: unparseable JSON`, + raw.slice(0, 400) + ) + continue + } + + const normalized = normalizeInteractiveDemoCandidate(parsed, lang) + lastNormalizedJson = JSON.stringify(normalized) + + const result = validateInteractiveDemo(normalized) + if (result.ok) { + return { ok: true, demo: result.demo, attempts: attempt } + } + lastIssues = result.issues + console.warn( + `[interactive-demo] attempt ${attempt}: validation failed`, + result.issues.slice(0, 8) + ) + } + + return { + ok: false, + issues: lastIssues, + raw: lastRaw, + attempts: MAX_ATTEMPTS, + } +} diff --git a/memento-note/lib/ai/services/interactive-page-client.service.ts b/memento-note/lib/ai/services/interactive-page-client.service.ts new file mode 100644 index 0000000..ca9d715 --- /dev/null +++ b/memento-note/lib/ai/services/interactive-page-client.service.ts @@ -0,0 +1,211 @@ +import type { + PageSection, + PageSpecV1, + PageValidationIssue, +} from '@/lib/interactive-page' + +export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none' + +export type PagePlanSection = { + title: string + goal: string + demoKind: PagePlanDemoKind + demoGoal?: string +} + +export type PagePlan = { + heroTitle: string + heroSubtitle?: string + overviewLead: string + overviewCards: { + badge: string + title: string + body: string + intent?: string + }[] + sections: PagePlanSection[] +} + +export type GenerateInteractivePageResponse = + | { ok: true; page: PageSpecV1; attempts: number } + | { + ok: false + error: string + reason?: string + issues?: PageValidationIssue[] + quotaExceeded?: boolean + status?: number + } + +export type GeneratePlanResponse = + | { ok: true; plan: PagePlan; attempts: number } + | { + ok: false + error: string + reason?: string + quotaExceeded?: boolean + status?: number + } + +export type GenerateSectionResponse = + | { ok: true; section: PageSection; attempts: number } + | { ok: false; error: string; status?: number } + +async function postJson( + body: Record, + timeoutMs = 55_000 +): Promise<{ res: Response; data: Record } | { abortError: true }> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await fetch('/api/ai/interactive-page', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }) + const data = await res.json().catch(() => ({})) + return { res, data } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return { abortError: true } + } + throw err + } finally { + clearTimeout(timeout) + } +} + +/** LLM plan (billed — 20 crédits). */ +export async function generateInteractivePagePlan(params: { + content: string + lang?: string + noteId?: string +}): Promise { + let out: Awaited> + try { + out = await postJson({ ...params, action: 'plan' }) + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Network error', + } + } + if ('abortError' in out) { + return { ok: false, error: 'Génération trop longue — réessaie' } + } + const { res, data } = out as { + res: Response + + data: any + } + if (res.status === 402) { + return { + ok: false, + error: data.error || 'Quota exceeded', + quotaExceeded: true, + status: 402, + } + } + if (!res.ok) { + return { + ok: false, + error: data.error || `HTTP ${res.status}`, + reason: data.reason, + status: res.status, + } + } + return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 } +} + +/** LLM single section (not billed — page billed at plan time). */ +export async function generateInteractivePageSection(params: { + content: string + lang?: string + noteId?: string + pageTitle: string + sectionId: string + section: PagePlanSection +}): Promise { + let out: Awaited> + try { + out = await postJson({ ...params, action: 'section' }) + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Network error', + } + } + if ('abortError' in out) { + return { ok: false, error: 'timeout' } + } + const { res, data } = out as { + res: Response + + data: any + } + if (!res.ok) { + return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status } + } + return { ok: true, section: data.section, attempts: data.attempts ?? 1 } +} + +/** Legacy deterministic full page (fallback, no quota). */ +export async function generateInteractivePage(params: { + content: string + lang?: string + noteId?: string + notebookId?: string +}): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 75_000) + let res: Response + try { + res = await fetch('/api/ai/interactive-page', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + signal: controller.signal, + }) + } catch (err) { + clearTimeout(timeout) + const aborted = err instanceof Error && err.name === 'AbortError' + return { + ok: false, + error: aborted + ? 'Génération trop longue — réessaie' + : err instanceof Error + ? err.message + : 'Network error', + } + } finally { + clearTimeout(timeout) + } + + const data = await res.json().catch(() => ({})) + + if (res.status === 402) { + return { + ok: false, + error: data.error || 'Quota exceeded', + quotaExceeded: true, + status: 402, + } + } + + if (!res.ok) { + return { + ok: false, + error: data.error || `HTTP ${res.status}`, + reason: data.reason, + issues: data.issues, + status: res.status, + } + } + + return { + ok: true, + page: data.page, + attempts: data.attempts ?? 1, + } +} diff --git a/memento-note/lib/ai/services/interactive-page-generate.service.ts b/memento-note/lib/ai/services/interactive-page-generate.service.ts new file mode 100644 index 0000000..dc3873f --- /dev/null +++ b/memento-note/lib/ai/services/interactive-page-generate.service.ts @@ -0,0 +1,398 @@ +import type { AIProvider } from '@/lib/ai/types' +import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets' +import { + validateInteractiveDemo, + type InteractiveDemoV1, +} from '@/lib/interactive-demo' +import { + validateInteractivePage, + type PageSpecV1, + type PageValidationIssue, +} from '@/lib/interactive-page' +import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize' + +function stripToPlain(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() +} + +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' +} + +function firstSentence(text: string, max = 100): string { + const s = text.split(/[.!?。]/)[0]?.trim() || text.trim() + return s.slice(0, max) || 'Page interactive' +} + +function chunkSentences(text: string): string[] { + return text + .split(/(?<=[.!?。])\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 20) +} + +const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const + +/** + * Instant Play/Step demo from note vocabulary — no LLM. + * 3–4 nodes + spotlight steps; formulas in speak when available. + */ +export function buildDeterministicDemo( + content: string, + lang: string, + assets: ReturnType +): InteractiveDemoV1 | null { + const fr = lang.startsWith('fr') + const plain = stripToPlain(content) + const sentences = [ + ...assets.keySentences, + ...chunkSentences(plain), + ].filter((s, i, a) => a.indexOf(s) === i) + + // Prefer short phrase labels from key sentences — never raw formula fragments + const labels: string[] = [] + for (const s of sentences.slice(0, 8)) { + const words = s + .replace(/\$[^$]*\$/g, ' ') + .replace(/[\\{}]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .slice(0, 4) + .join(' ') + .trim() + if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) { + labels.push(words) + } + if (labels.length >= 4) break + } + // Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded) + const cleanFormulas = assets.formulas.filter((f) => f.length <= 80) + if (labels.length < 3) { + const fallbacks = fr + ? ['Entrée', 'Transformation', 'Résultat', 'Retour'] + : ['Input', 'Transform', 'Output', 'Loop'] + for (const fb of fallbacks) { + if (labels.length >= 4) break + if (!labels.includes(fb)) labels.push(fb) + } + } + while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`) + const nodeCount = Math.min(4, Math.max(3, labels.length)) + + const nodes = labels.slice(0, nodeCount).map((label, i) => ({ + id: `n${i + 1}`, + label: + cleanFormulas[i] && i < 2 + ? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$` + : `${i + 1} · ${label}`, + intent: INTENT_CYCLE[i % INTENT_CYCLE.length], + })) + + const edges = nodes.map((n, i) => { + const next = nodes[(i + 1) % nodes.length] + return { + id: `e${i + 1}`, + from: n.id, + to: next.id, + style: 'solid' as const, + intent: 'flow' as const, + } + }) + + const steps = nodes.map((n, i) => { + const formula = cleanFormulas[i] + const speakBase = + sentences[i]?.slice(0, 100) || + (fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`) + const speak = formula + ? `${speakBase.split('.')[0]}. $${formula}$` + : speakBase + const revealed = nodes.slice(0, i + 1).map((x) => x.id) + if (i > 0) revealed.push(edges[i - 1].id) + const isLast = i === nodes.length - 1 + return { + id: `a1.s${i + 1}`, + speak: speak.slice(0, 160), + pattern: isLast ? ('overview' as const) : ('spotlightTour' as const), + pointTo: [n.id], + reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }], + } + }) + + const raw = { + schemaVersion: 1 as const, + id: 'demo.page-auto', + lang, + disclaimer: fr + ? 'Schéma pédagogique généré depuis la note — valeurs illustratives.' + : 'Pedagogical diagram from your note — illustrative values.', + scene: { + id: 'scene.main', + panels: [ + { + id: 'panel.main', + type: 'svg-scene' as const, + payload: { nodes, edges }, + }, + ], + }, + acts: [ + { + id: 'a1', + title: fr ? 'Parcours' : 'Walkthrough', + pattern: 'flowTrace' as const, + steps, + }, + ], + } + + const validated = validateInteractiveDemo(raw) + if (!validated.ok) { + console.warn( + '[interactive-page] deterministic demo invalid', + validated.issues.slice(0, 5) + ) + return null + } + return validated.demo +} + +export function buildPageFromNote( + content: string, + lang: string, + assets: ReturnType +): Record { + const plain = stripToPlain(content) + const sentences = [ + ...assets.keySentences, + ...chunkSentences(plain), + ].filter((s, i, arr) => arr.indexOf(s) === i) + const title = firstSentence(sentences[0] || plain, 90) + const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title + const fr = lang.startsWith('fr') + // Displayable formulas only (KaTeX eats spaces — no prose, bounded length) + const displayFormulas = assets.formulas.filter((f) => f.length <= 120) + const formula = displayFormulas[0] + const formula2 = displayFormulas[1] + + const cards = [ + { + badge: 'PROBLEM', + title: fr ? 'Contexte' : 'Context', + body: (sentences[0] || lead).slice(0, 160), + intent: 'warning' as const, + }, + { + badge: 'APPROACH', + title: fr ? 'Approche' : 'Approach', + body: (sentences[1] || sentences[0] || lead).slice(0, 160), + intent: 'flow' as const, + }, + { + badge: 'RESULT', + title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea', + body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160), + intent: 'output' as const, + }, + ] + + const sections: Record[] = [ + { + id: 's1', + title: fr ? 'Le problème' : 'The problem', + blocks: [ + { type: 'prose', md: sentences[0] || lead }, + { + type: 'callout', + kind: 'definition', + title: fr ? 'En bref' : 'In short', + md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220), + }, + ], + }, + { + id: 's2', + title: fr ? 'Mécanisme' : 'Mechanism', + blocks: [ + { type: 'prose', md: sentences[1] || sentences[0] || lead }, + ...(formula ? [{ type: 'formula', tex: formula }] : []), + ...(formula2 + ? [ + { + type: 'callout', + kind: 'tip', + title: fr ? 'Aussi' : 'Also', + md: `$${formula2}$`, + }, + ] + : []), + ], + }, + { + id: 's3', + title: fr ? 'Synthèse' : 'Synthesis', + blocks: [ + { + type: 'prose', + md: + sentences[2] || + (fr + ? 'Retenez le mécanisme — la démo interactive en retrace le flux.' + : 'Keep the mechanism — the interactive demo traces the flow.'), + }, + { + type: 'stats', + items: [ + { + value: String(Math.max(assets.formulas.length, 1)), + label: fr ? 'Formules' : 'Formulas', + }, + { + value: String(Math.min(Math.max(sentences.length, 3), 8)), + label: fr ? 'Idées' : 'Ideas', + }, + assets.numbers[0] + ? { + value: String(assets.numbers[0].value), + label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'), + } + : { value: '→', label: fr ? 'Suite' : 'Next' }, + ], + }, + ], + }, + ] + + return { + schemaVersion: 1, + id: slugId(title), + lang, + hero: { + kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER', + title, + subtitle: (sentences[1] || plain).slice(0, 140), + meta: fr ? 'Généré depuis votre note' : 'Generated from your note', + }, + overview: { lead, cards }, + sections, + } +} + +function injectDemo( + page: Record, + demo: unknown +): Record { + const sections = Array.isArray(page.sections) + ? ([...page.sections] as Record[]) + : [] + if (!sections.length) return page + const targetIdx = Math.min(1, sections.length - 1) + const target = { ...sections[targetIdx] } + const blocks = Array.isArray(target.blocks) + ? [...(target.blocks as Record[])] + : [] + blocks.push({ type: 'demo', demo, caption: 'Démo interactive' }) + target.blocks = blocks + sections[targetIdx] = target + return { ...page, sections } +} + +export type GenerateInteractivePageInput = { + content: string + lang?: string + /** Kept for API compatibility — page skeleton no longer depends on LLM. */ + provider: AIProvider +} + +export type GenerateInteractivePageResult = + | { ok: true; page: PageSpecV1; attempts: number } + | { + ok: false + issues?: PageValidationIssue[] + error?: string + reason?: string + raw?: string + attempts: number + } + +/** + * Instant reliable page: deterministic skeleton + deterministic Play/Step demo. + * No LLM round-trip for the page itself (LLM demos were timing out past client abort). + */ +export async function generateInteractivePageFromContent( + input: GenerateInteractivePageInput +): Promise { + const lang = input.lang || 'fr' + const assets = extractSourceAssets(input.content) + const plain = stripToPlain(input.content) + if (plain.split(/\s+/).filter(Boolean).length < 30) { + return { + ok: false, + error: 'unsuitable_content', + reason: 'Contenu trop court pour une page interactive', + attempts: 0, + } + } + + let pageObj = buildPageFromNote(input.content, lang, assets) + const demo = buildDeterministicDemo(input.content, lang, assets) + if (demo) { + pageObj = injectDemo(pageObj, demo) + } + + const normalized = normalizeInteractivePageCandidate(pageObj, lang) + if (!normalized) { + return { + ok: false, + error: 'normalize_failed', + reason: 'Impossible de normaliser la page', + attempts: 1, + } + } + + const result = validateInteractivePage(normalized) + if (!result.ok) { + // Last resort: page without demo + const sections = Array.isArray(normalized.sections) + ? (normalized.sections as Record[]).map((sec) => ({ + ...sec, + blocks: Array.isArray(sec.blocks) + ? (sec.blocks as Record[]).filter( + (b) => b.type !== 'demo' + ) + : [], + })) + : [] + const stripped = validateInteractivePage({ ...normalized, sections }) + if (stripped.ok) { + return { ok: true, page: stripped.page, attempts: 1 } + } + return { + ok: false, + issues: result.issues, + error: 'validation_failed', + reason: result.issues[0] + ? `${result.issues[0].path}: ${result.issues[0].message}` + : 'Page invalide', + attempts: 1, + } + } + + return { ok: true, page: result.page, attempts: 1 } +} diff --git a/memento-note/lib/ai/services/interactive-page-llm.service.ts b/memento-note/lib/ai/services/interactive-page-llm.service.ts new file mode 100644 index 0000000..578dc32 --- /dev/null +++ b/memento-note/lib/ai/services/interactive-page-llm.service.ts @@ -0,0 +1,547 @@ +/** + * LLM generation for interactive pages (spec §7) — split into short calls: + * 1. generatePagePlan → hero + overview + section list (one fast call) + * 2. generatePageSection → blocks of ONE section, incl. a content-matched demo + * + * Splitting keeps every LLM round-trip well under the client abort (~75 s), + * unlike the original single-shot page generation that timed out (~150 s). + * Validation failures are fed back verbatim (max 2 attempts per call). + */ + +import { generateText } from 'ai' +import { z } from 'zod' +import type { AIProvider } from '@/lib/ai/types' +import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response' +import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets' +import { + INTERACTIVE_DEMO_SCHEMA_VERSION, + PATTERN_IDS, + PANEL_TYPES, + INTENT_IDS, + SPEAK_WRITING_RULE, +} from '@/lib/interactive-demo' +import { + CALLOUT_KINDS, + INTERACTIVE_PAGE_CAPS, + validateInteractivePage, + normalizeInteractivePageCandidate, + type PageSection, + type PageValidationIssue, +} from '@/lib/interactive-page' +import { catalogForPrompt } from '@/lib/simulators' +import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json' + +const MAX_ATTEMPTS = 2 + +// ── Shared helpers ─────────────────────────────────────────────────────────── + +function stripToPlain(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() +} + +function extractJsonObject(raw: string): unknown | null { + if (!raw) return null + const cleaned = cleanAIJsonResponse(raw) + const tryParse = (s: string): unknown | null => { + try { + return JSON.parse(s) + } catch { + return null + } + } + const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1') + + let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned)) + if (parsed) return parsed + + const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i) + const candidate = fence?.[1]?.trim() ?? cleaned + parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate)) + if (parsed) return parsed + + const start = candidate.indexOf('{') + const end = candidate.lastIndexOf('}') + if (start >= 0 && end > start) { + const slice = candidate.slice(start, end + 1) + parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice)) + if (parsed) return parsed + } + return null +} + +function assetsBlock(assets: ReturnType): string { + let msg = '' + if (assets.formulas.length) { + msg += `\nFORMULES_EXTRAITES (réutilise telles quelles en $...$ — KaTeX inline):\n` + msg += assets.formulas + .slice(0, 14) + .map((f, i) => `${i + 1}. ${f}`) + .join('\n') + msg += '\n' + } + if (assets.keySentences.length) { + msg += `\nPHRASES_CLES:\n` + msg += assets.keySentences + .slice(0, 8) + .map((s) => `- ${s}`) + .join('\n') + msg += '\n' + } + if (assets.numbers.length) { + msg += `\nDONNEES_NUMERIQUES (source des blocs "stats"/"table" — ne pas inventer d'autres chiffres):\n${JSON.stringify(assets.numbers.slice(0, 10))}\n` + } + return msg +} + +// ── Golden demo examples (from the hand-crafted thermo fixture) ───────────── + +type FixtureDemoBlock = { type: string; demo: unknown } + +function fixtureDemo(panelType: string): string | null { + for (const section of thermoFixture.sections) { + for (const block of section.blocks as FixtureDemoBlock[]) { + if (block.type !== 'demo') continue + const demo = block.demo as { + scene?: { panels?: { type: string }[] } + } + const panels = demo?.scene?.panels ?? [] + if (panels.some((p) => p.type === panelType)) { + return JSON.stringify(block.demo) + } + } + } + return null +} + +// ── 1. Page plan ───────────────────────────────────────────────────────────── + +const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const +export type PagePlanDemoKind = (typeof DEMO_KINDS)[number] + +const planSectionSchema = z.object({ + title: z.string().min(1), + goal: z.string().min(1), + demoKind: z.enum(DEMO_KINDS), + demoGoal: z.string().optional(), +}) + +const pagePlanSchema = z.object({ + heroTitle: z.string().min(1), + heroSubtitle: z.string().optional(), + overviewLead: z.string().min(1), + overviewCards: z + .array( + z.object({ + badge: z.string().min(1), + title: z.string().min(1), + body: z.string().min(1), + intent: z.enum(INTENT_IDS).optional(), + }) + ) + .min(INTERACTIVE_PAGE_CAPS.minOverviewCards) + .max(INTERACTIVE_PAGE_CAPS.maxOverviewCards), + sections: z.array(planSectionSchema).min(2).max(5), +}) + +export type PagePlanSection = z.infer +export type PagePlan = z.infer + +export type GeneratePagePlanResult = + | { ok: true; plan: PagePlan; attempts: number } + | { ok: false; error: string; reason?: string; attempts: number } + +function buildPlanSystemPrompt(lang: string): string { + return `You plan an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer. +You output ONLY the PLAN as one JSON object — sections content is generated later, one call per section. + +OUTPUT: a single JSON object only. No markdown fences. No commentary. No blocks. + +SCHEMA: +{ + "heroTitle": string, // the SUBJECT of the note, never a generic title + "heroSubtitle": string, // one sentence, autoportant + "overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline) + "overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT) + "sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5 +} + +COUVERTURE (règle n°1): +- The page covers the CORE of the source: its 2–5 major ideas, one section each. +- NEVER build the page on the note's simplest example. +- First section = the problem / context; last section = synthesis / results. +- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead. + +MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps): +- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix" +- Loi / relation / courbe / évolution chiffrée → "chart" +- Processus / flux / cycle / architecture → "svg-scene" +- Avant/après, comparaison d'états → "svg-scene" (2 groupes de nœuds) et précise-le dans demoGoal +- Loi chiffrée avec paramètres manipulables (η = 1 − Tc/Th, COP, loi physique, modèle) → "simulation" (l'apprenant manipule des curseurs) +- Une démo seulement si elle ENSEIGNE mieux que le texte. Max 3 sections avec démo/simulation. + +RULES: +- Language of ALL strings = "${lang}" (the note's language). +- demoGoal: one sentence stating what the demo must show (used by the next LLM call). +- No colors anywhere. Intents only.` +} + +export async function generatePagePlan(input: { + content: string + lang?: string + provider: AIProvider +}): Promise { + const lang = input.lang || 'fr' + const assets = extractSourceAssets(input.content) + const plain = stripToPlain(input.content).slice(0, 6000) + const system = buildPlanSystemPrompt(lang) + const model = input.provider.getModel() + + let lastError = 'unknown' + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + let user = `Plan the interactive page for THIS note. + +EXCERPT_START +${plain} +EXCERPT_END +${assetsBlock(assets)}` + if (attempt > 1) { + user += `\nPREVIOUS_ANSWER_INVALID: ${lastError}\nReturn a corrected FULL JSON object matching the schema exactly.` + } + + const { text: raw } = await generateText({ + model, + system, + prompt: user, + temperature: attempt === 1 ? 0.3 : 0.1, + }) + + const parsed = extractJsonObject(raw) + if (!parsed) { + lastError = 'Model did not return parseable JSON' + console.warn('[interactive-page/plan] unparseable JSON', raw.slice(0, 300)) + continue + } + + const refusal = parsed as { error?: string; reason?: string } + if (refusal?.error === 'unsuitable_content') { + return { + ok: false, + error: 'unsuitable_content', + reason: refusal.reason || 'Contenu inadapté à une page interactive', + attempts: attempt, + } + } + + const result = pagePlanSchema.safeParse(parsed) + if (result.success) { + return { ok: true, plan: result.data, attempts: attempt } + } + lastError = result.error.issues + .slice(0, 6) + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join(' | ') + console.warn('[interactive-page/plan] schema failed', lastError) + } + + return { ok: false, error: 'plan_failed', reason: lastError, attempts: MAX_ATTEMPTS } +} + +// ── 2. Section generation ──────────────────────────────────────────────────── + +export type GeneratePageSectionInput = { + content: string + lang?: string + provider: AIProvider + pageTitle: string + sectionId: string + section: PagePlanSection +} + +export type GeneratePageSectionResult = + | { ok: true; section: PageSection; attempts: number } + | { ok: false; issues?: PageValidationIssue[]; error?: string; attempts: number } + +const SECTION_NARRATION_RULES = `NARRATION des démos (champ "speak"): +- 1–2 phrases, 25 mots max, une seule idée par étape, gras sur le concept clé, KaTeX inline ($...$) pour les formules. +- Voix off de prof, jamais de description mécanique. +- Jamais annoter ce qu'on pointTo dans la même étape. +- Chaque élément d'une scène est désigné au moins une fois dans l'acte. +- Scènes denses : svg-scene ≥ 5 nœuds ; heatmap avec valeurs réalistes et variées (intensité ∝ valeur, valeurs affichées). +- Étape finale d'acte : reveal ["*"] + pattern overview. +- Valeurs illustratives → "disclaimer" obligatoire.` + +function buildSectionSystemPrompt(lang: string, hasMath: boolean): string { + return `You generate ONE section of an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer. + +OUTPUT: a single JSON object only. No markdown fences. No commentary. No blocks. + +SCHEMA: +{ + "title": string, + "blocks": [ Block, ... ] // 2–6 blocks +} +Block types (discriminated by "type"): +- { "type": "prose", "md": string } // light markdown + $KaTeX$ inline +- { "type": "formula", "tex": string, "caption?": string } // KaTeX block for key relations +- { "type": "callout", "kind": ${JSON.stringify(CALLOUT_KINDS)}, "title": string, "md": string } +- { "type": "chart", "payload": { "chartType": "line"|"bar"|"area", "series": [{ "id": string, "label?": string, "values": number[], "intent?": IntentId }] }, "caption?": string } +- { "type": "stats", "items": [{ "value": string, "label": string, "intent?": IntentId }] } // 2–5, REAL figures from the note only +- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length +- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string } +- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders +IntentId: ${JSON.stringify(INTENT_IDS)} + +SimRef — TWO forms: +(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "", "title?": string, "preset?": { "": number }, "disclaimer?": string } + → You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation. +(B) GENERIC formula simulator: { "simId": "generic-formula", "title": string, "params": [{ "id", "symbol", "label", "min", "max", "step", "defaultValue", "unit?", "intent?" }] (1–4), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (1–6), "visual": { "kind": "gauges" } | { "kind": "bars" } | { "kind": "curve", "xParamId", "expr" }, "disclaimer?": string } + → expr = plain math over param ids: + - * / ^ % parentheses, functions sqrt abs exp ln log round min max, constants pi e. NO other identifiers. +SIMULATOR CATALOG (pick from this, else generic-formula): +${catalogForPrompt(lang)} + +InteractiveDemoV1 (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}): +{ + "schemaVersion": 1, "id": "demo.", "lang": "${lang}", "disclaimer?": string, + "scene": { "id": string, "panels": [Panel] }, + "acts": [ { "id": "a1", "title": string, "pattern?": Pattern, "steps": [Step] } ] +} +Panel types: ${PANEL_TYPES.join(', ')} (max 2 panels; heatmap cells ids: r{row}.c{col}, triangular:"lower" when appropriate) +Patterns: ${PATTERN_IDS.join(', ')} +Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?": string[], "reveal?": [{"ids": string[], "scope": "transient|act|scene"}], "annotate?": [...] } +svg-scene: nodes[{id,label?,intent?}] (labels may hold $KaTeX$), edges[{id,from,to,style?,weight?,intent?}] + +${SECTION_NARRATION_RULES} + +MÉCANIQUE (the validator rejects otherwise): +- Unique semantic ids; NO colors (hex/rgb) anywhere — intents only. +- Blocks per section ≤ ${INTERACTIVE_PAGE_CAPS.maxBlocksPerSection}. +- Every pointTo/reveal/annotate reference must be an id of the active scene. +- "stats"/"table" ONLY with figures actually present in the source. +- All human-facing strings in "${lang}". +RÈGLE D'OR — AUCUN VISUEL DÉCORATIF (checked after generation, violations are rejected): +- Every visual block must TEACH something the text alone cannot. Ask: "what does the learner understand after, that they didn't before?" No answer → no visual block. +- "heatmap-matrix" ONLY when the note contains genuinely matrix-shaped data (table of values, correlations, confusion matrix). Otherwise FORBIDDEN. +- "chart" ONLY with numeric series actually present in the source note. Otherwise FORBIDDEN. +- svg-scene: ≥ 5 nodes, dense, real vocabulary of the note — never 3 generic boxes. +- When in doubt: prose/formula/callout only. +${hasMath ? '- STEM: reuse formulas from FORMULES_EXTRAITES verbatim in formula blocks, prose and demo speak ($...$).' : ''}` +} + +function buildSectionUserPrompt( + input: GeneratePageSectionInput, + assets: ReturnType, + opts?: { issues?: PageValidationIssue[]; previousJson?: string } +): string { + const plain = stripToPlain(input.content).slice(0, 6000) + const { section } = input + let msg = `Generate the section "${section.title}" of the interactive page "${input.pageTitle}". + +SECTION_GOAL: ${section.goal} +${ + section.demoKind === 'simulation' + ? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation. +SIM_GOAL: ${section.demoGoal || section.goal}` + : section.demoKind !== 'none' + ? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}". +DEMO_GOAL: ${section.demoGoal || section.goal}` + : `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.` +} + +EXCERPT_START +${plain} +EXCERPT_END +${assetsBlock(assets)}` + + if (section.demoKind === 'heatmap-matrix' || section.demoKind === 'svg-scene') { + const golden = fixtureDemo(section.demoKind) + if (golden) { + msg += `\nEXEMPLE_D_OR (imite sa densité et sa qualité de narration — jamais moins ; adapte au contenu de CETTE note):\n${golden}\n` + } + } + + msg += `\nCONTRAINTES: section autoportante, fidèle au contenu réel de la note (jamais un exemple jouet). 2–6 blocks.` + + if (opts?.issues?.length) { + msg += `\n\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n` + msg += opts.issues + .slice(0, 12) + .map((i) => `- [${i.code}] ${i.path}: ${i.message}`) + .join('\n') + } + if (opts?.previousJson) { + msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 10000)}\nPREVIOUS_JSON_END` + } + return msg +} + +/** Wrap a section candidate in a minimal page so the shared validator runs (incl. demo delegation). */ +function validateSectionCandidate( + candidate: unknown, + sectionId: string, + lang: string +): { ok: true; section: PageSection } | { ok: false; issues: PageValidationIssue[] } { + const wrapped = { + schemaVersion: 1, + id: 'page.section-check', + lang, + hero: { kicker: 'CHECK', title: 'Section check' }, + sections: [ + typeof candidate === 'object' && candidate !== null + ? { id: sectionId, ...(candidate as Record) } + : candidate, + ], + } + const normalized = normalizeInteractivePageCandidate(wrapped, lang) + if (!normalized) { + return { + ok: false, + issues: [ + { code: 'normalize_failed', path: '', message: 'Section not normalizable' }, + ], + } + } + const result = validateInteractivePage(normalized) + if (!result.ok) return { ok: false, issues: result.issues } + const section = result.page.sections.find((s) => s.id === sectionId) + if (!section) { + return { + ok: false, + issues: [{ code: 'section_missing', path: 'sections', message: 'Section lost in normalization' }], + } + } + return { ok: true, section } +} + +/** + * Quality gate (règle d'or): reject decorative visuals AFTER schema validation — + * heatmap/chart whose values don't come from the note, svg-scene too sparse. + * Issues are fed back to the LLM for a repair attempt. + */ +function qualityGateSection( + section: PageSection, + assets: ReturnType +): PageValidationIssue[] { + const out: PageValidationIssue[] = [] + const sourceValues = assets.numbers.map((n) => n.value) + const valueInSource = (v: number) => + sourceValues.some((sv) => sv !== 0 && Math.abs(sv - v) / Math.max(1, Math.abs(sv)) < 0.06) + + for (const [bi, block] of section.blocks.entries()) { + const path = `blocks[${bi}]` + if (block.type === 'chart') { + const values = block.payload.series.flatMap((s) => s.values) + const grounded = values.filter(valueInSource).length + if (values.length > 0 && grounded / values.length < 0.5) { + out.push({ + code: 'decorative_data', + path, + message: + 'Chart values are not from the note (decorative data). Use real series from the source or remove the chart.', + }) + } + } + if (block.type === 'demo') { + for (const panel of block.demo.scene.panels) { + if (panel.type === 'heatmap-matrix') { + const values = panel.payload.values.flat() + const grounded = values.filter(valueInSource).length + if (values.length > 0 && grounded / values.length < 0.5) { + out.push({ + code: 'decorative_data', + path, + message: + 'Heatmap values are not from the note (decorative data). Only use heatmap-matrix for real matrix-shaped source data.', + }) + } + } + if (panel.type === 'svg-scene' && panel.payload.nodes.length < 5) { + out.push({ + code: 'scene_too_poor', + path, + message: + 'svg-scene has fewer than 5 nodes (too poor pedagogically). Densify: ≥5 nodes with real vocabulary and formulas from the note.', + }) + } + } + } + } + return out +} + +export async function generatePageSection( + input: GeneratePageSectionInput +): Promise { + const lang = input.lang || 'fr' + const assets = extractSourceAssets(input.content) + const system = buildSectionSystemPrompt( + lang, + assets.hasMath || assets.formulas.length > 0 + ) + const model = input.provider.getModel() + + let lastIssues: PageValidationIssue[] = [] + let lastRaw = '' + let lastNormalizedJson = '' + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const user = buildSectionUserPrompt(input, assets, { + issues: attempt > 1 ? lastIssues : undefined, + previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined, + }) + + const { text: raw } = await generateText({ + model, + system, + prompt: user, + temperature: attempt === 1 ? 0.3 : 0.1, + }) + lastRaw = raw + + const parsed = extractJsonObject(raw) + if (!parsed) { + lastIssues = [ + { + code: 'invalid_json', + path: '', + message: 'Model did not return parseable JSON', + }, + ] + console.warn( + `[interactive-page/section ${input.sectionId}] attempt ${attempt}: unparseable JSON`, + raw.slice(0, 300) + ) + continue + } + lastNormalizedJson = JSON.stringify(parsed) + + const result = validateSectionCandidate(parsed, input.sectionId, lang) + if (result.ok) { + const qualityIssues = qualityGateSection(result.section, assets) + if (!qualityIssues.length) { + return { ok: true, section: result.section, attempts: attempt } + } + lastIssues = qualityIssues + console.warn( + `[interactive-page/section ${input.sectionId}] attempt ${attempt}: quality gate`, + qualityIssues.slice(0, 6) + ) + continue + } + lastIssues = result.issues + console.warn( + `[interactive-page/section ${input.sectionId}] attempt ${attempt}: validation failed`, + result.issues.slice(0, 8) + ) + } + + return { ok: false, issues: lastIssues, attempts: MAX_ATTEMPTS } +} diff --git a/memento-note/lib/ai/services/publish-enhance.service.ts b/memento-note/lib/ai/services/publish-enhance.service.ts index d1369e2..855e4cd 100644 --- a/memento-note/lib/ai/services/publish-enhance.service.ts +++ b/memento-note/lib/ai/services/publish-enhance.service.ts @@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record = { magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`, brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`, essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`, + 'interactive-page': `Page immersive avec hero, sections et démos Play/Step (canal dédié — ne pas utiliser ici).`, } /* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */ diff --git a/memento-note/lib/credits.ts b/memento-note/lib/credits.ts index d724990..4e5ddc3 100644 --- a/memento-note/lib/credits.ts +++ b/memento-note/lib/credits.ts @@ -56,7 +56,9 @@ export const CREDIT_COSTS: Record = { ai_flashcard: 3, voice_transcribe: 1, excalidraw_generate: 4, - slide_generate: 7, // défaut si pas de slideCount (1+6) + slide_generate: 7, // défaut si pas de slideCount (1+N) + interactive_demo: 10, // génération initiale (brainstorm P12) + interactive_page: 20, // page immersive style Kimi } export function slideGenerateCreditCost(slideCount?: number | null): number { diff --git a/memento-note/lib/interactive-demo/constants.ts b/memento-note/lib/interactive-demo/constants.ts new file mode 100644 index 0000000..838d7ea --- /dev/null +++ b/memento-note/lib/interactive-demo/constants.ts @@ -0,0 +1,76 @@ +/** Interactive Demo schema v1 — caps & allowlists (brainstorm 2026-07-22). */ + +export const INTERACTIVE_DEMO_SCHEMA_VERSION = 1 as const + +export const INTERACTIVE_DEMO_CAPS = { + maxScenes: 5, + maxActs: 8, + maxStepsPerAct: 12, + maxAnnotationsPerStep: 5, + maxPanelsPerScene: 2, + maxJsonBytes: 64 * 1024, +} as const + +export const PATTERN_IDS = [ + 'progressiveReveal', + 'spotlightTour', + 'accumulate', + 'compareBeforeAfter', + 'chartBuild', + 'heatmapFill', + 'overview', + 'flowTrace', +] as const + +export const INTENT_IDS = [ + 'highlight', + 'flow', + 'cache', + 'compute', + 'output', + 'warning', +] as const + +export const SCOPE_IDS = ['transient', 'act', 'scene'] as const + +export const ANNOTATION_KINDS = ['circle', 'arrow', 'badge', 'callout'] as const + +export const PANEL_TYPES = ['svg-scene', 'chart', 'heatmap-matrix'] as const + +export const TRANSITIONS = ['fade', 'cut'] as const + +export const CHART_TYPES = ['line', 'bar', 'area'] as const + +/** + * Human / locale strings — may contain hex/rgb in prose; changeable by translateDemo. + * Must stay in sync between color-scan skip, translate strip, and docs. + */ +export const HUMAN_STRING_KEYS = [ + 'lang', + 'speak', + 'title', + 'text', + 'disclaimer', + 'label', + 'rowLabels', + 'colLabels', +] as const + +export type HumanStringKey = (typeof HUMAN_STRING_KEYS)[number] + +const HUMAN_STRING_KEY_SET = new Set(HUMAN_STRING_KEYS) + +export function isHumanStringKey(key: string): boolean { + return HUMAN_STRING_KEY_SET.has(key) +} + +/** Hex / rgb color literals forbidden in non-human JSON fields (P11). */ +export const FORBIDDEN_COLOR_RE = + /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|\brgba?\s*\(/i + +/** + * Future generation-prompt rule (not a validator cap): + * speak ≈ 1–2 sentences, ~25 words max — tempo is decided at writing time. + */ +export const SPEAK_WRITING_RULE = + 'speak ≈ 1–2 phrases, ~25 mots max — le tempo de la démo se décide à l’écriture, pas au rendu.' diff --git a/memento-note/lib/interactive-demo/fixtures/attnres.demo.json b/memento-note/lib/interactive-demo/fixtures/attnres.demo.json new file mode 100644 index 0000000..02d0197 --- /dev/null +++ b/memento-note/lib/interactive-demo/fixtures/attnres.demo.json @@ -0,0 +1,469 @@ +{ + "schemaVersion": 1, + "id": "demo.attnres", + "lang": "fr", + "disclaimer": "Poids d'enseignement — schéma illustratif, pas les mesures de la Figure 8 du papier.", + "scene": { + "id": "scene.problem", + "panels": [ + { + "id": "panel.trunk", + "type": "svg-scene", + "payload": { + "nodes": [ + { + "id": "residualTrunk", + "label": "Tronc résiduel h", + "intent": "flow" + }, + { + "id": "L1", + "label": "L1 · Attention", + "intent": "compute" + }, + { + "id": "L2", + "label": "L2 · MLP", + "intent": "cache" + }, + { + "id": "L3", + "label": "L3 · Attention", + "intent": "compute" + }, + { + "id": "L4", + "label": "L4 · MLP", + "intent": "cache" + }, + { + "id": "L5", + "label": "L5 · Attention", + "intent": "compute" + }, + { + "id": "L6", + "label": "L6 · MLP", + "intent": "cache" + }, + { + "id": "L7", + "label": "L7 · Attention", + "intent": "compute" + }, + { + "id": "L8", + "label": "L8 · MLP", + "intent": "cache" + } + ], + "edges": [ + { + "id": "e.L1.h", + "from": "L1", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L2.h", + "from": "L2", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L3.h", + "from": "L3", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L4.h", + "from": "L4", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L5.h", + "from": "L5", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L6.h", + "from": "L6", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L7.h", + "from": "L7", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + }, + { + "id": "e.L8.h", + "from": "L8", + "to": "residualTrunk", + "style": "solid", + "weight": 1, + "intent": "flow" + } + ] + } + }, + { + "id": "panel.magShare", + "type": "chart", + "payload": { + "chartType": "line", + "series": [ + { + "id": "series.magnitude", + "label": "‖h‖", + "values": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "intent": "compute" + }, + { + "id": "series.embedShare", + "label": "part embedding", + "values": [ + 0.5, + 0.33, + 0.25, + 0.2, + 0.17, + 0.14, + 0.12, + 0.11 + ], + "intent": "output" + } + ] + } + } + ] + }, + "acts": [ + { + "id": "a1", + "title": "Le problème — dilution en profondeur", + "pattern": "accumulate", + "steps": [ + { + "id": "a1.s1", + "speak": "Chaque couche entre dans le tronc résiduel avec un **coefficient fixe ×1** — aucune sélection.", + "pattern": "spotlightTour", + "pointTo": [ + "residualTrunk", + "L1", + "e.L1.h" + ], + "reveal": [ + { + "ids": [ + "residualTrunk", + "L1", + "e.L1.h" + ], + "scope": "act" + } + ] + }, + { + "id": "a1.s2", + "speak": "La **magnitude** croît avec la profondeur ; la part de l'embedding **dilue** $\\approx 1/(l+1)$.", + "pattern": "chartBuild", + "pointTo": [ + "series.magnitude", + "series.embedShare" + ], + "reveal": [ + { + "ids": [ + "series.magnitude" + ], + "scope": "act" + }, + { + "ids": [ + "series.embedShare" + ], + "scope": "act" + } + ] + } + ] + }, + { + "id": "a2", + "title": "Full Attention Residuals", + "pattern": "heatmapFill", + "transition": "fade", + "scene": { + "id": "scene.heatmap", + "panels": [ + { + "id": "panel.attnMatrix", + "type": "heatmap-matrix", + "payload": { + "rows": 8, + "cols": 8, + "triangular": "lower", + "rowLabels": [ + "L1", + "L2", + "L3", + "L4", + "L5", + "L6", + "L7", + "L8" + ], + "colLabels": [ + "h₁", + "f₁", + "f₂", + "f₃", + "f₄", + "f₅", + "f₆", + "f₇" + ], + "values": [ + [ + 1.0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0.3, + 0.7, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0.2, + 0.65, + 0.15, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0.25, + 0.15, + 0.45, + 0.15, + 0, + 0, + 0, + 0 + ], + [ + 0.1, + 0.1, + 0.15, + 0.6, + 0.05, + 0, + 0, + 0 + ], + [ + 0.1, + 0.08, + 0.12, + 0.2, + 0.45, + 0.05, + 0, + 0 + ], + [ + 0.18, + 0.07, + 0.1, + 0.12, + 0.18, + 0.3, + 0.05, + 0 + ], + [ + 0.12, + 0.06, + 0.08, + 0.1, + 0.14, + 0.2, + 0.25, + 0.05 + ] + ] + } + } + ] + }, + "steps": [ + { + "id": "a2.s1", + "speak": "Matrice **lower-triangular** : chaque ligne = une couche ; chaque ligne **somme à 1**.", + "pattern": "overview" + }, + { + "id": "a2.s2", + "speak": "On remplit progressivement : la couche 1 ne voit que **$h_1$**.", + "pattern": "heatmapFill", + "pointTo": [ + "r1.c1" + ], + "reveal": [ + { + "ids": [ + "r1.c1" + ], + "scope": "act" + } + ] + }, + { + "id": "a2.s3", + "speak": "**Dominance diagonale** — chaque couche favorise son prédécesseur immédiat.", + "pointTo": [ + "r3.c3", + "r4.c4" + ], + "reveal": [ + { + "ids": [ + "r2.c1", + "r2.c2", + "r3.c1", + "r3.c2", + "r3.c3" + ], + "scope": "act" + } + ], + "annotate": [ + { + "kind": "badge", + "targetIds": [ + "r2.c2" + ], + "scope": "act", + "intent": "highlight" + } + ] + }, + { + "id": "a2.s4", + "speak": "L'**embedding** garde un poids non trivial en profondeur.", + "annotate": [ + { + "kind": "circle", + "targetIds": [ + "r8.c1" + ], + "scope": "transient", + "intent": "highlight" + }, + { + "kind": "badge", + "targetIds": [ + "r8.c1" + ], + "scope": "act", + "intent": "highlight" + } + ] + }, + { + "id": "a2.s5", + "speak": "Certaines couches profondes **récupèrent** des sources très antérieures — skip connections apprises.", + "annotate": [ + { + "kind": "badge", + "targetIds": [ + "r7.c1" + ], + "scope": "act", + "intent": "highlight" + }, + { + "kind": "callout", + "targetIds": [ + "r8.c3" + ], + "scope": "transient", + "text": "récupération précoce", + "intent": "warning" + } + ] + }, + { + "id": "a2.s6", + "speak": "Pre-Attn et Pre-MLP se **spécialisent** différemment (patterns mesurés, ici illustratifs).", + "pattern": "overview", + "annotate": [ + { + "kind": "badge", + "targetIds": [ + "r5.c4" + ], + "scope": "scene", + "intent": "highlight" + } + ] + }, + { + "id": "a2.s7", + "speak": "Matrice assemblée — triangulaire inférieure ; **chaque ligne somme à 1**.", + "pattern": "overview", + "reveal": [ + { + "ids": [ + "*" + ], + "scope": "act" + } + ] + } + ] + } + ] +} diff --git a/memento-note/lib/interactive-demo/index.ts b/memento-note/lib/interactive-demo/index.ts new file mode 100644 index 0000000..d7c112a --- /dev/null +++ b/memento-note/lib/interactive-demo/index.ts @@ -0,0 +1,40 @@ +export { + INTERACTIVE_DEMO_CAPS, + INTERACTIVE_DEMO_SCHEMA_VERSION, + PATTERN_IDS, + INTENT_IDS, + SCOPE_IDS, + ANNOTATION_KINDS, + PANEL_TYPES, + CHART_TYPES, + HUMAN_STRING_KEYS, + SPEAK_WRITING_RULE, +} from './constants' +export { + SPEAK_WRITING_GUIDE, + intentColor, + dimOpacity, + spotlightColor, +} from './intent-colors' +export { interactiveDemoV1Schema } from './schema' +export { + validateInteractiveDemo, + assertTranslatePreservesStructure, + collectSceneElementIds, + heatmapCellIds, +} from './validate' +export { normalizeInteractiveDemoCandidate } from './normalize' +export { + resolveInteractiveDemo, + resolveActFinalState, +} from './resolve' +export type { + InteractiveDemoV1, + ValidationResult, + ValidationIssue, + DemoAct, + DemoStep, + DemoScene, + Panel, +} from './types' +export type { StepResolvedState, ActResolvedState, ResolvedAnnotation } from './resolve' diff --git a/memento-note/lib/interactive-demo/intent-colors.ts b/memento-note/lib/interactive-demo/intent-colors.ts new file mode 100644 index 0000000..534c42e --- /dev/null +++ b/memento-note/lib/interactive-demo/intent-colors.ts @@ -0,0 +1,62 @@ +import type { IntentId } from '@/lib/interactive-demo/types' + +/** + * Desaturated palette — one set for light, one for dark (P11). + * No free hex in demo JSON; app maps intents here. + */ +export const INTENT_PALETTE_LIGHT: Record = { + neutral: '#5c5c5c', + highlight: '#5a7a9a', // prune / slate-blue + flow: '#4a7d68', + cache: '#7a6a8a', + compute: '#8a6b4a', + output: '#4a7a8a', + warning: '#9a6548', +} + +export const INTENT_PALETTE_DARK: Record = { + neutral: '#a8a8a8', + highlight: '#8aabca', + flow: '#7ab89a', + cache: '#a898b8', + compute: '#c0a080', + output: '#7ab0c0', + warning: '#d09070', +} + +/** Light: ~35% readable on white; dark: ~20% as spec. */ +export const DIM_OPACITY_LIGHT = 0.35 +export const DIM_OPACITY_DARK = 0.2 + +export function intentColor( + intent: IntentId | null | undefined, + dark: boolean +): string { + const palette = dark ? INTENT_PALETTE_DARK : INTENT_PALETTE_LIGHT + if (!intent) return palette.neutral + return palette[intent] ?? palette.neutral +} + +export function spotlightColor(dark: boolean): string { + return intentColor('highlight', dark) +} + +export function dimOpacity(dark: boolean): number { + return dark ? DIM_OPACITY_DARK : DIM_OPACITY_LIGHT +} + +export const CIRCLED_NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'] as const + +export function badgeGlyph(index: number): string { + if (index >= 1 && index <= CIRCLED_NUMBERS.length) { + return CIRCLED_NUMBERS[index - 1]! + } + return String(index) +} + +/** Writing rule for generation prompts (not enforced in renderer). */ +export const SPEAK_WRITING_GUIDE = { + maxWordsApprox: 25, + sentences: '1–2', + note: 'Demo tempo is decided at writing time, not at render.', +} as const diff --git a/memento-note/lib/interactive-demo/normalize.ts b/memento-note/lib/interactive-demo/normalize.ts new file mode 100644 index 0000000..96c1919 --- /dev/null +++ b/memento-note/lib/interactive-demo/normalize.ts @@ -0,0 +1,561 @@ +/** + * Deterministic repairs for LLM-produced Interactive Demo JSON + * before Zod/semantic validation (common shape/id mistakes). + */ + +import { + ANNOTATION_KINDS, + CHART_TYPES, + FORBIDDEN_COLOR_RE, + INTENT_IDS, + PATTERN_IDS, + SCOPE_IDS, + TRANSITIONS, +} from './constants' + +const INTENT_SET = new Set(INTENT_IDS) +const PATTERN_SET = new Set(PATTERN_IDS) +const SCOPE_SET = new Set(SCOPE_IDS) +const ANN_KIND_SET = new Set(ANNOTATION_KINDS) +const CHART_TYPE_SET = new Set(CHART_TYPES) +const TRANSITION_SET = new Set(TRANSITIONS) + +const PANEL_TYPE_ALIASES: Record = { + svg: 'svg-scene', + svg_scene: 'svg-scene', + 'svg-scene': 'svg-scene', + graph: 'svg-scene', + diagram: 'svg-scene', + nodes: 'svg-scene', + chart: 'chart', + charts: 'chart', + line: 'chart', + bar: 'chart', + area: 'chart', + heatmap: 'heatmap-matrix', + heatmap_matrix: 'heatmap-matrix', + 'heatmap-matrix': 'heatmap-matrix', + matrix: 'heatmap-matrix', +} + +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : null +} + +function stripForbiddenColorsDeep(value: unknown): unknown { + if (typeof value === 'string') { + return FORBIDDEN_COLOR_RE.test(value) ? undefined : value + } + if (Array.isArray(value)) { + return value.map(stripForbiddenColorsDeep).filter((x) => x !== undefined) + } + if (value && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value)) { + // Keep human strings even if they mention a color in prose + if ( + k === 'speak' || + k === 'title' || + k === 'text' || + k === 'disclaimer' || + k === 'label' || + k === 'rowLabels' || + k === 'colLabels' || + k === 'lang' + ) { + out[k] = v + continue + } + const cleaned = stripForbiddenColorsDeep(v) + if (cleaned !== undefined) out[k] = cleaned + } + return out + } + return value +} + +function normalizeIntent(v: unknown): string | undefined { + if (typeof v !== 'string') return undefined + if (INTENT_SET.has(v)) return v + return 'highlight' +} + +function normalizePattern(v: unknown): string | undefined { + if (typeof v !== 'string') return undefined + if (PATTERN_SET.has(v)) return v + const camel = v.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase()) + if (PATTERN_SET.has(camel)) return camel + return 'spotlightTour' +} + +function normalizeScope(v: unknown): string { + if (typeof v === 'string' && SCOPE_SET.has(v)) return v + return 'act' +} + +function normalizePanel(panel: unknown, index: number): Record | null { + const p = asRecord(panel) + if (!p) return null + + const rawType = String(p.type ?? '') + const type = PANEL_TYPE_ALIASES[rawType] || PANEL_TYPE_ALIASES[rawType.toLowerCase()] + if (!type) return null + + const id = + typeof p.id === 'string' && p.id.trim() + ? p.id.trim() + : `panel.${index + 1}` + + let payload = asRecord(p.payload) ?? {} + + if (type === 'svg-scene') { + let nodes = Array.isArray(payload.nodes) ? payload.nodes : [] + if (nodes.length === 0 && Array.isArray(p.nodes)) nodes = p.nodes + nodes = nodes + .map((n, ni) => { + const node = asRecord(n) + if (!node) return null + const nid = + typeof node.id === 'string' && node.id.trim() + ? node.id.trim() + : `n${ni + 1}` + return { + id: nid, + ...(typeof node.label === 'string' ? { label: node.label } : {}), + ...(normalizeIntent(node.intent) + ? { intent: normalizeIntent(node.intent) } + : {}), + } + }) + .filter(Boolean) + + if (nodes.length === 0) { + nodes = [{ id: 'n1', label: 'Concept', intent: 'highlight' }] + } + + const nodeIds = new Set( + (nodes as { id: string }[]).map((n) => n.id) + ) + let edges = Array.isArray(payload.edges) ? payload.edges : [] + edges = edges + .map((e, ei) => { + const edge = asRecord(e) + if (!edge) return null + const from = String(edge.from ?? '') + const to = String(edge.to ?? '') + if (!nodeIds.has(from) || !nodeIds.has(to)) return null + return { + id: + typeof edge.id === 'string' && edge.id.trim() + ? edge.id.trim() + : `e${ei + 1}`, + from, + to, + ...(edge.style === 'dashed' || edge.style === 'solid' + ? { style: edge.style } + : {}), + ...(typeof edge.weight === 'number' ? { weight: edge.weight } : {}), + ...(normalizeIntent(edge.intent) + ? { intent: normalizeIntent(edge.intent) } + : {}), + } + }) + .filter(Boolean) + + payload = { nodes, ...(edges.length ? { edges } : {}) } + } else if (type === 'chart') { + let series = Array.isArray(payload.series) ? payload.series : [] + series = series + .map((s, si) => { + const ser = asRecord(s) + if (!ser) return null + const values = Array.isArray(ser.values) + ? ser.values.map((n) => Number(n)).filter((n) => Number.isFinite(n)) + : [] + if (values.length === 0) return null + return { + id: + typeof ser.id === 'string' && ser.id.trim() + ? ser.id.trim() + : `s${si + 1}`, + ...(typeof ser.label === 'string' ? { label: ser.label } : {}), + values, + ...(normalizeIntent(ser.intent) + ? { intent: normalizeIntent(ser.intent) } + : {}), + } + }) + .filter(Boolean) + if (series.length === 0) { + series = [{ id: 's1', values: [1, 2, 3], intent: 'highlight' }] + } + const chartType = + typeof payload.chartType === 'string' && + CHART_TYPE_SET.has(payload.chartType) + ? payload.chartType + : 'bar' + payload = { chartType, series } + } else if (type === 'heatmap-matrix') { + let rows = Number(payload.rows) + let cols = Number(payload.cols) + let values = Array.isArray(payload.values) ? payload.values : [] + if (!Number.isFinite(rows) || rows < 1) rows = values.length || 3 + if (!Number.isFinite(cols) || cols < 1) { + cols = Array.isArray(values[0]) ? (values[0] as unknown[]).length : 3 + } + // Pad / trim to declared shape + const matrix: number[][] = [] + for (let r = 0; r < rows; r++) { + const row = Array.isArray(values[r]) ? (values[r] as unknown[]) : [] + const outRow: number[] = [] + for (let c = 0; c < cols; c++) { + const n = Number(row[c]) + outRow.push(Number.isFinite(n) ? n : 0) + } + matrix.push(outRow) + } + const triangular = + payload.triangular === 'lower' || + payload.triangular === 'upper' || + payload.triangular === 'none' + ? payload.triangular + : undefined + payload = { + rows, + cols, + values: matrix, + ...(triangular ? { triangular } : {}), + ...(Array.isArray(payload.rowLabels) + ? { rowLabels: payload.rowLabels.map(String).slice(0, rows) } + : {}), + ...(Array.isArray(payload.colLabels) + ? { colLabels: payload.colLabels.map(String).slice(0, cols) } + : {}), + } + } + + return { id, type, payload } +} + +function normalizeScene(scene: unknown): Record { + const s = asRecord(scene) ?? {} + const rawPanels = Array.isArray(s.panels) ? s.panels : [] + const panels = rawPanels + .map((p, i) => normalizePanel(p, i)) + .filter(Boolean) + .slice(0, 2) as Record[] + + if (panels.length === 0) { + panels.push({ + id: 'panel.main', + type: 'svg-scene', + payload: { + nodes: [ + { id: 'concept', label: 'Idée', intent: 'highlight' }, + { id: 'detail', label: 'Détail', intent: 'compute' }, + ], + edges: [ + { + id: 'e1', + from: 'concept', + to: 'detail', + style: 'solid', + intent: 'flow', + }, + ], + }, + }) + } + + return { + ...(typeof s.id === 'string' && s.id.trim() ? { id: s.id.trim() } : {}), + panels, + } +} + +function normalizeStep( + step: unknown, + actId: string, + stepIndex: number +): Record | null { + const st = asRecord(step) + if (!st) return null + + const speak = + typeof st.speak === 'string' && st.speak.trim() + ? st.speak.trim() + : 'Regardons cet élément.' + + const id = `${actId}.s${stepIndex + 1}` + + const pointTo = Array.isArray(st.pointTo) + ? st.pointTo.map(String).filter(Boolean) + : undefined + + const reveal = Array.isArray(st.reveal) + ? st.reveal + .map((r) => { + const rev = asRecord(r) + if (!rev || !Array.isArray(rev.ids) || rev.ids.length === 0) return null + return { + ids: rev.ids.map(String).filter(Boolean), + scope: normalizeScope(rev.scope), + } + }) + .filter(Boolean) + : undefined + + let annotate = Array.isArray(st.annotate) + ? st.annotate + .map((a) => { + const ann = asRecord(a) + if (!ann || !Array.isArray(ann.targetIds) || ann.targetIds.length === 0) + return null + const kind = + typeof ann.kind === 'string' && ANN_KIND_SET.has(ann.kind) + ? ann.kind + : 'callout' + return { + kind, + targetIds: ann.targetIds.map(String).filter(Boolean), + scope: normalizeScope(ann.scope), + ...(typeof ann.text === 'string' ? { text: ann.text } : {}), + ...(normalizeIntent(ann.intent) + ? { intent: normalizeIntent(ann.intent) } + : {}), + } + }) + .filter(Boolean) + : undefined + + // Drop annotate targets that also appear in pointTo (semantic hard reject) + if (annotate && pointTo?.length) { + const pt = new Set(pointTo) + const filtered = annotate + .map((a) => { + if (!a) return null + const ann = a as { + targetIds: string[] + kind: string + scope: string + text?: string + intent?: string + } + const targetIds = ann.targetIds.filter((id) => !pt.has(id)) + if (targetIds.length === 0) return null + return { ...ann, targetIds } + }) + .filter(Boolean) as NonNullable<(typeof annotate)[number]>[] + annotate = filtered.length ? filtered : undefined + } + + return { + id, + speak, + ...(st.pattern !== undefined + ? { pattern: normalizePattern(st.pattern) } + : {}), + ...(pointTo?.length ? { pointTo } : {}), + ...(reveal?.length ? { reveal } : {}), + ...(annotate?.length ? { annotate } : {}), + } +} + +function collectElementIdsFromScene(scene: Record): { + ids: Set + wildcardAllowed: boolean + firstId?: string +} { + const ids = new Set() + let wildcardAllowed = false + const panels = Array.isArray(scene.panels) ? scene.panels : [] + for (const panel of panels) { + const p = asRecord(panel) + if (!p) continue + const payload = asRecord(p.payload) ?? {} + if (p.type === 'svg-scene') { + for (const n of Array.isArray(payload.nodes) ? payload.nodes : []) { + const node = asRecord(n) + if (node && typeof node.id === 'string') ids.add(node.id) + } + for (const e of Array.isArray(payload.edges) ? payload.edges : []) { + const edge = asRecord(e) + if (edge && typeof edge.id === 'string') ids.add(edge.id) + } + } else if (p.type === 'chart') { + wildcardAllowed = true + for (const s of Array.isArray(payload.series) ? payload.series : []) { + const ser = asRecord(s) + if (ser && typeof ser.id === 'string') ids.add(ser.id) + } + } else if (p.type === 'heatmap-matrix') { + wildcardAllowed = true + const rows = Number(payload.rows) || 0 + const cols = Number(payload.cols) || 0 + const triangular = payload.triangular + 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 + ids.add(`r${r}.c${c}`) + } + } + } + } + return { ids, wildcardAllowed, firstId: ids.values().next().value } +} + +function filterStepRefs( + step: Record, + elementIds: Set, + wildcardAllowed: boolean +): Record { + const filterId = (id: string) => + id === '*' ? wildcardAllowed : elementIds.has(id) + + if (Array.isArray(step.pointTo)) { + const pointTo = (step.pointTo as string[]).filter((id) => elementIds.has(id)) + if (pointTo.length) step.pointTo = pointTo + else delete step.pointTo + } + + if (Array.isArray(step.reveal)) { + const reveal = (step.reveal as { ids: string[]; scope: string }[]) + .map((r) => ({ + ...r, + ids: r.ids.filter(filterId), + })) + .filter((r) => r.ids.length > 0) + if (reveal.length) step.reveal = reveal + else delete step.reveal + } + + if (Array.isArray(step.annotate)) { + const annotate = ( + step.annotate as { targetIds: string[]; [k: string]: unknown }[] + ) + .map((a) => ({ + ...a, + targetIds: a.targetIds.filter((id) => elementIds.has(id)), + })) + .filter((a) => a.targetIds.length > 0) + if (annotate.length) step.annotate = annotate + else delete step.annotate + } + + return step +} + +function normalizeAct( + act: unknown, + actIndex: number, + defaultScene: Record +): Record | null { + const a = asRecord(act) + if (!a) return null + const actId = `a${actIndex + 1}` + const title = + typeof a.title === 'string' && a.title.trim() + ? a.title.trim() + : `Acte ${actIndex + 1}` + + const scene = a.scene ? normalizeScene(a.scene) : undefined + const activeScene = scene ?? defaultScene + const { ids, wildcardAllowed, firstId } = + collectElementIdsFromScene(activeScene) + + const rawSteps = Array.isArray(a.steps) ? a.steps : [] + let steps = rawSteps + .map((s, si) => normalizeStep(s, actId, si)) + .filter(Boolean) + .slice(0, 12) as Record[] + + steps = steps.map((s) => filterStepRefs(s, ids, wildcardAllowed)) + + if (steps.length === 0) { + steps.push({ + id: `${actId}.s1`, + speak: 'Voici le point clé.', + pattern: 'overview', + ...(firstId ? { pointTo: [firstId] } : {}), + }) + } + + return { + id: actId, + title, + ...(a.pattern !== undefined ? { pattern: normalizePattern(a.pattern) } : {}), + ...(typeof a.transition === 'string' && TRANSITION_SET.has(a.transition) + ? { transition: a.transition } + : {}), + ...(scene ? { scene } : {}), + steps, + } +} + +/** + * Best-effort shape fix so Zod + semantic validate can succeed on near-valid LLM output. + */ +export function normalizeInteractiveDemoCandidate( + input: unknown, + lang = 'fr' +): unknown { + const root = asRecord(input) + if (!root) return input + + let demo = stripForbiddenColorsDeep(root) as Record + demo = asRecord(demo) ?? root + + const safeLang = + typeof lang === 'string' && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang) + ? lang + : 'fr' + + demo.schemaVersion = 1 + demo.lang = + typeof demo.lang === 'string' && + /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(demo.lang) + ? demo.lang + : safeLang + + if (typeof demo.id !== 'string' || !demo.id.trim()) { + demo.id = 'demo.generated' + } + + if (typeof demo.disclaimer !== 'string') { + delete demo.disclaimer + } + + const scene = normalizeScene(demo.scene) + demo.scene = scene + const { firstId } = collectElementIdsFromScene(scene) + + const rawActs = Array.isArray(demo.acts) ? demo.acts : [] + let acts = rawActs + .map((a, i) => normalizeAct(a, i, scene)) + .filter(Boolean) + .slice(0, 8) as Record[] + + if (acts.length === 0) { + acts = [ + { + id: 'a1', + title: 'Introduction', + pattern: 'spotlightTour', + steps: [ + { + id: 'a1.s1', + speak: 'Voici le point clé.', + pattern: 'overview', + ...(firstId ? { pointTo: [firstId] } : {}), + }, + ], + }, + ] + } + + demo.acts = acts + return demo +} diff --git a/memento-note/lib/interactive-demo/resolve.ts b/memento-note/lib/interactive-demo/resolve.ts new file mode 100644 index 0000000..43e6212 --- /dev/null +++ b/memento-note/lib/interactive-demo/resolve.ts @@ -0,0 +1,227 @@ +import { collectSceneElementIds } from './validate' +import type { + Annotation, + DemoAct, + DemoScene, + DemoStep, + InteractiveDemoV1, + ScopeId, +} from './types' + +const SCOPE_RANK: Record = { + transient: 1, + act: 2, + scene: 3, +} + +function longestScope(a: ScopeId, b: ScopeId): ScopeId { + return SCOPE_RANK[a] >= SCOPE_RANK[b] ? a : b +} + +export type ResolvedAnnotation = Annotation & { badgeIndex?: number } + +export type StepResolvedState = { + actId: string + stepId: string + stepIndex: number + speak: string + /** Elements revealed and their effective scope */ + revealed: Record + /** Spotlight targets this step (empty ⇒ overview / full brightness) */ + spotlight: string[] + /** Active annotations after applying this step (transient of prior steps purged) */ + annotations: ResolvedAnnotation[] + overview: boolean +} + +export type ActResolvedState = { + actId: string + title: string + steps: StepResolvedState[] + /** State after the last step — static/SSR/print/export default */ + final: StepResolvedState +} + +function purgeScope( + revealed: Map, + annotations: ResolvedAnnotation[], + scopes: ScopeId[] +): { revealed: Map; annotations: ResolvedAnnotation[] } { + const drop = new Set(scopes) + const nextRevealed = new Map() + for (const [id, scope] of revealed) { + if (!drop.has(scope)) nextRevealed.set(id, scope) + } + const nextAnn = annotations.filter((a) => !drop.has(a.scope)) + return { revealed: nextRevealed, annotations: nextAnn } +} + +function mergeReveal( + revealed: Map, + id: string, + scope: ScopeId +): void { + const prev = revealed.get(id) + revealed.set(id, prev ? longestScope(prev, scope) : scope) +} + +function applyStep( + step: DemoStep, + elementIds: Set, + revealed: Map, + annotations: ResolvedAnnotation[], + badgeCounter: { n: number } +): { + revealed: Map + annotations: ResolvedAnnotation[] + spotlight: string[] + overview: boolean +} { + // Drop previous step's transient + ;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient'])) + + const spotlightSet = new Set() + + const revealId = (id: string, scope: ScopeId) => { + if (id === '*') { + for (const eid of elementIds) { + if (!revealed.has(eid)) mergeReveal(revealed, eid, scope) + } + return + } + mergeReveal(revealed, id, scope) + } + + for (const rev of step.reveal ?? []) { + for (const id of rev.ids) { + revealId(id, rev.scope) + if (id !== '*') spotlightSet.add(id) + } + } + + // Designation ⇒ reveal (pointTo default act) + for (const id of step.pointTo ?? []) { + revealId(id, 'act') + spotlightSet.add(id) + } + + for (const ann of step.annotate ?? []) { + for (const id of ann.targetIds) { + revealId(id, ann.scope) + spotlightSet.add(id) + } + const resolved: ResolvedAnnotation = { ...ann } + if (ann.kind === 'badge') { + badgeCounter.n += 1 + resolved.badgeIndex = badgeCounter.n + } + annotations.push(resolved) + } + + const overview = spotlightSet.size === 0 + return { + revealed, + annotations, + spotlight: [...spotlightSet], + overview, + } +} + +function resolveAct( + act: DemoAct, + scene: DemoScene, + sceneChanged: boolean, + carried: { + revealed: Map + annotations: ResolvedAnnotation[] + } +): ActResolvedState { + let { revealed, annotations } = carried + + if (sceneChanged) { + // New scene: purge everything from old scene (refs would be dead) + revealed = new Map() + annotations = [] + } else { + // Soft reset: purge transient + act, keep scene-scoped + ;({ revealed, annotations } = purgeScope(revealed, annotations, [ + 'transient', + 'act', + ])) + } + + const { ids: elementIds } = collectSceneElementIds(scene) + const badgeCounter = { n: 0 } + const steps: StepResolvedState[] = [] + + for (const [stepIndex, step] of act.steps.entries()) { + const applied = applyStep( + step, + elementIds, + revealed, + annotations, + badgeCounter + ) + revealed = applied.revealed + annotations = applied.annotations + + const snapshot: StepResolvedState = { + actId: act.id, + stepId: step.id, + stepIndex, + speak: step.speak, + revealed: Object.fromEntries(revealed), + spotlight: applied.spotlight, + annotations: annotations.map((a) => ({ ...a })), + overview: applied.overview, + } + steps.push(snapshot) + } + + const last = steps[steps.length - 1]! + return { + actId: act.id, + title: act.title, + steps, + final: last, + } +} + +/** + * Pure resolver: auto-reveal, wildcard, longest-scope-wins, spotlight. + * Shared by player / SSR / noscript / print / export — no React. + */ +export function resolveInteractiveDemo(demo: InteractiveDemoV1): { + acts: ActResolvedState[] +} { + let scene = demo.scene + let revealed = new Map() + let annotations: ResolvedAnnotation[] = [] + const acts: ActResolvedState[] = [] + + for (const act of demo.acts) { + const sceneChanged = Boolean(act.scene) + if (act.scene) scene = act.scene + + const resolved = resolveAct(act, scene, sceneChanged, { + revealed, + annotations, + }) + acts.push(resolved) + + // Carry state into next act (may be purged on soft reset / scene change) + const last = resolved.final + revealed = new Map(Object.entries(last.revealed) as [string, ScopeId][]) + annotations = last.annotations.map((a) => ({ ...a })) + } + + return { acts } +} + +/** Convenience: final static state for a given act (P10 default). */ +export function resolveActFinalState( + demo: InteractiveDemoV1, + actId: string +): StepResolvedState | undefined { + return resolveInteractiveDemo(demo).acts.find((a) => a.actId === actId)?.final +} diff --git a/memento-note/lib/interactive-demo/schema.ts b/memento-note/lib/interactive-demo/schema.ts new file mode 100644 index 0000000..893ab5b --- /dev/null +++ b/memento-note/lib/interactive-demo/schema.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { + ANNOTATION_KINDS, + CHART_TYPES, + INTENT_IDS, + INTERACTIVE_DEMO_CAPS, + INTERACTIVE_DEMO_SCHEMA_VERSION, + PANEL_TYPES, + PATTERN_IDS, + SCOPE_IDS, + TRANSITIONS, +} from './constants' + +const intentSchema = z.enum(INTENT_IDS).optional() +const patternSchema = z.enum(PATTERN_IDS) +const scopeSchema = z.enum(SCOPE_IDS) + +const revealSchema = z.object({ + ids: z.array(z.string().min(1)).min(1), + scope: scopeSchema, +}) + +const annotationSchema = z.object({ + kind: z.enum(ANNOTATION_KINDS), + targetIds: z.array(z.string().min(1)).min(1), + scope: scopeSchema, + text: z.string().optional(), + intent: intentSchema, +}) + +const stepSchema = z.object({ + id: z.string().min(1), + speak: z.string().min(1), + pattern: patternSchema.optional(), + pointTo: z.array(z.string().min(1)).optional(), + reveal: z.array(revealSchema).optional(), + annotate: z + .array(annotationSchema) + .max(INTERACTIVE_DEMO_CAPS.maxAnnotationsPerStep) + .optional(), +}) + +const svgNodeSchema = z.object({ + id: z.string().min(1), + label: z.string().optional(), + intent: intentSchema, +}) + +const svgEdgeSchema = z.object({ + id: z.string().min(1), + from: z.string().min(1), + to: z.string().min(1), + style: z.enum(['solid', 'dashed']).optional(), + weight: z.number().optional(), + intent: intentSchema, +}) + +const svgScenePayloadSchema = z.object({ + nodes: z.array(svgNodeSchema).min(1), + edges: z.array(svgEdgeSchema).optional(), +}) + +const chartSeriesSchema = z.object({ + id: z.string().min(1), + label: z.string().optional(), + values: z.array(z.number()), + intent: intentSchema, +}) + +const chartPayloadSchema = z.object({ + chartType: z.enum(CHART_TYPES), + series: z.array(chartSeriesSchema).min(1), +}) + +const heatmapPayloadSchema = z.object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), + values: z.array(z.array(z.number())), + triangular: z.enum(['lower', 'upper', 'none']).optional(), + rowLabels: z.array(z.string()).optional(), + colLabels: z.array(z.string()).optional(), +}) + +const panelSchema = z.discriminatedUnion('type', [ + z.object({ + id: z.string().min(1), + type: z.literal('svg-scene'), + payload: svgScenePayloadSchema, + }), + z.object({ + id: z.string().min(1), + type: z.literal('chart'), + payload: chartPayloadSchema, + }), + z.object({ + id: z.string().min(1), + type: z.literal('heatmap-matrix'), + payload: heatmapPayloadSchema, + }), +]) + +const sceneSchema = z.object({ + id: z.string().min(1).optional(), + panels: z + .array(panelSchema) + .min(1) + .max(INTERACTIVE_DEMO_CAPS.maxPanelsPerScene), +}) + +const actSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + scene: sceneSchema.optional(), + transition: z.enum(TRANSITIONS).optional(), + pattern: patternSchema.optional(), + steps: z.array(stepSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxStepsPerAct), +}) + +/** Loose BCP-47: primary tag + optional subtags (fr, en, zh-Hans, pt-BR). */ +const langSchema = z + .string() + .regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag') + +export const interactiveDemoV1Schema = z.object({ + schemaVersion: z.literal(INTERACTIVE_DEMO_SCHEMA_VERSION), + id: z.string().min(1), + lang: langSchema, + disclaimer: z.string().optional(), + scene: sceneSchema, + acts: z.array(actSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxActs), +}) + +export type InteractiveDemoV1Parsed = z.infer + +/** Re-export allowlists for callers that need them at runtime. */ +export { PANEL_TYPES, PATTERN_IDS } diff --git a/memento-note/lib/interactive-demo/types.ts b/memento-note/lib/interactive-demo/types.ts new file mode 100644 index 0000000..908d9fd --- /dev/null +++ b/memento-note/lib/interactive-demo/types.ts @@ -0,0 +1,116 @@ +import type { + ANNOTATION_KINDS, + INTENT_IDS, + PANEL_TYPES, + PATTERN_IDS, + SCOPE_IDS, + TRANSITIONS, +} from './constants' + +export type PatternId = (typeof PATTERN_IDS)[number] +export type IntentId = (typeof INTENT_IDS)[number] +export type ScopeId = (typeof SCOPE_IDS)[number] +export type AnnotationKind = (typeof ANNOTATION_KINDS)[number] +export type PanelType = (typeof PANEL_TYPES)[number] +export type TransitionId = (typeof TRANSITIONS)[number] + +export type RevealSpec = { + ids: string[] + scope: ScopeId +} + +export type Annotation = { + kind: AnnotationKind + targetIds: string[] + scope: ScopeId + text?: string + intent?: IntentId +} + +export type DemoStep = { + id: string + speak: string + pattern?: PatternId + pointTo?: string[] + reveal?: RevealSpec[] + annotate?: Annotation[] +} + +export type SvgNode = { + id: string + label?: string + intent?: IntentId +} + +export type SvgEdge = { + id: string + from: string + to: string + style?: 'solid' | 'dashed' + weight?: number + intent?: IntentId +} + +export type SvgScenePayload = { + nodes: SvgNode[] + edges?: SvgEdge[] +} + +export type ChartSeries = { + id: string + label?: string + values: number[] + intent?: IntentId +} + +export type ChartPayload = { + chartType: 'line' | 'bar' | 'area' + series: ChartSeries[] +} + +export type HeatmapPayload = { + rows: number + cols: number + values: number[][] + triangular?: 'lower' | 'upper' | 'none' + rowLabels?: string[] + colLabels?: string[] +} + +export type Panel = + | { id: string; type: 'svg-scene'; payload: SvgScenePayload } + | { id: string; type: 'chart'; payload: ChartPayload } + | { id: string; type: 'heatmap-matrix'; payload: HeatmapPayload } + +export type DemoScene = { + id?: string + panels: Panel[] +} + +export type DemoAct = { + id: string + title: string + scene?: DemoScene + transition?: TransitionId + pattern?: PatternId + steps: DemoStep[] +} + +export type InteractiveDemoV1 = { + schemaVersion: 1 + id: string + lang: string + disclaimer?: string + scene: DemoScene + acts: DemoAct[] +} + +export type ValidationIssue = { + code: string + path: string + message: string +} + +export type ValidationResult = + | { ok: true; demo: InteractiveDemoV1 } + | { ok: false; issues: ValidationIssue[] } diff --git a/memento-note/lib/interactive-demo/validate.ts b/memento-note/lib/interactive-demo/validate.ts new file mode 100644 index 0000000..7a9e109 --- /dev/null +++ b/memento-note/lib/interactive-demo/validate.ts @@ -0,0 +1,452 @@ +import { + FORBIDDEN_COLOR_RE, + INTERACTIVE_DEMO_CAPS, + isHumanStringKey, +} from './constants' +import { interactiveDemoV1Schema } from './schema' +import type { + DemoAct, + DemoScene, + InteractiveDemoV1, + Panel, + ValidationIssue, + ValidationResult, +} from './types' + +function issue(code: string, path: string, message: string): ValidationIssue { + return { code, path, message } +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** Heatmap cell ids — respect triangular mode (lower → 36 cells for 8×8). */ +export function heatmapCellIds( + rows: number, + cols: number, + triangular?: 'lower' | 'upper' | 'none' +): string[] { + const ids: string[] = [] + 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 + ids.push(`r${r}.c${c}`) + } + } + return ids +} + +/** Collect every addressable element id in a scene (nodes, edges, series, heatmap cells). */ +export function collectSceneElementIds(scene: DemoScene): { + ids: Set + wildcardAllowed: boolean +} { + const ids = new Set() + let wildcardAllowed = false + + for (const panel of scene.panels) { + if (panel.type === 'svg-scene') { + for (const n of panel.payload.nodes) ids.add(n.id) + for (const e of panel.payload.edges ?? []) { + ids.add(e.id) + } + } else if (panel.type === 'chart') { + wildcardAllowed = true + for (const s of panel.payload.series) ids.add(s.id) + } else if (panel.type === 'heatmap-matrix') { + wildcardAllowed = true + const { rows, cols, triangular } = panel.payload + for (const id of heatmapCellIds(rows, cols, triangular)) { + ids.add(id) + } + } + } + + return { ids, wildcardAllowed } +} + +function countDeclaredScenes(demo: InteractiveDemoV1): number { + let n = 1 // demo.scene + for (const act of demo.acts) { + if (act.scene) n += 1 + } + return n +} + +/** + * Scan for free color literals — skip human/translatable string fields + * so prose like « couleur #FF0000 » does not hard-reject. + */ +function scanForbiddenColors( + value: unknown, + path: string, + out: ValidationIssue[] +): void { + if (typeof value === 'string') { + if (FORBIDDEN_COLOR_RE.test(value)) { + out.push( + issue( + 'forbidden_color', + path, + 'Free color literals (hex/rgb) are forbidden — use intent enums' + ) + ) + } + return + } + if (Array.isArray(value)) { + value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out)) + return + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + if (isHumanStringKey(k)) continue + scanForbiddenColors(v, path ? `${path}.${k}` : k, out) + } + } +} + +function validateSvgEdges(panel: Panel, path: string, out: ValidationIssue[]): void { + if (panel.type !== 'svg-scene') return + const nodeIds = new Set(panel.payload.nodes.map((n) => n.id)) + for (const [i, edge] of (panel.payload.edges ?? []).entries()) { + if (!nodeIds.has(edge.from)) { + out.push( + issue( + 'unknown_edge_endpoint', + `${path}.edges[${i}].from`, + `Edge from "${edge.from}" is not a node id in this panel` + ) + ) + } + if (!nodeIds.has(edge.to)) { + out.push( + issue( + 'unknown_edge_endpoint', + `${path}.edges[${i}].to`, + `Edge to "${edge.to}" is not a node id in this panel` + ) + ) + } + } +} + +function validateHeatmapShape(panel: Panel, path: string, out: ValidationIssue[]): void { + if (panel.type !== 'heatmap-matrix') return + const { rows, cols, values, rowLabels, colLabels } = panel.payload + if (values.length !== rows) { + out.push( + issue( + 'heatmap_shape', + `${path}.values`, + `Expected ${rows} rows, got ${values.length}` + ) + ) + } + for (const [ri, row] of values.entries()) { + if (row.length !== cols) { + out.push( + issue( + 'heatmap_shape', + `${path}.values[${ri}]`, + `Expected ${cols} cols, got ${row.length}` + ) + ) + } + } + if (rowLabels && rowLabels.length !== rows) { + out.push( + issue( + 'heatmap_labels', + `${path}.rowLabels`, + `rowLabels length must equal rows (${rows})` + ) + ) + } + if (colLabels && colLabels.length !== cols) { + out.push( + issue( + 'heatmap_labels', + `${path}.colLabels`, + `colLabels length must equal cols (${cols})` + ) + ) + } +} + +function validateScene( + scene: DemoScene, + path: string, + out: ValidationIssue[] +): Set { + const panelIds = new Set() + for (const [pi, panel] of scene.panels.entries()) { + const pPath = `${path}.panels[${pi}]` + if (panelIds.has(panel.id)) { + out.push(issue('duplicate_panel_id', `${pPath}.id`, `Duplicate panel id "${panel.id}"`)) + } + panelIds.add(panel.id) + validateSvgEdges(panel, pPath, out) + validateHeatmapShape(panel, pPath, out) + } + + const seen = new Set() + const dups = new Set() + for (const panel of scene.panels) { + const local: string[] = [] + if (panel.type === 'svg-scene') { + local.push(...panel.payload.nodes.map((n) => n.id)) + local.push(...(panel.payload.edges ?? []).map((e) => e.id)) + } else if (panel.type === 'chart') { + local.push(...panel.payload.series.map((s) => s.id)) + } + for (const id of local) { + if (seen.has(id)) dups.add(id) + seen.add(id) + } + } + for (const id of dups) { + out.push( + issue('duplicate_element_id', path, `Duplicate element id "${id}" in scene`) + ) + } + + return collectSceneElementIds(scene).ids +} + +function validateActRefs( + act: DemoAct, + actIndex: number, + elementIds: Set, + wildcardAllowed: boolean, + out: ValidationIssue[] +): void { + const actPath = `acts[${actIndex}]` + const stepIdRe = new RegExp(`^${escapeRegExp(act.id)}\\.s\\d+$`) + + for (const [si, step] of act.steps.entries()) { + const stepPath = `${actPath}.steps[${si}]` + if (!stepIdRe.test(step.id)) { + out.push( + issue( + 'step_id_format', + `${stepPath}.id`, + `Step id must match /^${act.id}\\.s\\d+$/ (got "${step.id}")` + ) + ) + } + + for (const id of step.pointTo ?? []) { + if (!elementIds.has(id)) { + out.push( + issue( + 'unknown_element_id', + `${stepPath}.pointTo`, + `pointTo references unknown element "${id}" in active scene` + ) + ) + } + } + + for (const [ri, rev] of (step.reveal ?? []).entries()) { + for (const id of rev.ids) { + if (id === '*') { + if (!wildcardAllowed) { + out.push( + issue( + 'wildcard_not_allowed', + `${stepPath}.reveal[${ri}]`, + '"*" reveal is only allowed when the active scene has heatmap or chart panels' + ) + ) + } + continue + } + if (!elementIds.has(id)) { + out.push( + issue( + 'unknown_element_id', + `${stepPath}.reveal[${ri}].ids`, + `reveal references unknown element "${id}"` + ) + ) + } + } + } + + for (const [ai, ann] of (step.annotate ?? []).entries()) { + for (const id of ann.targetIds) { + if ((step.pointTo ?? []).includes(id)) { + out.push( + issue( + 'annotate_pointto_overlap', + `${stepPath}.annotate[${ai}]`, + `Do not annotate "${id}" in the same step as pointTo — spotlight is enough` + ) + ) + } + if (!elementIds.has(id)) { + out.push( + issue( + 'unknown_element_id', + `${stepPath}.annotate[${ai}].targetIds`, + `annotate references unknown element "${id}"` + ) + ) + } + } + } + } +} + +function semanticValidate(demo: InteractiveDemoV1): ValidationIssue[] { + const out: ValidationIssue[] = [] + + const jsonBytes = new TextEncoder().encode(JSON.stringify(demo)).length + if (jsonBytes > INTERACTIVE_DEMO_CAPS.maxJsonBytes) { + out.push( + issue( + 'json_too_large', + '', + `JSON exceeds ${INTERACTIVE_DEMO_CAPS.maxJsonBytes} bytes (${jsonBytes})` + ) + ) + } + + const sceneCount = countDeclaredScenes(demo) + if (sceneCount > INTERACTIVE_DEMO_CAPS.maxScenes) { + out.push( + issue( + 'too_many_scenes', + 'scene', + `At most ${INTERACTIVE_DEMO_CAPS.maxScenes} scenes allowed (found ${sceneCount})` + ) + ) + } + + scanForbiddenColors(demo, '', out) + + validateScene(demo.scene, 'scene', out) + + let activeScene = demo.scene + let { ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene) + + const actIds = new Set() + for (const [ai, act] of demo.acts.entries()) { + if (actIds.has(act.id)) { + out.push(issue('duplicate_act_id', `acts[${ai}].id`, `Duplicate act id "${act.id}"`)) + } + actIds.add(act.id) + + if (act.scene) { + validateScene(act.scene, `acts[${ai}].scene`, out) + activeScene = act.scene + ;({ ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene)) + } + + const stepIds = new Set() + for (const [si, step] of act.steps.entries()) { + if (stepIds.has(step.id)) { + out.push( + issue( + 'duplicate_step_id', + `acts[${ai}].steps[${si}].id`, + `Duplicate step id "${step.id}"` + ) + ) + } + stepIds.add(step.id) + } + + validateActRefs(act, ai, elementIds, wildcardAllowed, out) + } + + return out +} + +/** + * Validate an Interactive Demo document. + * Structural (Zod allowlist) then semantic (caps, refs, no hex, wildcards). + */ +export function validateInteractiveDemo(input: unknown): ValidationResult { + const parsed = interactiveDemoV1Schema.safeParse(input) + if (!parsed.success) { + const issues: ValidationIssue[] = parsed.error.issues.map((e) => ({ + code: e.code, + path: e.path.join('.'), + message: e.message, + })) + return { ok: false, issues } + } + + const demo = parsed.data as InteractiveDemoV1 + const semantic = semanticValidate(demo) + if (semantic.length > 0) { + return { ok: false, issues: semantic } + } + return { ok: true, demo } +} + +/** + * Translate must preserve geometry. Default: strings are structural (kept). + * Only HUMAN_STRING_KEYS are nullified — adding a structural key is protected by default. + */ +export function assertTranslatePreservesStructure( + source: InteractiveDemoV1, + translated: InteractiveDemoV1 +): ValidationResult { + const stripHumanStrings = (value: unknown): unknown => { + if (typeof value === 'number' || typeof value === 'boolean' || value === null) { + return value + } + if (typeof value === 'string') { + // Context-free string: structural until proven otherwise. + return value + } + if (Array.isArray(value)) return value.map(stripHumanStrings) + if (value && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value)) { + if (isHumanStringKey(k)) { + out[k] = null + continue + } + out[k] = stripHumanStrings(v) + } + return out + } + return value + } + + const a = JSON.stringify(stripHumanStrings(source)) + const b = JSON.stringify(stripHumanStrings(translated)) + if (a !== b) { + return { + ok: false, + issues: [ + issue( + 'translate_structure_drift', + '', + 'Translated demo must preserve structure and ids; only human strings may change' + ), + ], + } + } + + if (source.id !== translated.id) { + return { + ok: false, + issues: [ + issue( + 'translate_id_mismatch', + 'id', + 'Translated variant must keep the same demo.id' + ), + ], + } + } + + return { ok: true, demo: translated } +} diff --git a/memento-note/lib/interactive-page/constants.ts b/memento-note/lib/interactive-page/constants.ts new file mode 100644 index 0000000..578488d --- /dev/null +++ b/memento-note/lib/interactive-page/constants.ts @@ -0,0 +1,86 @@ +/** Interactive Page schema v1 — caps & allowlists (spec Kimi / AttnRes). */ + +export const INTERACTIVE_PAGE_SCHEMA_VERSION = 1 as const + +export const INTERACTIVE_PAGE_CAPS = { + maxSections: 8, + maxBlocksPerSection: 12, + maxDemosPerPage: 5, + maxSimsPerPage: 3, + maxOverviewCards: 4, + minOverviewCards: 2, + maxStatsItems: 5, + minStatsItems: 2, + maxJsonBytes: 128 * 1024, +} as const + +export const SIM_CAPS = { + maxParams: 4, + maxComputed: 6, + maxExprChars: 200, +} as const + +export const PAGE_BLOCK_TYPES = [ + 'prose', + 'formula', + 'callout', + 'demo', + 'chart', + 'stats', + 'table', + 'image', + 'sim', +] as const + +export const CALLOUT_KINDS = [ + 'definition', + 'warning', + 'tip', + 'note', +] as const + +/** + * Human / locale strings — may contain hex in prose; skipped by color scan. + * Must stay in sync with validate + translate strip. + */ +export const PAGE_HUMAN_STRING_KEYS = [ + // page-level + 'lang', + 'kicker', + 'title', + 'subtitle', + 'meta', + 'lead', + 'badge', + 'body', + 'footer', + // blocks + 'md', + 'tex', + 'caption', + 'alt', + 'value', + 'label', + 'columns', + 'rows', + // sim blocks + 'symbol', + 'expr', + 'intro', + 'xLabel', + 'yLabel', + // inherited from demos (speak etc. scanned via demo validator) + 'speak', + 'text', + 'disclaimer', + 'rowLabels', + 'colLabels', +] as const + +export type PageHumanStringKey = (typeof PAGE_HUMAN_STRING_KEYS)[number] + +const PAGE_HUMAN_SET = new Set(PAGE_HUMAN_STRING_KEYS) + +export function isPageHumanStringKey(key: string): boolean { + return PAGE_HUMAN_SET.has(key) +} diff --git a/memento-note/lib/interactive-page/fixtures/thermo-page.json b/memento-note/lib/interactive-page/fixtures/thermo-page.json new file mode 100644 index 0000000..425617d --- /dev/null +++ b/memento-note/lib/interactive-page/fixtures/thermo-page.json @@ -0,0 +1,354 @@ +{ + "schemaVersion": 1, + "id": "page.thermo-test", + "lang": "fr", + "hero": { + "kicker": "EXPLAINER INTERACTIF", + "title": "Cycle frigorifique", + "subtitle": "Compression, condensation, détente, évaporation", + "meta": "Note de cours · valeurs illustratives" + }, + "overview": { + "lead": "Le cycle frigorifique déplace de la chaleur du froid vers le chaud grâce à un travail $W$.", + "cards": [ + { + "badge": "PROBLEM", + "title": "Objectif", + "body": "Extraire $Q_e$ à basse température.", + "intent": "warning" + }, + { + "badge": "APPROACH", + "title": "Cycle", + "body": "Quatre organes en boucle fermée.", + "intent": "flow" + }, + { + "badge": "RESULT", + "title": "COP", + "body": "$\\mathrm{COP}=Q_e/W$", + "intent": "output" + } + ] + }, + "sections": [ + { + "id": "s1", + "title": "Le problème", + "blocks": [ + { + "type": "prose", + "md": "On veut **refroidir** un volume en rejetant la chaleur à l’extérieur." + }, + { + "type": "callout", + "kind": "definition", + "title": "Travail", + "md": "Le compresseur fournit $W=h_2-h_1$." + } + ] + }, + { + "id": "s2", + "title": "Échanges", + "blocks": [ + { + "type": "formula", + "tex": "\\mathrm{COP}=\\frac{Q_e}{W}", + "caption": "Coefficient de performance" + }, + { + "type": "sim", + "sim": { + "simId": "ts-diagram" + }, + "caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées." + } + ] + }, + { + "id": "s3", + "title": "Isotherme", + "blocks": [ + { + "type": "chart", + "payload": { + "chartType": "line", + "series": [ + { + "id": "p", + "label": "P(V)", + "values": [ + 4, + 2.5, + 1.8, + 1.4, + 1.2 + ], + "intent": "flow" + } + ] + }, + "caption": "Pression vs volume (illustratif)" + }, + { + "type": "demo", + "caption": "Le cycle à compression de vapeur — 4 organes en boucle fermée.", + "demo": { + "schemaVersion": 1, + "id": "demo.vapor-cycle", + "lang": "fr", + "disclaimer": "Schéma pédagogique — valeurs illustratives.", + "scene": { + "id": "scene.cycle", + "panels": [ + { + "id": "panel.cycle", + "type": "svg-scene", + "payload": { + "nodes": [ + { + "id": "comp", + "label": "1 · Compresseur\n$W = h_2 - h_1$", + "intent": "compute" + }, + { + "id": "cond", + "label": "2 · Condenseur\n$Q_c = h_2 - h_3$", + "intent": "output" + }, + { + "id": "exp", + "label": "3 · Détente\n$h_3 = h_4$", + "intent": "flow" + }, + { + "id": "evap", + "label": "4 · Évaporateur\n$Q_e = h_1 - h_4$", + "intent": "cache" + }, + { + "id": "work", + "label": "Travail fourni $W$", + "intent": "highlight" + } + ], + "edges": [ + { + "id": "e12", + "from": "comp", + "to": "cond", + "style": "solid", + "intent": "flow" + }, + { + "id": "e23", + "from": "cond", + "to": "exp", + "style": "solid", + "intent": "flow" + }, + { + "id": "e34", + "from": "exp", + "to": "evap", + "style": "solid", + "intent": "flow" + }, + { + "id": "e41", + "from": "evap", + "to": "comp", + "style": "solid", + "intent": "flow" + }, + { + "id": "ew", + "from": "work", + "to": "comp", + "style": "dashed", + "intent": "highlight" + } + ] + } + } + ] + }, + "acts": [ + { + "id": "a1", + "title": "Cycle", + "pattern": "flowTrace", + "steps": [ + { + "id": "a1.s1", + "speak": "Le compresseur fournit le travail $W$ au fluide.", + "pattern": "spotlightTour", + "pointTo": [ + "comp" + ], + "reveal": [ + { + "ids": [ + "comp", + "work", + "ew" + ], + "scope": "act" + } + ] + }, + { + "id": "a1.s2", + "speak": "Au condenseur, la chaleur $Q_c$ est rejetée vers l'extérieur.", + "pattern": "spotlightTour", + "pointTo": [ + "cond" + ], + "reveal": [ + { + "ids": [ + "cond", + "e12" + ], + "scope": "act" + } + ] + }, + { + "id": "a1.s3", + "speak": "La détente est isenthalpique : $h_3 = h_4$.", + "pattern": "spotlightTour", + "pointTo": [ + "exp" + ], + "reveal": [ + { + "ids": [ + "exp", + "e23" + ], + "scope": "act" + } + ] + }, + { + "id": "a1.s4", + "speak": "À l'évaporateur, le fluide absorbe $Q_e$ : c'est le froid utile.", + "pattern": "spotlightTour", + "pointTo": [ + "evap" + ], + "reveal": [ + { + "ids": [ + "evap", + "e34" + ], + "scope": "act" + } + ] + }, + { + "id": "a1.s5", + "speak": "Le cycle se boucle : le fluide retourne au compresseur.", + "pattern": "overview", + "reveal": [ + { + "ids": [ + "comp", + "cond", + "exp", + "evap", + "work", + "e12", + "e23", + "e34", + "e41", + "ew" + ], + "scope": "act" + } + ] + } + ] + } + ] + } + }, + { + "type": "sim", + "sim": { + "simId": "carnot-cycle", + "preset": { + "t_cold": 260, + "t_hot": 300, + "q_cold": 100 + }, + "disclaimer": "Valeurs illustratives — les COP réels sont inférieurs au COP de Carnot." + }, + "caption": "Manipulez les températures des sources : le COP maximal suit le 2ᵉ principe." + }, + { + "type": "sim", + "sim": { + "simId": "carnot-cycle-anim" + }, + "caption": "Le cycle de Carnot battement par battement — piston et diagramme P–V en direct." + }, + { + "type": "stats", + "items": [ + { + "value": "3.2", + "label": "COP typique", + "intent": "output" + }, + { + "value": "1.25×", + "label": "Gain relatif", + "intent": "highlight" + }, + { + "value": "<2%", + "label": "Pertes", + "intent": "warning" + } + ] + } + ] + }, + { + "id": "s4", + "title": "Synthèse", + "blocks": [ + { + "type": "table", + "columns": [ + "Organe", + "Échange" + ], + "rows": [ + [ + "Compresseur", + "$W$" + ], + [ + "Condenseur", + "$Q_c$" + ], + [ + "Évaporateur", + "$Q_e$" + ] + ] + }, + { + "type": "prose", + "md": "Le COP mesure l’efficacité du cycle." + } + ] + } + ], + "footer": "Valeurs pédagogiques — pas des mesures expérimentales." +} diff --git a/memento-note/lib/interactive-page/index.ts b/memento-note/lib/interactive-page/index.ts new file mode 100644 index 0000000..4e5c27d --- /dev/null +++ b/memento-note/lib/interactive-page/index.ts @@ -0,0 +1,25 @@ +export { + INTERACTIVE_PAGE_CAPS, + INTERACTIVE_PAGE_SCHEMA_VERSION, + PAGE_BLOCK_TYPES, + CALLOUT_KINDS, + PAGE_HUMAN_STRING_KEYS, + isPageHumanStringKey, +} from './constants' +export { pageSpecV1Schema, pageBlockSchema } from './schema' +export { validateInteractivePage } from './validate' +export { normalizeInteractivePageCandidate } from './normalize' +export type { + PageSpecV1, + PageSection, + PageBlock, + PageHero, + PageOverview, + PageValidationIssue, + PageValidationResult, + IntentId, + SimRef, + CatalogSimRef, + GenericFormulaSim, + SimBlock, +} from './types' diff --git a/memento-note/lib/interactive-page/normalize.ts b/memento-note/lib/interactive-page/normalize.ts new file mode 100644 index 0000000..389fbb7 --- /dev/null +++ b/memento-note/lib/interactive-page/normalize.ts @@ -0,0 +1,335 @@ +/** + * Deterministic repairs for LLM PageSpecV1 before Zod validation. + * Mirrors interactive-demo/normalize — fix common shape/field aliases. + */ + +import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize' +import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants' + +const CALLOUT_SET = new Set(CALLOUT_KINDS) + +const CALLOUT_ALIASES: Record = { + definition: 'definition', + def: 'definition', + warning: 'warning', + warn: 'warning', + danger: 'warning', + alert: 'warning', + tip: 'tip', + hint: 'tip', + advice: 'tip', + note: 'note', + info: 'note', + remark: 'note', +} + +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : null +} + +function asString(v: unknown): string | undefined { + if (typeof v === 'string' && v.trim()) return v.trim() + if (typeof v === 'number' && Number.isFinite(v)) return String(v) + return undefined +} + +function slugId(title: string, fallback: 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}` : fallback +} + +function pickMd(obj: Record): string | undefined { + return ( + asString(obj.md) || + asString(obj.content) || + asString(obj.text) || + asString(obj.body) || + asString(obj.html) || + asString(obj.markdown) + ) +} + +function pickTex(obj: Record): string | undefined { + return ( + asString(obj.tex) || + asString(obj.latex) || + asString(obj.formula) || + asString(obj.math) || + asString(obj.equation) + ) +} + +function normalizeCalloutKind(v: unknown): string { + if (typeof v !== 'string') return 'note' + const key = v.trim().toLowerCase() + const mapped = CALLOUT_ALIASES[key] || key + return CALLOUT_SET.has(mapped) ? mapped : 'note' +} + +function normalizeBlock( + raw: unknown, + index: number +): Record | null { + const obj = asRecord(raw) + if (!obj) return null + const type = asString(obj.type)?.toLowerCase() + if (!type) return null + + if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') { + const md = pickMd(obj) + if (!md) return null + return { type: 'prose', md } + } + + if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') { + const tex = pickTex(obj) + if (!tex) return null + const out: Record = { type: 'formula', tex } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + if (type === 'callout' || type === 'aside' || type === 'box') { + const md = pickMd(obj) || '—' + const title = asString(obj.title) || asString(obj.heading) || 'Note' + return { + type: 'callout', + kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style), + title, + md, + } + } + + if (type === 'demo' || type === 'interactive' || type === 'animation') { + const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo + if (!nested) return null + const normalized = normalizeInteractiveDemoCandidate(nested) + const out: Record = { type: 'demo', demo: normalized } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + if (type === 'chart' || type === 'graph') { + const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj + const series = Array.isArray((payload as Record).series) + ? (payload as Record).series + : null + if (!series) return null + const chartType = + asString((payload as Record).chartType) || + asString(obj.chartType) || + 'line' + const out: Record = { + type: 'chart', + payload: { + chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line', + series, + }, + } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + if (type === 'stats' || type === 'metrics' || type === 'kpis') { + const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null + if (!items || items.length < 2) return null + return { + type: 'stats', + items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => { + const r = asRecord(it) || {} + return { + value: asString(r.value) || asString(r.v) || '—', + label: asString(r.label) || asString(r.name) || '—', + ...(asString(r.intent) ? { intent: asString(r.intent) } : {}), + } + }), + } + } + + if (type === 'table') { + const columns = Array.isArray(obj.columns) + ? obj.columns.map((c) => asString(c) || '—') + : [] + const rows = Array.isArray(obj.rows) ? obj.rows : [] + if (!columns.length) return null + const out: Record = { type: 'table', columns, rows } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + if (type === 'image' || type === 'img' || type === 'figure') { + const src = asString(obj.src) || asString(obj.url) + const alt = asString(obj.alt) || asString(obj.title) || 'Image' + if (!src) return null + const out: Record = { type: 'image', src, alt } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') { + const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj + const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id) + if (!simId) return null + const out: Record = { type: 'sim', sim: { ...sim, simId } } + const caption = asString(obj.caption) + if (caption) out.caption = caption + return out + } + + return null +} + +function normalizeSection( + raw: unknown, + index: number +): Record | null { + const obj = asRecord(raw) + if (!obj) return null + const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}` + const id = asString(obj.id) || `s${index + 1}` + const blocksRaw = Array.isArray(obj.blocks) + ? obj.blocks + : Array.isArray(obj.content) + ? obj.content + : [] + const blocks = blocksRaw + .map((b, i) => normalizeBlock(b, i)) + .filter(Boolean) + .slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record[] + if (!blocks.length) { + blocks.push({ + type: 'prose', + md: asString(obj.summary) || asString(obj.lead) || title, + }) + } + return { id, title, blocks } +} + +/** + * Best-effort normalize of an LLM page candidate. + * Returns a plain object ready for validateInteractivePage. + */ +export function normalizeInteractivePageCandidate( + input: unknown, + lang = 'fr' +): Record | null { + const root = asRecord(input) + if (!root) return null + + const heroIn = asRecord(root.hero) || {} + const title = + asString(heroIn.title) || + asString(root.title) || + 'Page interactive' + const kicker = + asString(heroIn.kicker) || + asString(heroIn.eyebrow) || + asString(heroIn.label) || + (lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER') + + const hero: Record = { + kicker, + title, + } + const subtitle = asString(heroIn.subtitle) || asString(root.subtitle) + const meta = asString(heroIn.meta) || asString(root.meta) + if (subtitle) hero.subtitle = subtitle + if (meta) hero.meta = meta + + let overview: Record | undefined + const overviewIn = asRecord(root.overview) + if (overviewIn) { + const lead = + asString(overviewIn.lead) || + asString(overviewIn.summary) || + asString(overviewIn.text) + const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : [] + const cards = cardsRaw + .map((c) => { + const r = asRecord(c) + if (!r) return null + const badge = asString(r.badge) || asString(r.label) || 'IDEA' + const cTitle = asString(r.title) || badge + const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle + return { + badge, + title: cTitle, + body, + ...(asString(r.intent) ? { intent: asString(r.intent) } : {}), + } + }) + .filter(Boolean) + if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) { + overview = { + lead, + cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards), + } + } else if (lead) { + // Pad to min cards so validation can pass + const padded = [...cards] + while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) { + padded.push({ + badge: `C${padded.length + 1}`, + title: title, + body: lead.slice(0, 120), + }) + } + overview = { + lead, + cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards), + } + } + } + + const sectionsRaw = Array.isArray(root.sections) ? root.sections : [] + let sections = sectionsRaw + .map((s, i) => normalizeSection(s, i)) + .filter(Boolean) as Record[] + + // Cap demos (speed + reliability): keep first 2 + let demoCount = 0 + sections = sections.map((sec) => { + const blocks = (sec.blocks as Record[]).filter((b) => { + if (b.type !== 'demo') return true + demoCount += 1 + return demoCount <= 2 + }) + return { ...sec, blocks } + }) + + sections = sections.slice(0, 5) + if (!sections.length) { + sections = [ + { + id: 's1', + title: title, + blocks: [{ type: 'prose', md: asString(root.summary) || title }], + }, + ] + } + + const out: Record = { + schemaVersion: 1, + id: asString(root.id) || slugId(title, 'page.generated'), + lang: asString(root.lang) || lang, + hero, + sections, + } + if (overview) out.overview = overview + const footer = asString(root.footer) + if (footer) out.footer = footer + return out +} diff --git a/memento-note/lib/interactive-page/schema.ts b/memento-note/lib/interactive-page/schema.ts new file mode 100644 index 0000000..a77f97e --- /dev/null +++ b/memento-note/lib/interactive-page/schema.ts @@ -0,0 +1,210 @@ +import { z } from 'zod' +import { INTENT_IDS } from '@/lib/interactive-demo/constants' +import { interactiveDemoV1Schema } from '@/lib/interactive-demo/schema' +import { + CALLOUT_KINDS, + INTERACTIVE_PAGE_CAPS, + INTERACTIVE_PAGE_SCHEMA_VERSION, + PAGE_BLOCK_TYPES, + SIM_CAPS, +} from './constants' + +const intentSchema = z.enum(INTENT_IDS).optional() + +const langSchema = z + .string() + .regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag') + +const chartPayloadSchema = z.object({ + chartType: z.enum(['line', 'bar', 'area']), + series: z + .array( + z.object({ + id: z.string().min(1), + label: z.string().optional(), + values: z.array(z.number()), + intent: intentSchema, + }) + ) + .min(1), +}) + +const proseBlock = z.object({ + type: z.literal('prose'), + md: z.string().min(1), +}) + +const formulaBlock = z.object({ + type: z.literal('formula'), + tex: z.string().min(1), + caption: z.string().optional(), +}) + +const calloutBlock = z.object({ + type: z.literal('callout'), + kind: z.enum(CALLOUT_KINDS), + title: z.string().min(1), + md: z.string().min(1), +}) + +const demoBlock = z.object({ + type: z.literal('demo'), + demo: interactiveDemoV1Schema, + caption: z.string().optional(), +}) + +const chartBlock = z.object({ + type: z.literal('chart'), + payload: chartPayloadSchema, + caption: z.string().optional(), +}) + +const statsBlock = z.object({ + type: z.literal('stats'), + items: z + .array( + z.object({ + value: z.string().min(1), + label: z.string().min(1), + intent: intentSchema, + }) + ) + .min(INTERACTIVE_PAGE_CAPS.minStatsItems) + .max(INTERACTIVE_PAGE_CAPS.maxStatsItems), +}) + +const tableBlock = z.object({ + type: z.literal('table'), + columns: z.array(z.string().min(1)).min(1), + rows: z.array(z.array(z.string())), + caption: z.string().optional(), +}) + +const imageBlock = z.object({ + type: z.literal('image'), + src: z.string().min(1), + alt: z.string().min(1), + caption: z.string().optional(), +}) + +const simParamIdSchema = z + .string() + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier') + +const genericSimParamSchema = z.object({ + id: simParamIdSchema, + symbol: z.string().min(1), + label: z.string().min(1), + min: z.number(), + max: z.number(), + step: z.number().positive(), + defaultValue: z.number(), + unit: z.string().optional(), + intent: intentSchema, +}) + +const genericSimComputedSchema = z.object({ + id: simParamIdSchema, + symbol: z.string().min(1), + label: z.string().min(1), + expr: z.string().min(1).max(SIM_CAPS.maxExprChars), + unit: z.string().optional(), + intent: intentSchema, +}) + +const genericSimSchema = z.object({ + simId: z.literal('generic-formula'), + title: z.string().min(1), + intro: z.string().optional(), + params: z.array(genericSimParamSchema).min(1).max(SIM_CAPS.maxParams), + computed: z.array(genericSimComputedSchema).min(1).max(SIM_CAPS.maxComputed), + visual: z.union([ + z.object({ kind: z.literal('gauges') }), + z.object({ kind: z.literal('bars') }), + z.object({ + kind: z.literal('curve'), + xParamId: simParamIdSchema, + expr: z.string().min(1), + xLabel: z.string().optional(), + yLabel: z.string().optional(), + }), + ]), + disclaimer: z.string().optional(), +}) + +const catalogSimSchema = z.object({ + simId: z + .string() + .min(1) + .refine((s) => s !== 'generic-formula', { + message: 'generic-formula must use the full inline schema', + }), + title: z.string().optional(), + preset: z.record(z.string(), z.number()).optional(), + disclaimer: z.string().optional(), +}) + +const simBlock = z.object({ + type: z.literal('sim'), + sim: z.union([genericSimSchema, catalogSimSchema]), + caption: z.string().optional(), +}) + +export const pageBlockSchema = z.discriminatedUnion('type', [ + proseBlock, + formulaBlock, + calloutBlock, + demoBlock, + chartBlock, + statsBlock, + tableBlock, + imageBlock, + simBlock, +]) + +const sectionSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + blocks: z + .array(pageBlockSchema) + .min(1) + .max(INTERACTIVE_PAGE_CAPS.maxBlocksPerSection), +}) + +const overviewSchema = z.object({ + lead: z.string().min(1), + cards: z + .array( + z.object({ + badge: z.string().min(1), + title: z.string().min(1), + body: z.string().min(1), + intent: intentSchema, + }) + ) + .min(INTERACTIVE_PAGE_CAPS.minOverviewCards) + .max(INTERACTIVE_PAGE_CAPS.maxOverviewCards), +}) + +export const pageSpecV1Schema = z.object({ + schemaVersion: z.literal(INTERACTIVE_PAGE_SCHEMA_VERSION), + id: z.string().min(1), + lang: langSchema, + hero: z.object({ + kicker: z.string().min(1), + title: z.string().min(1), + subtitle: z.string().optional(), + meta: z.string().optional(), + }), + overview: overviewSchema.optional(), + sections: z + .array(sectionSchema) + .min(1) + .max(INTERACTIVE_PAGE_CAPS.maxSections), + footer: z.string().optional(), +}) + +export type PageSpecV1Parsed = z.infer + +/** Re-export for callers. */ +export { PAGE_BLOCK_TYPES } diff --git a/memento-note/lib/interactive-page/sim-eval.ts b/memento-note/lib/interactive-page/sim-eval.ts new file mode 100644 index 0000000..646ff95 --- /dev/null +++ b/memento-note/lib/interactive-page/sim-eval.ts @@ -0,0 +1,355 @@ +/** + * Safe math-expression evaluator for the generic-formula simulator. + * Tokenizer + recursive-descent parser — NO eval/Function, no object access, + * no loops. Identifiers resolve only against an explicit environment; + * functions come from a fixed allowlist. + */ + +type Token = + | { t: 'num'; v: number } + | { t: 'id'; v: string } + | { t: 'op'; v: string } + | { t: 'lparen' } + | { t: 'rparen' } + | { t: 'comma' } + +const CONSTANTS: Record = { + pi: Math.PI, + e: Math.E, +} + +type Fn = (...args: number[]) => number +const FUNCTIONS: Record = { + sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 }, + abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 }, + exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 }, + ln: { fn: Math.log, minArgs: 1, maxArgs: 1 }, + log: { fn: Math.log10, minArgs: 1, maxArgs: 1 }, + round: { fn: Math.round, minArgs: 1, maxArgs: 1 }, + floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 }, + ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 }, + min: { fn: Math.min, minArgs: 1, maxArgs: 8 }, + max: { fn: Math.max, minArgs: 1, maxArgs: 8 }, +} + +const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export type SimExprError = { message: string } + +type Ast = + | { k: 'num'; v: number } + | { k: 'id'; v: string } + | { k: 'call'; name: string; args: Ast[] } + | { k: 'un'; op: '-'; a: Ast } + | { k: 'bin'; op: string; a: Ast; b: Ast } + +function tokenize(src: string): Token[] | SimExprError { + const tokens: Token[] = [] + let i = 0 + while (i < src.length) { + const ch = src[i] + if (ch === ' ' || ch === '\t' || ch === '\n') { + i++ + continue + } + if (ch >= '0' && ch <= '9') { + let j = i + while (j < src.length && /[0-9.]/.test(src[j])) j++ + const raw = src.slice(i, j) + const v = Number(raw) + if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` } + tokens.push({ t: 'num', v }) + i = j + continue + } + if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') { + let j = i + 1 + while (j < src.length && /[0-9]/.test(src[j])) j++ + tokens.push({ t: 'num', v: Number(src.slice(i, j)) }) + i = j + continue + } + if (/[A-Za-z_]/.test(ch)) { + let j = i + while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++ + tokens.push({ t: 'id', v: src.slice(i, j) }) + i = j + continue + } + if ('+-*/^%'.includes(ch)) { + tokens.push({ t: 'op', v: ch }) + i++ + continue + } + if (ch === '(') { + tokens.push({ t: 'lparen' }) + i++ + continue + } + if (ch === ')') { + tokens.push({ t: 'rparen' }) + i++ + continue + } + if (ch === ',') { + tokens.push({ t: 'comma' }) + i++ + continue + } + return { message: `unexpected character "${ch}"` } + } + return tokens +} + +class Parser { + private pos = 0 + constructor(private tokens: Token[]) {} + + private peek(): Token | undefined { + return this.tokens[this.pos] + } + private next(): Token | undefined { + return this.tokens[this.pos++] + } + private expectOp(): string | null { + const t = this.peek() + return t?.t === 'op' ? t.v : null + } + + parseExpr(): Ast | SimExprError { + let left = this.parseTerm() + if ('message' in left) return left + for (;;) { + const op = this.expectOp() + if (op !== '+' && op !== '-') break + this.next() + const right = this.parseTerm() + if ('message' in right) return right + left = { k: 'bin', op, a: left, b: right } + } + return left + } + + private parseTerm(): Ast | SimExprError { + let left = this.parseUnary() + if ('message' in left) return left + for (;;) { + const op = this.expectOp() + if (op !== '*' && op !== '/' && op !== '%') break + this.next() + const right = this.parseUnary() + if ('message' in right) return right + left = { k: 'bin', op, a: left, b: right } + } + return left + } + + private parseUnary(): Ast | SimExprError { + if (this.expectOp() === '-') { + this.next() + const a = this.parseUnary() + if ('message' in a) return a + return { k: 'un', op: '-', a } + } + if (this.expectOp() === '+') { + this.next() + return this.parseUnary() + } + return this.parsePower() + } + + private parsePower(): Ast | SimExprError { + const base = this.parseAtom() + if ('message' in base) return base + if (this.expectOp() === '^') { + this.next() + const exp = this.parseUnary() // right-assoc + if ('message' in exp) return exp + return { k: 'bin', op: '^', a: base, b: exp } + } + return base + } + + private parseAtom(): Ast | SimExprError { + const t = this.next() + if (!t) return { message: 'unexpected end of expression' } + if (t.t === 'num') return { k: 'num', v: t.v } + if (t.t === 'lparen') { + const inner = this.parseExpr() + if ('message' in inner) return inner + const close = this.next() + if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' } + return inner + } + if (t.t === 'id') { + if (this.peek()?.t === 'lparen') { + this.next() // consume ( + const args: Ast[] = [] + if (this.peek()?.t !== 'rparen') { + for (;;) { + const arg = this.parseExpr() + if ('message' in arg) return arg + args.push(arg) + if (this.peek()?.t === 'comma') { + this.next() + continue + } + break + } + } + const close = this.next() + if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' } + return { k: 'call', name: t.v, args } + } + return { k: 'id', v: t.v } + } + return { message: `unexpected token "${'v' in t ? t.v : t.t}"` } + } + + parseTop(): Ast | SimExprError { + const ast = this.parseExpr() + if ('message' in ast) return ast + if (this.pos < this.tokens.length) { + return { message: 'trailing tokens after expression' } + } + return ast + } +} + +function evalAst(ast: Ast, env: Record): number { + switch (ast.k) { + case 'num': + return ast.v + case 'id': { + if (ast.v in CONSTANTS) return CONSTANTS[ast.v] + const v = env[ast.v] + return typeof v === 'number' ? v : NaN + } + case 'un': + return -evalAst(ast.a, env) + case 'bin': { + const a = evalAst(ast.a, env) + const b = evalAst(ast.b, env) + switch (ast.op) { + case '+': + return a + b + case '-': + return a - b + case '*': + return a * b + case '/': + return b === 0 ? NaN : a / b + case '%': + return b === 0 ? NaN : a % b + case '^': + return Math.pow(a, b) + default: + return NaN + } + } + case 'call': { + const def = FUNCTIONS[ast.name] + if (!def) return NaN + const args = ast.args.map((a) => evalAst(a, env)) + if (args.some((x) => Number.isNaN(x))) return NaN + return def.fn(...args) + } + } +} + +function collectIds(ast: Ast, out: Set): void { + switch (ast.k) { + case 'num': + return + case 'id': + out.add(ast.v) + return + case 'un': + collectIds(ast.a, out) + return + case 'bin': + collectIds(ast.a, out) + collectIds(ast.b, out) + return + case 'call': + ast.args.forEach((a) => collectIds(a, out)) + return + } +} + +export type ParsedSimExpr = { + /** Evaluate against an environment; NaN when uncomputable. */ + evaluate(env: Record): number + /** Identifiers used (params/computed refs), excluding constants. */ + identifiers: string[] +} + +/** + * Parse a safe math expression. Returns error message on syntax problems. + * Unknown function names are rejected at parse time. + */ +export function parseSimExpr(src: string): ParsedSimExpr | SimExprError { + const trimmed = src.trim() + if (!trimmed || trimmed.length > 200) { + return { message: 'expression empty or too long (max 200 chars)' } + } + const tokens = tokenize(trimmed) + if ('message' in tokens) return tokens + const ast = new Parser(tokens).parseTop() + if ('message' in ast) return ast + + const ids = new Set() + collectIds(ast, ids) + for (const id of ids) { + if (id in FUNCTIONS) { + return { message: `"${id}" is a function name — call it with (...)` } + } + } + // Validate function calls (unknown names, arity) + const checkCalls = (node: Ast): SimExprError | null => { + if (node.k === 'call') { + const def = FUNCTIONS[node.name] + if (!def) return { message: `unknown function "${node.name}"` } + if (node.args.length < def.minArgs || node.args.length > def.maxArgs) { + return { message: `function "${node.name}" expects ${def.minArgs}–${def.maxArgs} args` } + } + for (const a of node.args) { + const err = checkCalls(a) + if (err) return err + } + } else if (node.k === 'un') { + return checkCalls(node.a) + } else if (node.k === 'bin') { + return checkCalls(node.a) ?? checkCalls(node.b) + } + return null + } + const callErr = checkCalls(ast) + if (callErr) return callErr + + const identifiers = [...ids].filter((id) => !(id in CONSTANTS)) + return { + evaluate: (env) => evalAst(ast, env), + identifiers, + } +} + +/** + * Validation helper: parse + require every identifier ∈ allowedIds. + * Returns a list of issue messages (empty = OK). + */ +export function validateSimExprRefs( + src: string, + allowedIds: Set +): string[] { + const parsed = parseSimExpr(src) + if ('message' in parsed) return [parsed.message] + return parsed.identifiers + .filter((id) => !allowedIds.has(id)) + .map((id) => `unknown identifier "${id}"`) +} + +/** Type guard helper for zod refinements. */ +export function isValidSimParamId(id: string): boolean { + return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS) +} diff --git a/memento-note/lib/interactive-page/types.ts b/memento-note/lib/interactive-page/types.ts new file mode 100644 index 0000000..ad800df --- /dev/null +++ b/memento-note/lib/interactive-page/types.ts @@ -0,0 +1,153 @@ +import type { ChartPayload, IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types' +import type { CALLOUT_KINDS, PAGE_BLOCK_TYPES } from './constants' + +export type { IntentId } +export type PageBlockType = (typeof PAGE_BLOCK_TYPES)[number] +export type CalloutKind = (typeof CALLOUT_KINDS)[number] + +export type PageHero = { + kicker: string + title: string + subtitle?: string + meta?: string +} + +export type OverviewCard = { + badge: string + title: string + body: string + intent?: IntentId +} + +export type PageOverview = { + lead: string + cards: OverviewCard[] +} + +export type ProseBlock = { type: 'prose'; md: string } +export type FormulaBlock = { type: 'formula'; tex: string; caption?: string } +export type CalloutBlock = { + type: 'callout' + kind: CalloutKind + title: string + md: string +} +export type DemoBlock = { + type: 'demo' + demo: InteractiveDemoV1 + caption?: string +} +export type ChartBlock = { + type: 'chart' + payload: ChartPayload + caption?: string +} +export type StatsBlock = { + type: 'stats' + items: { value: string; label: string; intent?: IntentId }[] +} +export type TableBlock = { + type: 'table' + columns: string[] + rows: string[][] + caption?: string +} +export type ImageBlock = { + type: 'image' + src: string + alt: string + caption?: string +} + +// ── Simulator block (plugin catalog + generic formula) ────────────────────── + +/** Curated simulator from the plugin registry — AI only picks + presets. */ +export type CatalogSimRef = { + simId: string + title?: string + preset?: Record + disclaimer?: string +} + +export type GenericSimParam = { + id: string + symbol: string + label: string + min: number + max: number + step: number + defaultValue: number + unit?: string + intent?: IntentId +} + +export type GenericSimComputed = { + id: string + symbol: string + label: string + /** Safe math expression (see sim-eval) over params + previous computed. */ + expr: string + unit?: string + intent?: IntentId +} + +export type GenericSimVisual = + | { kind: 'gauges' } + | { kind: 'bars' } + | { kind: 'curve'; xParamId: string; expr: string; xLabel?: string; yLabel?: string } + +/** Inline custom simulation — expressions validated by sim-eval (no eval). */ +export type GenericFormulaSim = { + simId: 'generic-formula' + title: string + intro?: string + params: GenericSimParam[] + computed: GenericSimComputed[] + visual: GenericSimVisual + disclaimer?: string +} + +export type SimRef = CatalogSimRef | GenericFormulaSim + +export type SimBlock = { + type: 'sim' + sim: SimRef + caption?: string +} + +export type PageBlock = + | ProseBlock + | FormulaBlock + | CalloutBlock + | DemoBlock + | ChartBlock + | StatsBlock + | TableBlock + | ImageBlock + | SimBlock + +export type PageSection = { + id: string + title: string + blocks: PageBlock[] +} + +export type PageSpecV1 = { + schemaVersion: 1 + id: string + lang: string + hero: PageHero + overview?: PageOverview + sections: PageSection[] + footer?: string +} + +export type PageValidationIssue = { + code: string + path: string + message: string +} + +export type PageValidationResult = + | { ok: true; page: PageSpecV1 } + | { ok: false; issues: PageValidationIssue[] } diff --git a/memento-note/lib/interactive-page/validate.ts b/memento-note/lib/interactive-page/validate.ts new file mode 100644 index 0000000..a4959c1 --- /dev/null +++ b/memento-note/lib/interactive-page/validate.ts @@ -0,0 +1,303 @@ +import { FORBIDDEN_COLOR_RE } from '@/lib/interactive-demo/constants' +import { validateInteractiveDemo } from '@/lib/interactive-demo/validate' +import { getPlugin } from '@/lib/simulators' +import { + INTERACTIVE_PAGE_CAPS, + isPageHumanStringKey, +} from './constants' +import { pageSpecV1Schema } from './schema' +import { validateSimExprRefs } from './sim-eval' +import type { + PageBlock, + PageSpecV1, + PageValidationIssue, + PageValidationResult, +} from './types' + +function issue(code: string, path: string, message: string): PageValidationIssue { + return { code, path, message } +} + +function scanForbiddenColors( + value: unknown, + path: string, + out: PageValidationIssue[] +): void { + if (typeof value === 'string') { + if (FORBIDDEN_COLOR_RE.test(value)) { + out.push( + issue( + 'forbidden_color', + path, + 'Free color literals (hex/rgb) are forbidden — use intent enums' + ) + ) + } + return + } + if (Array.isArray(value)) { + value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out)) + return + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + if (isPageHumanStringKey(k)) continue + // Nested demos are validated separately (incl. their own color scan) + if (k === 'demo') continue + scanForbiddenColors(v, path ? `${path}.${k}` : k, out) + } + } +} + +function countDemos(page: PageSpecV1): number { + let n = 0 + for (const s of page.sections) { + for (const b of s.blocks) { + if (b.type === 'demo') n += 1 + } + } + return n +} + +function countSims(page: PageSpecV1): number { + let n = 0 + for (const s of page.sections) { + for (const b of s.blocks) { + if (b.type === 'sim') n += 1 + } + } + return n +} + +/** Simulator block: catalog ref integrity / generic exprs safety. */ +function validateSim( + block: Extract, + path: string, + out: PageValidationIssue[] +): void { + const sim = block.sim + if (sim.simId === 'generic-formula') { + const generic = sim as Extract + const paramIds = new Set(generic.params.map((p) => p.id)) + for (const p of generic.params) { + if (p.min >= p.max) { + out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`)) + } + if (p.defaultValue < p.min || p.defaultValue > p.max) { + out.push( + issue('sim_param_default', `${path}.params`, `Param "${p.id}": default outside [min, max]`) + ) + } + } + const allowed = new Set(paramIds) + for (const c of generic.computed) { + const errs = validateSimExprRefs(c.expr, allowed) + for (const e of errs) { + out.push(issue('sim_expr', `${path}.computed.${c.id}`, e)) + } + if (errs.length === 0) allowed.add(c.id) // computed may chain + } + if (generic.visual.kind === 'curve') { + if (!paramIds.has(generic.visual.xParamId)) { + out.push( + issue('sim_curve_param', `${path}.visual.xParamId`, `Unknown param "${generic.visual.xParamId}"`) + ) + } + const errs = validateSimExprRefs(generic.visual.expr, allowed) + for (const e of errs) { + out.push(issue('sim_expr', `${path}.visual.expr`, e)) + } + } + return + } + + // Catalog plugin: must exist. Sims: preset within bounds + compute finite. + const catalog = sim as Extract & { + preset?: Record + } + const plugin = getPlugin(catalog.simId) + if (!plugin) { + out.push(issue('unknown_simulator', `${path}.simId`, `Unknown simulator "${catalog.simId}"`)) + return + } + if (plugin.family === 'anim') { + if (!plugin.beats.length) { + out.push(issue('sim_anim_empty', `${path}`, 'Animation plugin has no beats')) + } + return + } + const env: Record = {} + for (const p of plugin.params) env[p.id] = p.defaultValue + if (catalog.preset) { + for (const [k, v] of Object.entries(catalog.preset)) { + const def = plugin.params.find((p) => p.id === k) + if (!def) { + out.push(issue('sim_preset_key', `${path}.preset.${k}`, 'Not a parameter of this simulator')) + continue + } + if (v < def.min || v > def.max) { + out.push( + issue('sim_preset_range', `${path}.preset.${k}`, `Value ${v} outside [${def.min}, ${def.max}]`) + ) + continue + } + env[k] = v + } + } + try { + const result = plugin.compute(env) + for (const o of plugin.outputs) { + if (!Number.isFinite(result[o.id])) { + out.push( + issue('sim_compute', `${path}`, `Output "${o.id}" not finite at preset values`) + ) + } + } + } catch { + out.push(issue('sim_compute', `${path}`, 'Simulator compute() threw at preset values')) + } +} + +function validateTable( + block: Extract, + path: string, + out: PageValidationIssue[] +): void { + const cols = block.columns.length + for (const [ri, row] of block.rows.entries()) { + if (row.length !== cols) { + out.push( + issue( + 'table_shape', + `${path}.rows[${ri}]`, + `Expected ${cols} cells, got ${row.length}` + ) + ) + } + } +} + +function validateImageSrc( + block: Extract, + path: string, + out: PageValidationIssue[] +): void { + const src = block.src.trim() + // Allow relative /uploads, https, and data:image — reject javascript: etc. + if (/^\s*javascript:/i.test(src) || /^\s*data:text\/html/i.test(src)) { + out.push( + issue('unsafe_image_src', `${path}.src`, 'Unsafe image src rejected') + ) + } +} + +function semanticValidate(page: PageSpecV1): PageValidationIssue[] { + const out: PageValidationIssue[] = [] + + const jsonBytes = new TextEncoder().encode(JSON.stringify(page)).length + if (jsonBytes > INTERACTIVE_PAGE_CAPS.maxJsonBytes) { + out.push( + issue( + 'json_too_large', + '', + `JSON exceeds ${INTERACTIVE_PAGE_CAPS.maxJsonBytes} bytes (${jsonBytes})` + ) + ) + } + + const demoCount = countDemos(page) + if (demoCount > INTERACTIVE_PAGE_CAPS.maxDemosPerPage) { + out.push( + issue( + 'too_many_demos', + 'sections', + `At most ${INTERACTIVE_PAGE_CAPS.maxDemosPerPage} demos per page (found ${demoCount})` + ) + ) + } + + const simCount = countSims(page) + if (simCount > INTERACTIVE_PAGE_CAPS.maxSimsPerPage) { + out.push( + issue( + 'too_many_sims', + 'sections', + `At most ${INTERACTIVE_PAGE_CAPS.maxSimsPerPage} sims per page (found ${simCount})` + ) + ) + } + + scanForbiddenColors(page, '', out) + + const sectionIds = new Set() + for (const [si, section] of page.sections.entries()) { + const sPath = `sections[${si}]` + if (sectionIds.has(section.id)) { + out.push( + issue( + 'duplicate_section_id', + `${sPath}.id`, + `Duplicate section id "${section.id}"` + ) + ) + } + sectionIds.add(section.id) + + if (!/^s\d+$/.test(section.id)) { + out.push( + issue( + 'section_id_format', + `${sPath}.id`, + `Section id should match /^s\\d+$/ (got "${section.id}")` + ) + ) + } + + for (const [bi, block] of section.blocks.entries()) { + const bPath = `${sPath}.blocks[${bi}]` + + // Unknown types already hard-rejected by Zod discriminatedUnion. + if (block.type === 'table') validateTable(block, bPath, out) + if (block.type === 'image') validateImageSrc(block, bPath, out) + if (block.type === 'sim') validateSim(block, bPath, out) + + if (block.type === 'demo') { + const demoResult = validateInteractiveDemo(block.demo) + if (!demoResult.ok) { + for (const iss of demoResult.issues) { + out.push({ + code: `demo_${iss.code}`, + path: `${bPath}.demo${iss.path ? '.' + iss.path : ''}`, + message: iss.message, + }) + } + } + } + } + } + + return out +} + +/** + * Validate a PageSpecV1 document. + * Structural (Zod allowlist) then semantic (caps, colors, demos, tables). + */ +export function validateInteractivePage(input: unknown): PageValidationResult { + const parsed = pageSpecV1Schema.safeParse(input) + if (!parsed.success) { + const issues: PageValidationIssue[] = parsed.error.issues.map((e) => ({ + code: e.code, + path: e.path.join('.'), + message: e.message, + })) + return { ok: false, issues } + } + + const page = parsed.data as PageSpecV1 + const semantic = semanticValidate(page) + if (semantic.length > 0) { + return { ok: false, issues: semantic } + } + return { ok: true, page } +} diff --git a/memento-note/lib/plan-entitlements.ts b/memento-note/lib/plan-entitlements.ts index a003c17..a79420b 100644 --- a/memento-note/lib/plan-entitlements.ts +++ b/memento-note/lib/plan-entitlements.ts @@ -25,6 +25,8 @@ export const FALLBACK_TIER_LIMITS: Record< ai_flashcard: 5, voice_transcribe: 20, publish_enhance: 2, + interactive_demo: 5, + interactive_page: 2, }, PRO: { semantic_search: 200, @@ -42,6 +44,8 @@ export const FALLBACK_TIER_LIMITS: Record< ai_flashcard: 100, voice_transcribe: 500, publish_enhance: 15, + interactive_demo: 40, + interactive_page: 20, }, BUSINESS: { semantic_search: 1000, @@ -59,6 +63,8 @@ export const FALLBACK_TIER_LIMITS: Record< ai_flashcard: 'unlimited', voice_transcribe: 'unlimited', publish_enhance: 100, + interactive_demo: 200, + interactive_page: 80, }, ENTERPRISE: { semantic_search: 'unlimited', @@ -75,6 +81,8 @@ export const FALLBACK_TIER_LIMITS: Record< ai_flashcard: 'unlimited', voice_transcribe: 'unlimited', publish_enhance: 'unlimited', + interactive_demo: 'unlimited', + interactive_page: 'unlimited', }, }; diff --git a/memento-note/lib/publish/types.ts b/memento-note/lib/publish/types.ts index aa52d6a..de57fd6 100644 --- a/memento-note/lib/publish/types.ts +++ b/memento-note/lib/publish/types.ts @@ -1,4 +1,4 @@ -export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const +export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay', 'interactive-page'] as const export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number] /** Métadonnées éditoriales IA — corps = HTML source original. */ @@ -19,3 +19,9 @@ export interface PublishRewriteSpec { export function isPublishTemplateId(value: string): value is PublishTemplateId { return (PUBLISH_TEMPLATES as readonly string[]).includes(value) } + +export function isInteractivePageTemplate( + value: string | null | undefined +): boolean { + return value === 'interactive-page' +} diff --git a/memento-note/lib/quota-utils.ts b/memento-note/lib/quota-utils.ts index 064ecb3..37fa9a3 100644 --- a/memento-note/lib/quota-utils.ts +++ b/memento-note/lib/quota-utils.ts @@ -13,6 +13,8 @@ export const VALID_FEATURES = [ 'ai_flashcard', 'voice_transcribe', 'publish_enhance', + 'interactive_demo', + 'interactive_page', ] as const; export type FeatureName = (typeof VALID_FEATURES)[number]; diff --git a/memento-note/lib/simulators/README.md b/memento-note/lib/simulators/README.md new file mode 100644 index 0000000..7702e77 --- /dev/null +++ b/memento-note/lib/simulators/README.md @@ -0,0 +1,59 @@ +# Simulateurs interactifs (plugins) + +Bibliothèque de simulateurs pédagogiques pour les **pages interactives** (`/p/{slug}`). +L'IA ne code jamais une simulation : elle **choisit** un simulateur de cette liste et le +**configure** (`preset` = valeurs de la note), ou utilise `generic-formula` si aucun ne +correspond au sujet. + +## Liste des simulateurs disponibles + +| `simId` | Famille | Sujet | Interaction | +|---|---|---|---| +| `carnot-cycle` | `sim` | Machine frigorifique / PAC / moteur de Carnot (2ᵉ principe) | Curseurs T_c, T_h, Q_c → COP, η, W min, Q_h en direct | +| `carnot-cycle-anim` | `anim` | Cycle de Carnot animé (piston + diagramme P–V live, 5 étapes) | Play / Pause / Step / Reset / vitesse + narration | +| `generic-formula` | `sim` | (générique) toute relation chiffrée y = f(paramètres) | Curseurs définis par l'IA, expressions sûres (aucun `eval`) | + +Deux familles de plugins : `sim` (manipulation de paramètres, calcul en direct) et +`anim` (scène animée codée en dur, pilotée par le player Play/Step — narration par +battements). Les deux sont choisis par l'IA via le même bloc `{ "type": "sim" }`. + +## Comment l'IA les utilise + +1. Le prompt de génération de section injecte `catalogForPrompt(lang)` (registry.ts) — + id + résumé + mots-clés + bornes de chaque simulateur. +2. Si le contenu de la note correspond (`keywords`), l'IA émet + `{ "type": "sim", "sim": { "simId": "carnot-cycle", "preset": { "t_hot": 300 } } }`. +3. `validateInteractivePage` vérifie : `simId` connu, preset dans les bornes, + `compute(preset)` fini — sinon rejet (renvoyé au LLM ou fallback). + +## Ajouter un plugin (5 étapes) + +Pour un **`sim`** (curseurs) : +1. **`lib/simulators/.ts`** — implémenter `SimulatorPlugin` (`family: 'sim'`) : + `id`, `title`/`summary` fr+en (le summary sert au matching IA), `keywords`, + `params` (curseurs : bornes, pas, défaut, unité, intent), `outputs` (symbole KaTeX, + unité), et `compute(env)` **pur et déterministe**. +2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`. +3. **`components/simulators/-view.tsx`** — composant sur mesure + (props : `preset`, `title`, `disclaimer`, `lang`). Réutiliser + `SimSlider` / `SimOutputCard` / `SimHeading` / `SimKaTeX` de `sim-controls.tsx`. +4. **`components/simulators/index.ts`** — enregistrer dans `SIMULATOR_VIEWS`. +5. Vérifier : `compute` fini aux valeurs par défaut, `npx tsc --noEmit`, tester sur + `/dev/interactive-page`. + +Pour une **`anim`** (scène animée Play/Step) : +1. **`lib/simulators/.ts`** — implémenter `AnimPlugin` (`family: 'anim'`) : + `id`, `title`/`summary` fr+en, `keywords`, `disclaimer?`, `beats` (narration + fr+en par étape — KaTeX inline `$…$` dans `speak`). +2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`. +3. **`components/simulators/-anim-view.tsx`** — scène SVG/React pilotée par + `step` (props : `step`, `lang`). Transitions CSS sur `transform`/`opacity` + uniquement. Le chrome (Play/Pause/Step/Reset/vitesse + panneau de narration + + clavier Espace/←/→/R) est fourni par `AnimPlayerShell` — ne pas le réécrire. +4. **`components/simulators/index.ts`** — enregistrer dans `ANIM_VIEWS`. +5. Vérifier : chaque `step` rend un état cohérent (y compris état final sans JS), + `npx tsc --noEmit`, tester sur `/dev/interactive-page`. + +Règles : pas de couleurs en dur (intents / tokens `--pp-*`), labels fr+en dans le +plugin, tout calcul côté `compute` ou scène codée (jamais de logique LLM), SSR = +état lisible sans JS. diff --git a/memento-note/lib/simulators/carnot-cycle-anim.ts b/memento-note/lib/simulators/carnot-cycle-anim.ts new file mode 100644 index 0000000..161c829 --- /dev/null +++ b/memento-note/lib/simulators/carnot-cycle-anim.ts @@ -0,0 +1,76 @@ +import type { AnimPlugin } from './types' + +/** + * Carnot cycle — animated piston + live P-V diagram, 5 beats. + * Scene rendered by components/simulators/carnot-cycle-anim-view.tsx. + */ +export const carnotCycleAnim: AnimPlugin = { + family: 'anim', + id: 'carnot-cycle-anim', + title: { + fr: 'Le cycle de Carnot en mouvement', + en: 'The Carnot cycle in motion', + }, + summary: { + fr: 'Animation du cycle de Carnot : piston, détentes/compressions isothermes et adiabatiques, diagramme P-V tracé en direct, échanges Q et W. 2ᵉ principe, machine thermique, réfrigérateur.', + en: 'Animated Carnot cycle: piston, isothermal and adiabatic expansion/compression, live P-V diagram, Q and W exchanges. Second law, heat engine, refrigerator.', + }, + keywords: [ + 'carnot', + 'thermodynamique', + 'thermodynamics', + 'cycle', + 'piston', + 'isotherme', + 'adiabatique', + 'isothermal', + 'adiabatic', + 'entropie', + 'entropy', + 'machine thermique', + 'heat engine', + 'deuxième principe', + 'second law', + ], + disclaimer: { + fr: 'Schéma pédagogique — grandeurs illustratives, gaz parfait.', + en: 'Teaching schematic — illustrative values, ideal gas.', + }, + beats: [ + { + id: 'b1', + speak: { + fr: '**Détente isotherme** à $T_h$ : le gaz pousse le piston, la chaleur $Q_h$ entre depuis la source chaude.', + en: '**Isothermal expansion** at $T_h$: the gas pushes the piston, heat $Q_h$ flows in from the hot reservoir.', + }, + }, + { + id: 'b2', + speak: { + fr: '**Détente adiabatique** : isolé, le gaz continue de se détendre et se refroidit de $T_h$ à $T_c$.', + en: '**Adiabatic expansion**: insulated, the gas keeps expanding and cools from $T_h$ down to $T_c$.', + }, + }, + { + id: 'b3', + speak: { + fr: '**Compression isotherme** à $T_c$ : on travaille sur le gaz, la chaleur $Q_c$ sort vers la source froide.', + en: '**Isothermal compression** at $T_c$: work is done on the gas, heat $Q_c$ flows out to the cold reservoir.', + }, + }, + { + id: 'b4', + speak: { + fr: '**Compression adiabatique** : le gaz remonte de $T_c$ à $T_h$ — le cycle se referme.', + en: '**Adiabatic compression**: the gas warms back from $T_c$ to $T_h$ — the cycle closes.', + }, + }, + { + id: 'b5', + speak: { + fr: "Le **travail net** $W = Q_h - Q_c$ est l'aire du cycle sur le diagramme $P$–$V$. Le rendement $\eta = 1 - T_c/T_h$ est le maximum permis par le 2ᵉ principe.", + en: '**Net work** $W = Q_h - Q_c$ is the cycle area on the $P$–$V$ diagram. Efficiency $\eta = 1 - T_c/T_h$ is the second-law maximum.', + }, + }, + ], +} diff --git a/memento-note/lib/simulators/carnot-cycle.ts b/memento-note/lib/simulators/carnot-cycle.ts new file mode 100644 index 0000000..71e736a --- /dev/null +++ b/memento-note/lib/simulators/carnot-cycle.ts @@ -0,0 +1,254 @@ +import type { SimulatorPlugin } from './types' + +/** + * Ideal Carnot machine between two reservoirs (2nd law). + * + * Physics (absolute temperatures only): + * - Refrigerator: COP_R = Tc/(Th−Tc), W_min = Qc/COP_R, Qh = Qc+W + * - Heat pump: COP_HP = Th/(Th−Tc) = COP_R+1, W_min = Qh/COP_HP + * - Engine: η = 1−Tc/Th, W_out = η·Qh, Qc = Qh−W + * + * `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only). + * For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin. + * + * Refs: COP_R Carnot = Tc/(Th−Tc); 1st law Qh=Qc+W; energy↔power interchangeable + * if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators). + */ +export const carnotCycleSimulator: SimulatorPlugin = { + family: 'sim', + id: 'carnot-cycle', + title: { + fr: 'Machine de Carnot (frigo / PAC / moteur)', + en: 'Carnot machine (fridge / heat pump / engine)', + }, + summary: { + fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(Th−Tc), COP pompe à chaleur Th/(Th−Tc), rendement moteur η=1−Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.', + en: 'Carnot limits between two reservoirs: fridge COP Tc/(Th−Tc), heat-pump COP Th/(Th−Tc), engine η=1−Tc/Th, minimum work or power. 1st and 2nd laws.', + }, + keywords: [ + 'carnot', + 'thermodynamique', + 'thermodynamics', + 'cop', + 'réfrigérateur', + 'frigo', + 'refrigerator', + 'pompe à chaleur', + 'heat pump', + 'moteur', + 'engine', + 'rendement', + 'efficiency', + 'watt', + 'puissance', + 'power', + 'deuxième principe', + 'second law', + ], + params: [ + { + id: 't_cold', + symbol: 'T_c', + label: { fr: 'Source froide', en: 'Cold reservoir' }, + min: 200, + max: 320, + step: 1, + defaultValue: 260, + unit: 'K', + intent: 'cache', + }, + { + id: 't_hot', + symbol: 'T_h', + label: { fr: 'Source chaude', en: 'Hot reservoir' }, + min: 273, + max: 400, + step: 1, + defaultValue: 300, + unit: 'K', + intent: 'warning', + }, + { + id: 'q_cold', + symbol: 'Q_c', + label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' }, + min: 10, + max: 500, + step: 5, + defaultValue: 100, + unit: 'kJ', + intent: 'flow', + }, + ], + outputs: [ + { + id: 'cop_fridge', + symbol: '\\mathrm{COP}_{R}', + label: { fr: 'COP réfrigérateur', en: 'Fridge COP' }, + intent: 'output', + digits: 2, + }, + { + id: 'cop_hp', + symbol: '\\mathrm{COP}_{HP}', + label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' }, + intent: 'output', + digits: 2, + }, + { + id: 'eta', + symbol: '\\eta', + label: { fr: 'Rendement moteur', en: 'Engine efficiency' }, + unit: '%', + intent: 'highlight', + digits: 1, + }, + { + id: 'w_min', + symbol: 'W', + label: { fr: 'Travail (énergie)', en: 'Work (energy)' }, + unit: 'kJ', + intent: 'compute', + digits: 1, + }, + { + id: 'q_hot', + symbol: 'Q_h', + label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' }, + unit: 'kJ', + intent: 'flow', + digits: 1, + }, + ], + compute(env) { + const tc = env.t_cold + const th = env.t_hot + const qc = env.q_cold + if (!(th > tc) || !(qc > 0)) { + return { + cop_fridge: NaN, + cop_hp: NaN, + eta: NaN, + w_min: NaN, + q_hot: NaN, + } + } + const copFridge = tc / (th - tc) + const copHp = th / (th - tc) + const eta = 1 - tc / th + const wMin = qc / copFridge + return { + cop_fridge: copFridge, + cop_hp: copHp, + eta: eta * 100, + w_min: wMin, + q_hot: qc + wMin, + } + }, +} + +/** Operating mode for the interactive view (UI-only; physics shared). */ +export type CarnotMode = 'fridge' | 'heat_pump' | 'engine' + +/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */ +export type CarnotQuantity = 'energy' | 'power' + +export type CarnotPhysics = { + ok: boolean + tc: number + th: number + copR: number + copHP: number + eta: number + /** Heat exchanged with cold reservoir (magnitude > 0). */ + qc: number + /** Heat exchanged with hot reservoir (magnitude > 0). */ + qh: number + /** Work magnitude > 0 (input for fridge/PAC, output for engine). */ + w: number + /** Reversible check: Qc/Tc ≈ Qh/Th */ + entropyOk: boolean +} + +/** + * Resolve magnitudes for the selected mode. + * `load` is the primary useful quantity: + * - fridge: Qc extracted from cold + * - heat_pump: Qh delivered to hot + * - engine: Qh absorbed from hot + */ +export function resolveCarnotPhysics( + tc: number, + th: number, + load: number, + mode: CarnotMode +): CarnotPhysics { + if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) { + return { + ok: false, + tc, + th, + copR: NaN, + copHP: NaN, + eta: NaN, + qc: NaN, + qh: NaN, + w: NaN, + entropyOk: false, + } + } + const copR = tc / (th - tc) + const copHP = th / (th - tc) + const eta = 1 - tc / th + + let qc: number + let qh: number + let w: number + if (mode === 'fridge') { + qc = load + w = qc / copR + qh = qc + w + } else if (mode === 'heat_pump') { + qh = load + w = qh / copHP + qc = qh - w + } else { + qh = load + w = eta * qh + qc = qh - w + } + + const ratioC = qc / tc + const ratioH = qh / th + const entropyOk = + Number.isFinite(ratioC) && + Number.isFinite(ratioH) && + Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6 + + return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk } +} + +/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */ +export function fridgeLoadFromModeLoad( + tc: number, + th: number, + modeLoad: number, + mode: CarnotMode +): number { + if (!(th > tc) || !(modeLoad > 0)) return modeLoad + if (mode === 'fridge') return modeLoad + if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th + return modeLoad * (tc / th) // engine: Qc = Qh · (1−η) = Qh · Tc/Th +} + +export function modeLoadFromFridgeLoad( + tc: number, + th: number, + fridgeQc: number, + mode: CarnotMode +): number { + if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc + if (mode === 'fridge') return fridgeQc + if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc + return fridgeQc * (th / tc) // engine Qh = Qc / (1−η) = Qc · Th/Tc +} diff --git a/memento-note/lib/simulators/index.ts b/memento-note/lib/simulators/index.ts new file mode 100644 index 0000000..73aa3a1 --- /dev/null +++ b/memento-note/lib/simulators/index.ts @@ -0,0 +1,65 @@ +import type { IntentId } from '@/lib/interactive-demo/types' +import { carnotCycleSimulator } from './carnot-cycle' +import { carnotCycleAnim } from './carnot-cycle-anim' +import { tsDiagramAnim } from './ts-diagram' +import type { AnimPlugin, AnyPlugin, SimulatorPlugin } from './types' + +export const GENERIC_SIM_ID = 'generic-formula' as const + +const REGISTRY: Record = { + [carnotCycleSimulator.id]: carnotCycleSimulator, + [carnotCycleAnim.id]: carnotCycleAnim, + [tsDiagramAnim.id]: tsDiagramAnim, +} + +export function getPlugin(id: string): AnyPlugin | null { + return REGISTRY[id] ?? null +} + +export function getSimulator(id: string): SimulatorPlugin | null { + const p = REGISTRY[id] + return p?.family === 'sim' ? p : null +} + +export function getAnimPlugin(id: string): AnimPlugin | null { + const p = REGISTRY[id] + return p?.family === 'anim' ? p : null +} + +export function isCatalogSimId(id: string): boolean { + return id !== GENERIC_SIM_ID && id in REGISTRY +} + +export function listSimulators(): AnyPlugin[] { + return Object.values(REGISTRY) +} + +/** + * Compact catalog injected into the section-generation prompt so the LLM + * can pick a curated plugin (sim or anim) and bind note values into a preset. + */ +export function catalogForPrompt(lang: string): string { + const fr = lang.startsWith('fr') + const catalog = listSimulators().map((plugin) => ({ + simId: plugin.id, + family: plugin.family, + summary: fr ? plugin.summary.fr : plugin.summary.en, + keywords: plugin.keywords.slice(0, 10), + ...(plugin.family === 'sim' + ? { + params: plugin.params.map((p) => ({ + id: p.id, + symbol: p.symbol, + range: [p.min, p.max], + default: p.defaultValue, + unit: p.unit, + })), + outputs: plugin.outputs.map((o) => o.symbol), + } + : {}), + })) + return JSON.stringify(catalog, null, 1) +} + +export type { SimulatorPlugin, AnimPlugin, AnyPlugin, AnimBeat, SimI18n, SimParamDef, SimOutputDef } from './types' +export type { IntentId } diff --git a/memento-note/lib/simulators/ts-diagram.ts b/memento-note/lib/simulators/ts-diagram.ts new file mode 100644 index 0000000..9ed3144 --- /dev/null +++ b/memento-note/lib/simulators/ts-diagram.ts @@ -0,0 +1,74 @@ +import type { AnimPlugin } from './types' + +/** + * T–s diagram of the Carnot cycle — the canonical 2nd-law diagram: + * isotherms are horizontal, adiabatics vertical, heat = area. + * Same 5 phases as carnot-cycle-anim (piston) so both tell one story. + */ +export const tsDiagramAnim: AnimPlugin = { + family: 'anim', + id: 'ts-diagram', + title: { + fr: 'Le cycle de Carnot sur le diagramme T–s', + en: 'The Carnot cycle on the T–s diagram', + }, + summary: { + fr: 'Diagramme température–entropie (T–s) du cycle de Carnot : isothermes horizontales, adiabatiques verticales, aires = chaleurs Q_h et Q_c, aire du cycle = travail W. 2ᵉ principe, entropie, rendement.', + en: 'Temperature–entropy (T–s) diagram of the Carnot cycle: horizontal isotherms, vertical adiabatics, areas = heats Q_h and Q_c, cycle area = work W. Second law, entropy, efficiency.', + }, + keywords: [ + 'carnot', + 'entropie', + 'entropy', + 'diagramme t-s', + 't-s diagram', + 'thermodynamique', + 'thermodynamics', + 'deuxième principe', + 'second law', + 'rendement', + 'efficiency', + 'cycle', + ], + disclaimer: { + fr: 'Diagramme pédagogique — grandeurs illustratives.', + en: 'Teaching diagram — illustrative values.', + }, + beats: [ + { + id: 'b1', + speak: { + fr: '**Détente isotherme** à $T_h$ : l’entropie croît, la chaleur $Q_h = T_h \\Delta s$ est l’aire sous l’isotherme.', + en: '**Isothermal expansion** at $T_h$: entropy grows, heat $Q_h = T_h \\Delta s$ is the area under the isotherm.', + }, + }, + { + id: 'b2', + speak: { + fr: '**Détente adiabatique** : verticale — l’entropie est constante, la température chute de $T_h$ à $T_c$.', + en: '**Adiabatic expansion**: vertical line — entropy is constant, temperature drops from $T_h$ to $T_c$.', + }, + }, + { + id: 'b3', + speak: { + fr: '**Compression isotherme** à $T_c$ : l’entropie décroît, la chaleur $Q_c = T_c \\Delta s$ est rejetée — l’aire bleue.', + en: '**Isothermal compression** at $T_c$: entropy decreases, heat $Q_c = T_c \\Delta s$ is rejected — the blue area.', + }, + }, + { + id: 'b4', + speak: { + fr: '**Compression adiabatique** : remontée verticale de $T_c$ à $T_h$ — le rectangle se referme.', + en: '**Adiabatic compression**: vertical climb from $T_c$ back to $T_h$ — the rectangle closes.', + }, + }, + { + id: 'b5', + speak: { + fr: 'Le **travail net** $W = (T_h - T_c)\\Delta s$ est l’aire du rectangle. Rapport des aires = rendement $\\eta = 1 - T_c/T_h$ — le maximum du 2ᵉ principe.', + en: '**Net work** $W = (T_h - T_c)\\Delta s$ is the rectangle area. Area ratio = efficiency $\\eta = 1 - T_c/T_h$ — the second-law maximum.', + }, + }, + ], +} diff --git a/memento-note/lib/simulators/types.ts b/memento-note/lib/simulators/types.ts new file mode 100644 index 0000000..00a19ef --- /dev/null +++ b/memento-note/lib/simulators/types.ts @@ -0,0 +1,72 @@ +import type { IntentId } from '@/lib/interactive-demo/types' + +/** Bilingual label — page content follows the note's language, not the UI locale. */ +export type SimI18n = { fr: string; en: string } + +export type SimParamDef = { + id: string + /** KaTeX symbol (without $…$), e.g. "T_c" */ + symbol: string + label: SimI18n + min: number + max: number + step: number + defaultValue: number + unit?: string + intent?: IntentId +} + +export type SimOutputDef = { + id: string + /** KaTeX symbol (without $…$), e.g. "\\mathrm{COP}" */ + symbol: string + label: SimI18n + unit?: string + intent?: IntentId + /** Fraction digits for display (default 2). */ + digits?: number +} + +/** + * A curated interactive simulator plugin (catalog). + * The AI never writes simulation code — it picks a plugin and a preset. + * `compute` must be pure and deterministic; it runs client-side on every + * slider move and once server-side at validation time. + */ +export type SimulatorPlugin = { + family: 'sim' + id: string + title: SimI18n + /** Shown to the LLM for content matching. */ + summary: SimI18n + keywords: string[] + params: SimParamDef[] + outputs: SimOutputDef[] + compute(env: Record): Record +} + +// ── Animated pedagogical scenes (Play/Step narration over a coded scene) ──── + +export type AnimBeat = { + id: string + speak: SimI18n +} + +/** + * A curated ANIMATION plugin: a hand-coded parametric scene (like the demos + * on distill.pub / the Kimi AttnRes page) driven beat-by-beat by the shared + * player chrome. The view component receives `stepIndex` and renders the + * scene state — transitions are CSS (transform/opacity) only. + */ +export type AnimPlugin = { + family: 'anim' + id: string + title: SimI18n + summary: SimI18n + keywords: string[] + disclaimer?: SimI18n + /** Narration beats; step N of the player = beat N. */ + beats: AnimBeat[] +} + +export type AnyPlugin = SimulatorPlugin | AnimPlugin diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 1d8d6e8..ab7a265 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -38,7 +38,24 @@ "createYourSpaceSubtitle": "Join the new era of smart note-taking.", "forgot": "Forgot?", "backToSite": "Back to site", - "privacyTerms": "© 2025 Memento Labs — Privacy · Terms" + "privacyTerms": "© 2025 Memento Labs — Privacy · Terms", + "checkEmailTitle": "Check your email", + "checkEmailDescription": "We sent a confirmation link to {email}. Open it to activate your account before signing in.", + "checkEmailDescriptionGeneric": "We sent a confirmation link to your email. Open it to activate your account before signing in.", + "resendVerification": "Resend confirmation email", + "verifyResent": "Confirmation email sent. Check your inbox.", + "verifyResendFailed": "Could not send the confirmation email. Try again later.", + "verifyMissingEmail": "Enter your email address.", + "verifyLoading": "Confirming your email…", + "verifySuccessTitle": "Email confirmed", + "verifySuccessDescription": "Your account is ready. You can now sign in.", + "verifyExpiredTitle": "Link expired", + "verifyExpiredDescription": "This confirmation link has expired. Request a new one.", + "verifyInvalidTitle": "Invalid link", + "verifyInvalidDescription": "This confirmation link is invalid or already used.", + "emailNotVerified": "Please confirm your email before signing in.", + "emailVerifiedBanner": "Email confirmed. You can sign in now.", + "invalidCredentials": "Invalid email or password." }, "sidebar": { "notes": "Notes", @@ -792,6 +809,18 @@ "formal": "Formal", "casual": "Casual" }, + "interactiveDemo": { + "invalid": "Invalid interactive demo", + "empty": "Interactive demo unavailable", + "step": "step", + "speed": "Playback speed", + "needMoreText": "Select at least ~20 words (or write more content) to generate a demo", + "generating": "Generating interactive demo…", + "generateSuccess": "Interactive demo inserted", + "generateFailed": "Demo generation failed", + "insertFailed": "Could not insert the demo", + "quotaExceeded": "Not enough AI quota" + }, "memoryEcho": { "title": "I noticed something...", "description": "Proactive connections between your notes", @@ -1602,7 +1631,58 @@ "notSynced": "Not synced yet (cron /api/cron/sync-usage)", "byFeature": "By feature (PostgreSQL)", "topUsers": "Top users", - "noUsageData": "No usage data for this period yet" + "noUsageData": "No usage data for this period yet", + "healthTitle": "Stripe health check", + "healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).", + "healthSecret": "Secret key (server)", + "healthPublishable": "Publishable key", + "healthWebhook": "Webhook secret", + "healthBillingFlag": "Billing enabled", + "healthTrial": "Free trial", + "trialDaysValue": "{days} days on first checkout", + "modeTest": "Test mode (sk_test_…)", + "modeLive": "Live mode (sk_live_…)", + "modePlaceholder": "Placeholder / invalid key", + "modeMissing": "Not configured", + "configured": "Configured", + "missing": "Missing", + "enabled": "Enabled", + "disabled": "Disabled", + "priceStatusTitle": "Price IDs vs Stripe", + "colKey": "Plan", + "colPriceId": "Price ID", + "colSource": "Source", + "colStripe": "Stripe amount", + "priceError": "Lookup failed", + "inactive": "inactive", + "notChecked": "Not checked (no Stripe key)", + "subsTitle": "Subscriptions overview", + "subsDescription": "Counts from the local database (synced via Stripe webhooks).", + "statPaid": "Active + trial", + "statTrialing": "On trial", + "statPastDue": "Past due", + "statCanceling": "Cancel at period end", + "byTier": "By tier", + "byStatus": "By status", + "usersWithoutSub": "Users with no Subscription row", + "noSubs": "No subscriptions yet", + "recentSubs": "Recent paid / trial accounts", + "colUser": "User", + "colTier": "Tier", + "colStatus": "Status", + "colPeriod": "Period / trial end", + "canceling": "canceling", + "manualTier": "manual (no Stripe sub)", + "trialUntil": "Trial until {date}", + "testGuideTitle": "How to test Stripe locally", + "testGuideDescription": "Checklist to validate checkout, webhooks and trial.", + "testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.", + "testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.", + "testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.", + "testStep4": "npm run dev → open /settings/billing as a BASIC user.", + "testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.", + "testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.", + "testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode." }, "dashboard": { "title": "Dashboard", @@ -1812,6 +1892,8 @@ "featureBrainstormCreate": "Brainstorm creations", "featureBrainstormSessions": "Brainstorm sessions", "featureCharts": "AI Charts", + "featureInteractiveDemo": "Interactive demos", + "featureInteractivePage": "Interactive pages", "featurePublishEnhance": "AI Publishing", "featureBrainstormExpand": "Brainstorm expansions", "featureBrainstormEnrich": "Brainstorm enrichments", @@ -2579,6 +2661,8 @@ "slashTableDesc": "Insert a simple grid", "slashDatabase": "Structured View", "slashDatabaseDesc": "Embed your notebook's structured data", + "slashInteractiveDemo": "Interactive Demo", + "slashInteractiveDemoDesc": "AI step-by-step teaching demo (Play / Step)", "slashToggle": "Toggle Section", "slashToggleDesc": "Create a collapsible section", "slashCallout": "Callout", @@ -2726,6 +2810,21 @@ "publishTemplateMagazine": "Magazine article", "publishTemplateBrief": "Expert brief", "publishTemplateEssay": "Essay", + "publishTemplateInteractivePage": "Interactive page", + "publishInteractivePage": "Interactive page", + "publishInteractivePageHint": "AI-generated page: hero, sections, Play/Step demos — 20 credits", + "publishInteractivePageGenerating": "Building interactive page…", + "publishInteractivePageGeneratingWait": "Almost ready…", + "publishInteractivePageGeneratingLong": "Still working…", + "publishInteractivePagePlanning": "Analyzing content — planning the page…", + "publishInteractivePageSectionProgress": "Section {current}/{total}: {title}", + "publishInteractivePageFallback": "AI generation unavailable — showing simplified page", + "publishInteractivePagePartialFallback": "Some sections were generated in simplified mode", + "publishInteractivePageTooShort": "Note too short — add more content and try again", + "publishInteractivePageSuccess": "Interactive page published!", + "publishInteractivePageFailed": "Interactive page generation failed", + "publishInteractivePagePreviewHint": "Preview — review, then publish to the public URL", + "publishInteractivePageConfirm": "Publish page", "publishAiSuccess": "AI-enhanced page published!", "publishRewriteLabel": "Rewrite for the web", "publishRewriteOnHint": "Structures your editor blocks (exercises, toggles, callouts) for the web — AI writes only the intro", @@ -3381,7 +3480,11 @@ "fetchStatusFailed": "Failed to fetch billing status", "fetchQuotasFailed": "Failed to load credit usage", "fetchInvoicesFailed": "Failed to load billing history.", - "savePercent": "Save ~17%" + "savePercent": "Save ~17%", + "startTrialCta": "Start {days}-day free trial", + "trialFeature": "{days}-day free trial (card required)", + "trialEndsOn": "Your free trial ends on {date}. You will then be billed automatically.", + "trialEndsLabel": "Trial ends" }, "quotaPaywall": { "title": "Out of AI credits", @@ -3528,6 +3631,12 @@ "perMonthAnnual": "/mo, billed yearly", "perUser": "+ €3.90/user", "perUserAnnual": "+ €2.90/user, yearly", + "savePercent": "Save ~17%", + "proMonthly": "€9.90", + "proAnnualMonthly": "€8.25", + "businessMonthly": "€29.90", + "businessAnnualMonthly": "€24.92", + "enterprisePrice": "Custom", "popular": "Most chosen", "basic": { "name": "Basic", @@ -3572,7 +3681,10 @@ "feature4": "Dedicated support", "feature5": "Live onboarding" }, - "basicPrice": "Free" + "basicPrice": "Free", + "trialBadge": "{days}-day free trial", + "trialFeature": "{days}-day free trial (card required)", + "trialCta": "Start {days}-day free trial" }, "cta": { "title": "Stop losing your best ideas.", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index ffd7136..908fd0d 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -38,7 +38,24 @@ "createYourSpaceSubtitle": "Rejoignez la nouvelle ère de la prise de notes intelligente.", "forgot": "Oublié ?", "backToSite": "Retour", - "privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions" + "privacyTerms": "© 2025 Memento Labs — Confidentialité · Conditions", + "checkEmailTitle": "Vérifiez votre e-mail", + "checkEmailDescription": "Nous avons envoyé un lien de confirmation à {email}. Ouvrez-le pour activer votre compte avant de vous connecter.", + "checkEmailDescriptionGeneric": "Nous avons envoyé un lien de confirmation à votre e-mail. Ouvrez-le pour activer votre compte avant de vous connecter.", + "resendVerification": "Renvoyer l’e-mail de confirmation", + "verifyResent": "E-mail de confirmation envoyé. Vérifiez votre boîte de réception.", + "verifyResendFailed": "Impossible d’envoyer l’e-mail de confirmation. Réessayez plus tard.", + "verifyMissingEmail": "Saisissez votre adresse e-mail.", + "verifyLoading": "Confirmation de votre e-mail…", + "verifySuccessTitle": "E-mail confirmé", + "verifySuccessDescription": "Votre compte est prêt. Vous pouvez vous connecter.", + "verifyExpiredTitle": "Lien expiré", + "verifyExpiredDescription": "Ce lien de confirmation a expiré. Demandez-en un nouveau.", + "verifyInvalidTitle": "Lien invalide", + "verifyInvalidDescription": "Ce lien de confirmation est invalide ou déjà utilisé.", + "emailNotVerified": "Confirmez votre e-mail avant de vous connecter.", + "emailVerifiedBanner": "E-mail confirmé. Vous pouvez vous connecter.", + "invalidCredentials": "E-mail ou mot de passe incorrect." }, "sidebar": { "notes": "Notes", @@ -798,6 +815,18 @@ "formal": "Formel", "casual": "Décontracté" }, + "interactiveDemo": { + "invalid": "Démo interactive invalide", + "empty": "Démo interactive indisponible", + "step": "étape", + "speed": "Vitesse de lecture", + "needMoreText": "Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo", + "generating": "Génération de la démo interactive…", + "generateSuccess": "Démo interactive insérée", + "generateFailed": "Échec de la génération", + "insertFailed": "Impossible d’insérer la démo", + "quotaExceeded": "Quota IA insuffisant" + }, "memoryEcho": { "title": "💡 J'ai remarqué quelque chose...", "description": "Connexions proactives entre vos notes", @@ -1608,7 +1637,58 @@ "notSynced": "Pas encore synchronisé (cron /api/cron/sync-usage)", "byFeature": "Par fonctionnalité (PostgreSQL)", "topUsers": "Utilisateurs les plus actifs", - "noUsageData": "Aucune donnée pour cette période" + "noUsageData": "Aucune donnée pour cette période", + "healthTitle": "État Stripe", + "healthDescription": "Statut des clés, webhooks, price IDs et flag facturation (les secrets ne sont jamais affichés).", + "healthSecret": "Clé secrète (serveur)", + "healthPublishable": "Clé publique", + "healthWebhook": "Secret webhook", + "healthBillingFlag": "Facturation activée", + "healthTrial": "Essai gratuit", + "trialDaysValue": "{days} jours au premier checkout", + "modeTest": "Mode test (sk_test_…)", + "modeLive": "Mode live (sk_live_…)", + "modePlaceholder": "Clé placeholder / invalide", + "modeMissing": "Non configurée", + "configured": "Configuré", + "missing": "Manquant", + "enabled": "Activé", + "disabled": "Désactivé", + "priceStatusTitle": "Price IDs vs Stripe", + "colKey": "Offre", + "colPriceId": "Price ID", + "colSource": "Source", + "colStripe": "Montant Stripe", + "priceError": "Échec lecture", + "inactive": "inactif", + "notChecked": "Non vérifié (pas de clé Stripe)", + "subsTitle": "Vue des abonnements", + "subsDescription": "Compteurs depuis la base locale (synchronisée via webhooks Stripe).", + "statPaid": "Actifs + essai", + "statTrialing": "En essai", + "statPastDue": "Impayés", + "statCanceling": "Résiliation fin de période", + "byTier": "Par tier", + "byStatus": "Par statut", + "usersWithoutSub": "Utilisateurs sans ligne Subscription", + "noSubs": "Aucun abonnement pour l’instant", + "recentSubs": "Comptes payants / essai récents", + "colUser": "Utilisateur", + "colTier": "Tier", + "colStatus": "Statut", + "colPeriod": "Période / fin d’essai", + "canceling": "résiliation", + "manualTier": "manuel (pas de sub Stripe)", + "trialUntil": "Essai jusqu’au {date}", + "testGuideTitle": "Comment tester Stripe en local", + "testGuideDescription": "Checklist pour valider checkout, webhooks et essai.", + "testStep1": "Stripe Dashboard → mode Test ON. Créer produits Pro/Business + prix mensuel/annuel + packs crédits.", + "testStep2": "Mettre sk_test_…, pk_test_… dans .env. Mettre les price_… dans Admin → Facturation (ou env) et activer la facturation.", + "testStep3": "Copier le whsec_… dans STRIPE_WEBHOOK_SECRET et redémarrer l’app.", + "testStep4": "npm run dev → ouvrir /settings/billing avec un compte BASIC.", + "testStep5": "Lancer le checkout Pro. Carte : 4242 4242 4242 4242, date future, CVC quelconque. Essai 7 jours attendu.", + "testStep6": "Vérifier Admin → Facturation (TRIALING) et /settings/billing (date de fin d’essai).", + "testCardHint": "Autres cartes : 4000000000009995 = échec paiement · 4000002500003155 = 3D Secure. Jamais de vraie carte en mode test." }, "dashboard": { "title": "Tableau de bord", @@ -1818,6 +1898,8 @@ "featureBrainstormCreate": "Créations brainstorm", "featureBrainstormSessions": "Sessions brainstorm", "featureCharts": "Graphiques IA", + "featureInteractiveDemo": "Démos interactives", + "featureInteractivePage": "Pages interactives", "featurePublishEnhance": "Publication IA", "featureBrainstormExpand": "Extensions brainstorm", "featureBrainstormEnrich": "Enrichissements brainstorm", @@ -2585,6 +2667,8 @@ "slashTableDesc": "Insérer un tableau simple", "slashDatabase": "Vue structurée", "slashDatabaseDesc": "Intégrer les données structurées de votre carnet", + "slashInteractiveDemo": "Démo interactive", + "slashInteractiveDemoDesc": "Démo pédagogique IA étape par étape (Play / Step)", "slashToggle": "Section repliable", "slashToggleDesc": "Créer une section dépliable", "slashCallout": "Encadré", @@ -2732,6 +2816,21 @@ "publishTemplateMagazine": "Article magazine", "publishTemplateBrief": "Fiche expert", "publishTemplateEssay": "Essai", + "publishTemplateInteractivePage": "Page interactive", + "publishInteractivePage": "Page interactive", + "publishInteractivePageHint": "Page générée par IA : hero, sections, démos Play/Step — 20 crédits", + "publishInteractivePageGenerating": "Construction de la page…", + "publishInteractivePageGeneratingWait": "Presque prêt…", + "publishInteractivePageGeneratingLong": "Toujours en cours…", + "publishInteractivePagePlanning": "Analyse du contenu — plan de la page…", + "publishInteractivePageSectionProgress": "Section {current}/{total} : {title}", + "publishInteractivePageFallback": "Génération IA indisponible — page simplifiée affichée", + "publishInteractivePagePartialFallback": "Certaines sections ont été générées en mode simplifié", + "publishInteractivePageTooShort": "Note trop courte — ajoutez du contenu puis réessayez", + "publishInteractivePageSuccess": "Page interactive publiée !", + "publishInteractivePageFailed": "Échec de la page interactive", + "publishInteractivePagePreviewHint": "Aperçu — vérifiez puis publiez sur l’URL publique", + "publishInteractivePageConfirm": "Publier la page", "publishAiSuccess": "Page publiée avec mise en page IA !", "publishRewriteLabel": "Reformuler pour le web", "publishRewriteOnHint": "Structure vos blocs éditeur (exercices, toggles, encadrés) en page web — l'IA rédige seulement le chapô", @@ -3387,7 +3486,11 @@ "fetchStatusFailed": "Échec du chargement des informations de facturation", "fetchQuotasFailed": "Échec du chargement des crédits", "fetchInvoicesFailed": "Impossible de charger l'historique de facturation.", - "savePercent": "Économisez ~17%" + "savePercent": "Économisez ~17%", + "startTrialCta": "Essai gratuit {days} jours", + "trialFeature": "Essai gratuit {days} jours (carte requise)", + "trialEndsOn": "Votre essai gratuit se termine le {date}. Vous serez ensuite facturé automatiquement.", + "trialEndsLabel": "Fin de l'essai" }, "quotaPaywall": { "title": "Plus de crédits IA", @@ -3534,6 +3637,12 @@ "perMonthAnnual": "/mois, facturé à l'année", "perUser": "+ 3,90€/user", "perUserAnnual": "+ 2,90€/user, à l'année", + "savePercent": "~17 %", + "proMonthly": "9,90€", + "proAnnualMonthly": "8,25€", + "businessMonthly": "29,90€", + "businessAnnualMonthly": "24,92€", + "enterprisePrice": "Sur devis", "popular": "Le plus choisi", "basic": { "name": "Basic", @@ -3578,7 +3687,10 @@ "feature4": "Support dédié", "feature5": "Onboarding live" }, - "basicPrice": "Gratuit" + "basicPrice": "Gratuit", + "trialBadge": "Essai gratuit {days} jours", + "trialFeature": "Essai gratuit {days} jours (carte requise)", + "trialCta": "Essayer {days} jours gratuitement" }, "cta": { "title": "Arrêtez de perdre vos meilleures idées.", diff --git a/memento-note/tests/unit/interactive-demo-validate.test.ts b/memento-note/tests/unit/interactive-demo-validate.test.ts new file mode 100644 index 0000000..c918537 --- /dev/null +++ b/memento-note/tests/unit/interactive-demo-validate.test.ts @@ -0,0 +1,274 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, test } from 'vitest' +import { + assertTranslatePreservesStructure, + heatmapCellIds, + resolveActFinalState, + validateInteractiveDemo, + type InteractiveDemoV1, +} from '../../lib/interactive-demo' + +const fixturePath = join( + __dirname, + '../../lib/interactive-demo/fixtures/attnres.demo.json' +) + +function loadFixture(): InteractiveDemoV1 { + return JSON.parse(readFileSync(fixturePath, 'utf8')) as InteractiveDemoV1 +} + +function clone(v: T): T { + return JSON.parse(JSON.stringify(v)) as T +} + +describe('validateInteractiveDemo', () => { + test('AttnRes fixture passes (positive)', () => { + const result = validateInteractiveDemo(loadFixture()) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.demo.id).toBe('demo.attnres') + expect(result.demo.lang).toBe('fr') + expect(result.demo.schemaVersion).toBe(1) + } + }) + + test('allows hex mentioned in human speak (no false positive)', () => { + const demo = clone(loadFixture()) + demo.acts[0].steps[0].speak = + 'La couleur #FF0000 signale l\'erreur ; aussi rgb(20,20,20).' + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(true) + }) + + test('rejects hex color in non-human structural field', () => { + const demo = clone(loadFixture()) + // Force a structural string via chartType bypass — use node id? ids can't be hex easily. + // Inject forbidden color into a future-proof structural key by mutating after parse path: + // Put hex in edge style is enum-only; use disclaimer is human. + // Put on a custom field that round-trips through JSON under panels payload as unknown — Zod strips unknown. + // Use annotate text is human. So inject via re-validate raw object with hex in `id` of a node: + demo.scene.panels[0] = { + id: 'panel.trunk', + type: 'svg-scene', + payload: { + nodes: [ + { id: 'residualTrunk', label: 'ok' }, + { id: '#ff0000', label: 'bad id that is also hex-like' }, + ], + }, + } + // Wait — id "#ff0000" matches FORBIDDEN_COLOR_RE when scanning the id string. + // But refs will also break. Better: add hex only as scanned string on a kept structural key. + // chartType is enum. triangular is enum. + // Scan walks all non-human strings — node `id` is structural. + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'forbidden_color')).toBe(true) + } + }) + + test('rejects unknown pattern', () => { + const demo = clone(loadFixture()) + // @ts-expect-error intentional invalid pattern + demo.acts[0].steps[0].pattern = 'zoomIntoDetail' + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.path.includes('pattern'))).toBe(true) + } + }) + + test('rejects unknown chartType', () => { + const demo = clone(loadFixture()) + const panel = demo.scene.panels[1] + if (panel.type === 'chart') { + // @ts-expect-error intentional + panel.payload.chartType = 'pie3D' + } + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.path.includes('chartType'))).toBe(true) + } + }) + + test('rejects pointTo to missing element id', () => { + const demo = clone(loadFixture()) + demo.acts[0].steps[0].pointTo = ['doesNotExist'] + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'unknown_element_id')).toBe(true) + } + }) + + test('rejects step id not matching act.id.sN', () => { + const demo = clone(loadFixture()) + demo.acts[0].steps[0].id = 's1' + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'step_id_format')).toBe(true) + } + }) + + test('rejects more than 5 scenes', () => { + const demo = clone(loadFixture()) + const extraScene = clone(demo.scene) + for (let i = 0; i < 4; i++) { + demo.acts.push({ + id: `a${10 + i}`, + title: `Extra ${i}`, + scene: { ...extraScene, id: `scene.extra${i}` }, + steps: [ + { + id: `a${10 + i}.s1`, + speak: 'Extra step for scene cap.', + pattern: 'overview', + }, + ], + }) + } + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'too_many_scenes')).toBe(true) + } + }) + + test('rejects more than 12 steps per act', () => { + const demo = clone(loadFixture()) + const act = demo.acts[0] + while (act.steps.length < 13) { + const n = act.steps.length + 1 + act.steps.push({ + id: `a1.s${n}`, + speak: `Étape de remplissage ${n}.`, + pattern: 'overview', + }) + } + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect( + result.issues.some( + (i) => + i.path.includes('steps') || + i.message.toLowerCase().includes('array') || + i.code === 'too_big' + ) + ).toBe(true) + } + }) + + test('rejects annotate that overlaps pointTo in same step', () => { + const demo = clone(loadFixture()) + demo.acts[0].steps[0].annotate = [ + { + kind: 'arrow', + targetIds: ['e.L1.h'], + scope: 'act', + }, + ] + const result = validateInteractiveDemo(demo) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'annotate_pointto_overlap')).toBe( + true + ) + } + }) +}) + +describe('assertTranslatePreservesStructure', () => { + test('accepts lang + speak change with same ids', () => { + const source = loadFixture() + const translated = clone(source) + translated.lang = 'en' + translated.acts[0].title = 'The Problem — Depth Dilution' + translated.acts[0].steps[0].speak = + 'Each layer enters the residual trunk with a **fixed ×1 coefficient**.' + const result = assertTranslatePreservesStructure(source, translated) + expect(result.ok).toBe(true) + }) + + test('rejects translated variant that mutates an element id', () => { + const source = loadFixture() + const translated = clone(source) + translated.lang = 'en' + translated.scene.panels[0] = { + ...translated.scene.panels[0], + type: 'svg-scene', + payload: { + nodes: [ + { id: 'residualTrunkMUTATED', label: 'Residual trunk' }, + { id: 'L1', label: 'Layer 1' }, + { id: 'L2', label: 'Layer 2' }, + ], + edges: [ + { + id: 'e.L1.h', + from: 'L1', + to: 'residualTrunkMUTATED', + style: 'solid', + weight: 1, + intent: 'flow', + }, + ], + }, + } + translated.acts[0].steps[0].pointTo = ['residualTrunkMUTATED', 'e.L1.h'] + const result = assertTranslatePreservesStructure(source, translated) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe( + true + ) + } + }) + + test('rejects mutation of unknown structural string key (default-keep)', () => { + const source = clone(loadFixture()) as InteractiveDemoV1 & { + scene: InteractiveDemoV1['scene'] & { layout?: string } + } + const translated = clone(source) as typeof source + // Simulate a future structural field present on both, then mutated on translate + ;(source.scene as { layout?: string }).layout = 'horizontal' + ;(translated.scene as { layout?: string }).layout = 'vertical' + translated.lang = 'en' + const result = assertTranslatePreservesStructure( + source as InteractiveDemoV1, + translated as InteractiveDemoV1 + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe( + true + ) + } + }) +}) + +describe('resolveInteractiveDemo', () => { + test('heatmap lower-triangular has 36 addressable cells', () => { + expect(heatmapCellIds(8, 8, 'lower')).toHaveLength(36) + }) + + test('a2 final state after s7: 36 cells revealed + 4 badges', () => { + const demo = loadFixture() + const final = resolveActFinalState(demo, 'a2') + expect(final).toBeDefined() + const revealedIds = Object.keys(final!.revealed) + expect(revealedIds).toHaveLength(36) + expect(revealedIds.every((id) => /^r\d+\.c\d+$/.test(id))).toBe(true) + + const badges = final!.annotations.filter((a) => a.kind === 'badge') + expect(badges).toHaveLength(4) + expect(badges.map((b) => b.badgeIndex)).toEqual([1, 2, 3, 4]) + expect(badges.map((b) => b.targetIds[0]).sort()).toEqual( + ['r2.c2', 'r5.c4', 'r7.c1', 'r8.c1'].sort() + ) + }) +}) diff --git a/memento-note/tests/unit/interactive-page-validate.test.ts b/memento-note/tests/unit/interactive-page-validate.test.ts new file mode 100644 index 0000000..f2a5ed9 --- /dev/null +++ b/memento-note/tests/unit/interactive-page-validate.test.ts @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, test } from 'vitest' +import { + validateInteractivePage, + type PageSpecV1, +} from '../../lib/interactive-page' + +const fixturePath = join( + __dirname, + '../../lib/interactive-page/fixtures/thermo-page.json' +) + +function loadFixture(): PageSpecV1 { + return JSON.parse(readFileSync(fixturePath, 'utf8')) as PageSpecV1 +} + +function clone(v: T): T { + return JSON.parse(JSON.stringify(v)) as T +} + +describe('validateInteractivePage', () => { + test('thermo fixture passes (positive)', () => { + const result = validateInteractivePage(loadFixture()) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.page.id).toBe('page.thermo-test') + expect(result.page.schemaVersion).toBe(1) + expect(result.page.sections.length).toBeGreaterThanOrEqual(3) + } + }) + + test('rejects unknown block type', () => { + const page = clone(loadFixture()) as Record + const sections = page.sections as Array<{ blocks: unknown[] }> + sections[0].blocks.push({ type: 'carousel', items: [] }) + const result = validateInteractivePage(page) + expect(result.ok).toBe(false) + if (!result.ok) { + expect( + result.issues.some( + (i) => + i.path.includes('blocks') || + i.code === 'invalid_union_discriminator' || + i.message.toLowerCase().includes('discriminat') + ) + ).toBe(true) + } + }) + + test('rejects hex color in non-human structural field', () => { + const page = clone(loadFixture()) + // intent is enum — inject hex via overview card by forcing raw object + const raw = clone(loadFixture()) as Record + const overview = raw.overview as { + cards: Array> + } + overview.cards[0].intent = '#ff0000' + const result = validateInteractivePage(raw) + expect(result.ok).toBe(false) + }) + + test('rejects duplicate section id', () => { + const page = clone(loadFixture()) + page.sections[1].id = page.sections[0].id + const result = validateInteractivePage(page) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'duplicate_section_id')).toBe( + true + ) + } + }) + + test('rejects invalid embedded demo (delegation)', () => { + const page = clone(loadFixture()) + const demoBlock = page.sections + .flatMap((s) => s.blocks) + .find((b) => b.type === 'demo') + expect(demoBlock?.type).toBe('demo') + if (demoBlock?.type === 'demo') { + // @ts-expect-error intentional + demoBlock.demo.acts[0].steps[0].pattern = 'notARealPattern' + } + const result = validateInteractivePage(page) + expect(result.ok).toBe(false) + if (!result.ok) { + expect( + result.issues.some( + (i) => + i.path.includes('demo') || + i.code.startsWith('demo_') || + i.path.includes('pattern') + ) + ).toBe(true) + } + }) + + test('rejects table row width mismatch', () => { + const page = clone(loadFixture()) + const table = page.sections + .flatMap((s) => s.blocks) + .find((b) => b.type === 'table') + expect(table?.type).toBe('table') + if (table?.type === 'table') { + table.rows[0] = ['only-one'] + } + const result = validateInteractivePage(page) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.issues.some((i) => i.code === 'table_shape')).toBe(true) + } + }) + + test('allows hex mentioned in human prose', () => { + const page = clone(loadFixture()) + const prose = page.sections[0].blocks.find((b) => b.type === 'prose') + if (prose?.type === 'prose') { + prose.md = 'La couleur #FF0000 est un signal d’alarme.' + } + const result = validateInteractivePage(page) + expect(result.ok).toBe(true) + }) +})