2 Commits

Author SHA1 Message Date
Antigravity
80ccc1f6de feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00
Antigravity
69c99e4f4f feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:51:43 +00:00
136 changed files with 15951 additions and 439 deletions

45
.impeccable/config.json Normal file
View File

@@ -0,0 +1,45 @@
{
"buildPath": "comp",
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "ai-color-palette",
"value": "*",
"files": [
"memento-note/components/intelligence-hub.tsx"
],
"createdAt": "2026-08-26T18:03:40.105Z",
"reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA."
},
{
"rule": "side-tab",
"value": "*",
"files": [
"memento-note/components/note-editor/note-editor-toolbar.tsx"
],
"createdAt": "2026-08-26T18:14:54.764Z",
"reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI."
},
{
"rule": "border-accent-on-rounded",
"value": "*",
"files": [
"memento-note/app/(main)/insights/page.tsx"
],
"createdAt": "2026-08-26T18:23:06.241Z",
"reason": "Styles existants de /insights (incumbent), pas une nouvelle carte."
},
{
"rule": "gray-on-color",
"value": "*",
"files": [
"memento-note/app/(main)/insights/page.tsx"
],
"createdAt": "2026-08-26T18:23:06.270Z",
"reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris."
}
]
}
}

View File

@@ -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+`, 280560px, 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 quune 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``<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **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) ; unités température commutables **K / °C / °F** ; distinguer **énergie (kJ)** et **puissance (W)** ; modes **frigo / PAC / moteur** ; **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}` ; **simulateur Carnot** : `lib/simulators/carnot-cycle*.ts` + `components/simulators/carnot-cycle*.tsx` (modes frigo/PAC/moteur, unités K/°C/°F, énergie kJ vs puissance W) ; **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).

88
PRODUCT.md Normal file
View File

@@ -0,0 +1,88 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Personnes qui capturent des idées, notes et savoirs au fil du temps et ont besoin de les retrouver, les relier et les actionner — pas seulement de les stocker.
Audiences confirmées (sans hiérarchie exclusive) :
- étudiants et chercheurs (cours, papers, synthèses, révision) ;
- freelances et professionnels indépendants (projets, briefs, clients) ;
- équipes (partage de contexte, agents, Second Brain collectif selon le palier) ;
- personnes « lambda » qui veulent un second cerveau accessible, pas un outil expert-only.
Situation type : trop de notes / dossiers, recherche trop littérale, idées perdues ; le job est de transformer la capture quotidienne en système vivant (liens, briefing, révision, actions).
## Product Purpose
**Memento** est une application de prise de notes intelligente positionnée comme **Second Brain** : elle relie les idées dans le temps, fait émerger des connexions cachées, et transforme les carnets en système searchable, briefable et actionnable (matin / routines).
Succès produit : lutilisateur écrit dans Memento et le système lui rend de la valeur *pendant* et *après* lécriture (Memory Echo, insights, dashboard, flashcards, agents) — pas une pile morte de fichiers.
## Positioning
Pas « une app de notes de plus ». Mécanisme différenciant : un Second Brain qui **se connecte pendant que vous écrivez** — similarité sémantique réelle, ponts entre notes/carnets, aperçu côte à côte, dashboard Second Brain (briefing, pistes, checklist), agents qui **écrivent dans les carnets**, recherche sémantique, révision espacée — plutôt quun chat jetable ou un classement manuel de dossiers.
Un concurrent « Keep/Notion générique » ne peut pas honnêtement reprendre la promesse *Second Brain vivant qui se connecte tout seul* sans ces mécanismes.
## Operating Context
- App web principale : `memento-note/` (Next.js App Router), URL prod `https://memento-note.com`.
- Capture & édition : notes riche / Markdown, carnets, tags, structured views (table/kanban), peek split note liée, publication web, pages interactives.
- Second Brain au quotidien : dashboard `/home`, Memory Echo dans léditeur, `/insights` (clusters sémantiques), `/revision` (flashcards SM-2), agents, rappels, scan Gmail (vols/colis/abonnements).
- Référence UX métier / prototype : `architectural-grid/` (et base `architectural-grid1/`) — à consulter avant implémentation UI significative ; la vérité produit dinterface suit le prototype quand il y a conflit dinterprétation.
- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern).
- Companion mobile Expo (`memento-mobile/`) existe ; **lautorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais nimpose pas un langage UI `adaptive` tant quil nest pas le surface principal de craft.
- Auth : priorité Google OAuth ; email/mdp avec email vérifié obligatoire.
- i18n : 15 langues ; calendrier iranien + chiffres persans + RTL pour le persan (et arabe) — app et extension.
- Facturation : paliers BASIC/PRO/BUSINESS/ENTERPRISE, quotas IA, essai abonnement **7 jours** ; BASIC sans MCP ; BYOK possible selon config.
## Capabilities and Constraints
**Capacités confirmées (non exhaustif)** : notes/carnets, recherche sémantique, Memory Echo, insights réseau, dashboard Second Brain configurable, agents, flashcards SM-2, structured views, Web Clipper, MCP (hors BASIC), publication simple/IA, pages interactives, multilinguisme.
**Contraintes durables** :
- Nom produit : **Memento** uniquement (jamais « Momento ») dans libellés, metadata, pages publiques, docs.
- Libellés UI via i18n (`memento-note/locales/*.json`) — pas de texte en dur ; pas de marques tierces dans les libellés utilisateur.
- Qualité UX prioritaire ; solutions long terme plutôt que rustines.
- Pas de perte de données : jamais de reset/drop/truncate DB sans backup vérifié + confirmation explicite (voir `CLAUDE.md` / règles projet).
- Quotas IA : réservation atomique avant appel ; chaque usage IA décompte.
- Une user story / feature à la fois, validation utilisateur avant la suivante.
- Docs de stories souvent périmées — vérifier le code avant de « (re)développer ».
**Ouvert / hors scope Impeccable init** : détails de roadmap feature-par-feature ; choix esthétiques (monde visuel → `new-work` / `document`).
## Brand Commitments
- Marque : **Memento** ; entité visible « Memento Labs » dans certains pied-de-page.
- Promesse marketing : Second Brain — « Il se souvient de ce que vous avez oublié » / se connecte pendant que vous écrivez.
- Voix : claire, concrète, orientée bénéfice utilisateur ; français correct en communication produit FR ; éviter le jargon non expliqué dans lUI.
- Référence prototype `architectural-grid` pour les flux métier (liste/carte notes, recherche, insights, sidebar, etc.) — engagement produit confirmé, pas une esthétique figée dans ce fichier.
## Evidence on Hand
- Copy landing et produit : `memento-note/locales/*.json` (ex. `landing.*`), `components/landing-page.tsx`.
- README produit : `README.fr.md` / `README.md`, guide utilisateur `docs/guide-utilisateur/`.
- Prototypes UI : `architectural-grid/`, `architectural-grid1/`.
- Extension : `memento-note/extension/`.
- Assets promo / démos : `promo-video/` (captures authentifiées, VO EN).
- **Ne pas inventer** : témoignages clients, logos presse, benchmarks chiffrés non présents dans le repo.
## Product Principles
1. **Second Brain dabord** — chaque surface renforce connexion, rappel et action ; pas le stockage pour le stockage.
2. **Valeur pendant lécriture** — Memory Echo, peek, agents et briefing doivent être exploitables dans le flux réel, pas décoratifs.
3. **Accessible à la personne lambda, assez profond pour le chercheur** — clarté et aide contextuelle avant jargon ; puissance sans exiger dêtre power-user dès le jour 1.
4. **Monde multilingue réel** — RTL, calendriers et embeddings inclus ; pas danglais hardcodé quand lUI est dans une autre langue.
5. **Preuve avant promesse** — sappuyer sur features et copy existantes ; ne pas fabriquer de preuves marketing.
## Accessibility & Inclusion
- Multilinguisme et RTL (fa/ar) sont des exigences produit, pas un bonus.
- Aide contextuelle (« ? ») et libellés lisibles attendus là où lUX le demande.
- Standard WCAG formel non fixé dans cet init : à traiter comme **ouvert** ; viser une UI utilisable clavier / contraste correcte par défaut sur le web sans inventer une certification.

View File

@@ -31,3 +31,17 @@ All BYOK items implemented on 2026-05-30:
- **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story.
- **Rate limiting absent sur `GET /api/user/export`** — vecteur dabus (exports répétés) ; hardening ultérieur.
## Deferred — revue page interactive (2026-07-24)
- Régénération section/démo (§7.3 spec origine) + UI auteur associée + tarif regen.
- Page publique interactive : `ReadingProgress` + `CopyLinkButton` (régression UX vs templates magazine/brief/essay).
- Fallback déterministe : libellés complets dans les 13 autres locales + `dir="rtl"` pour fa/ar (actuellement FR/EN seulement).
- `normalize.ts` : caps 2 démos/5 sections plus restrictifs que le validateur (5/8) — aligner si latence LLM acceptable.
- `validateImageSrc` : autoriser uniquement `/uploads` + https (bloquer http:// et //domaine externe — pixel de tracking sur page publique).
- Quality gate « règle d'or » appliquée aussi au `pageSpec` posté directement à `/api/notes/publish` (pas seulement au flux LLM).
- `resolve.ts` : numérotation des badges par démo (pas réinitialisée par acte).
- `demo-scene-view.tsx:479` : warning eslint exhaustive-deps (`edges` dans useMemo).
- `page-view.tsx` useScrollReveal : scoper les `querySelectorAll` via ref (multi-instance).
- Test `rejects hex color` : le faire échouer via le scan sémantique (pas l'enum Zod) pour prouver le scan.
- Page publiée sans JS : démos à l'état final au premier paint (spec §9.5) — nécessite SSR static + swap hydratation.

View File

@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin dessai)
- `invoice.payment_failed`
4. Copier la **signature secrète**`STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
| Événement Stripe | Action Memento | Statut Prisma |
|------------------|---------------|---------------|
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
| `customer.subscription.trial_will_end` | Sync + email rappel fin dessai | `TRIALING` |
### 3.3 Mapping statuts Stripe → Prisma

View File

@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
## Features
-**22 Tools** for notes, notebooks, labels, and reminders
-**29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
- 🔒 **API Key Authentication** with secure storage
- 🚀 **Performance Optimized** with connection pooling and caching
- 📊 **Observability** with Prometheus metrics export
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
```
## Available Tools (22)
## Available Tools (29)
### Notes (13)
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `batch_move_notes` | Move multiple notes at once |
| `batch_delete_notes` | Delete multiple notes at once |
### Notebooks (6)
### Notebooks (7)
| Tool | Description |
|------|-------------|
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `update_label` | Update a label |
| `delete_label` | Delete a label |
### Reminders (1)
### Reminders (3)
| Tool | Description |
|------|-------------|
| `get_due_reminders` | Get due reminders |
| `get_upcoming_reminders` | Get reminders due in the next N hours |
| `update_reminder` | Set, update, or clear a note reminder |
### Semantic & AI (3)
| Tool | Description |
|------|-------------|
| `find_similar_notes` | Find notes semantically similar to a given note |
| `get_memory_echo_insights` | List AI-discovered note connections |
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
### Filters & Statistics (2)
| Tool | Description |
|------|-------------|
| `get_notes_by_label` | List notes by label/tag |
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
### Utilities (2)

View File

@@ -197,8 +197,9 @@ export function validateConfig() {
}
}
// Port validation
const portValidation = validatePort(config.port);
// Port validation (check raw value before clamping)
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
if (!portValidation.valid) {
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
}

View File

@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
sessions: '/sessions',
},
auth: { enabled: config.requireAuth },
tools: 22,
tools: 29,
uptime: process.uptime(),
});
});
@@ -445,10 +445,9 @@ app.all(
}
// Validate tool input if present
if (req.body?.method) {
const toolName = req.body.method;
if (req.body?.params) {
const validation = validateAndSanitize(toolName, req.body.params);
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
const toolName = req.body.params.name;
const validation = validateAndSanitize(toolName, req.body.params.arguments);
if (!validation.success) {
log('warn', `Validation failed for ${toolName}:`, validation.errors);
return res
@@ -462,8 +461,7 @@ app.all(
);
}
// Update request with sanitized data
req.body.params = validation.data;
}
req.body.params.arguments = validation.data;
}
const ctx = { userId: req.userSession?.userId || null };
@@ -539,7 +537,7 @@ async function main() {
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${config.requestTimeout}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
Tools: 29
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
`);
});

View File

@@ -176,7 +176,7 @@ async function main() {
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
User: ${config.userId || 'all'}
Log Level: ${config.logLevel}
Tools: 22
Tools: 29
`);
}

View File

@@ -1,7 +1,7 @@
{
"name": "memento-mcp-server",
"version": "3.2.0",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
"type": "module",
"main": "index.js",
"scripts": {

View File

@@ -5,7 +5,7 @@
* Run with: npm test
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
it('should allow safe HTML', () => {
const xss = checkXSS({ content: 'Hello <em>world</em>' });
// This will be true because we check for any HTML tags
// In production, you might want more sophisticated checking
expect(xss).toBe(true);
// Safe formatting tags are not flagged as XSS
expect(xss).toBe(false);
});
it('should sanitize input', () => {
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
'update_label',
'delete_label',
'get_due_reminders',
'get_upcoming_reminders',
'update_reminder',
'find_similar_notes',
'get_memory_echo_insights',
'dismiss_memory_echo_insight',
'get_notes_by_label',
'get_note_statistics',
'export_notes',
'import_notes',
];
it('should have all expected tools with schemas', () => {
it('should have all expected tools with schemas', async () => {
const { toolSchemas } = await import('../validation.js');
for (const toolName of toolNames) {

View File

@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -463,6 +463,100 @@ const toolDefinitions = [
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_upcoming_reminders',
description: 'Get notes with reminders due in the next N hours (default 24).',
inputSchema: {
type: 'object',
properties: {
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
},
},
{
name: 'update_reminder',
description: 'Set, update, or clear a reminder on a note.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
reminderLocation: { type: 'string', nullable: true },
},
required: ['id'],
},
},
// ═══ SEMANTIC / AI ═══
{
name: 'find_similar_notes',
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Source note ID' },
limit: { type: 'number', description: 'Max results', default: 10 },
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
},
required: ['id'],
},
},
{
name: 'get_memory_echo_insights',
description: 'List Memory Echo insights: AI-discovered connections between notes.',
inputSchema: {
type: 'object',
properties: {
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
limit: { type: 'number', description: 'Max results', default: 20 },
},
},
},
{
name: 'dismiss_memory_echo_insight',
description: 'Mark a Memory Echo insight as viewed or dismissed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Insight ID' },
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
},
required: ['id'],
},
},
// ═══ FILTERS & STATISTICS ═══
{
name: 'get_notes_by_label',
description: 'List notes that have a specific label/tag.',
inputSchema: {
type: 'object',
properties: {
label: { type: 'string', description: 'Label text to match' },
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
includeArchived: { type: 'boolean', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
required: ['label'],
},
},
{
name: 'get_note_statistics',
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
},
},
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
title: args.title || null,
content: args.content,
color: args.color || 'default',
type: args.type || 'text',
type: args.type || 'richtext',
checkItems: args.checkItems ?? null,
labels: args.labels ?? null,
isPinned: args.isPinned || false,
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders });
}
case 'get_upcoming_reminders': {
const now = new Date();
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
const extra = {
reminder: { not: null, gte: now, lte: horizon },
isArchived: false,
};
if (!args.includeDone) extra.isReminderDone = false;
const reminders = await prisma.note.findMany({
where: noteWhere(uid, extra),
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: args.limit || 100,
});
return textResult({ count: reminders.length, horizon, reminders });
}
case 'update_reminder': {
const d = { updatedAt: new Date() };
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
const note = await prisma.note.update({
where: { id: args.id, userId: uid, trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
// ═══ SEMANTIC / AI ═══
case 'find_similar_notes': {
const source = await prisma.note.findUnique({
where: { id: args.id, userId: uid, trashedAt: null },
select: { id: true, title: true },
});
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
where: { noteId: args.id },
select: { embedding: true },
});
if (!sourceEmbedding) {
return textResult({
noteId: args.id,
error: 'Source note has no embedding. Index it first via the Memento app.',
results: [],
});
}
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
? sourceEmbedding.embedding
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
const vecStr = `[${embeddingArray.join(',')}]`;
const limit = Math.min(args.limit || 10, 50);
const threshold = args.threshold ?? 0.7;
const rows = await prisma.$queryRawUnsafe(
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
n."notebookId", n."createdAt", n."updatedAt",
1 - (e."embedding"::vector <=> $1::vector) AS similarity
FROM "Note" n
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
WHERE n."trashedAt" IS NULL
AND n."isArchived" = false
AND n."userId" = $2
AND n.id != $3
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
ORDER BY e."embedding"::vector <=> $1::vector ASC
LIMIT $5`,
vecStr,
uid,
args.id,
threshold,
limit,
);
return textResult({
noteId: args.id,
sourceTitle: source.title || 'Untitled',
count: rows.length,
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
});
}
case 'get_memory_echo_insights': {
const where = { userId: uid };
if (!args.includeViewed) where.viewed = false;
if (!args.includeDismissed) where.dismissed = false;
const insights = await prisma.memoryEchoInsight.findMany({
where,
orderBy: { similarityScore: 'desc' },
take: Math.min(args.limit || 20, 100),
include: {
note1: { select: { id: true, title: true } },
note2: { select: { id: true, title: true } },
},
});
return textResult({
count: insights.length,
insights: insights.map(i => ({
id: i.id,
insight: i.insight,
similarityScore: i.similarityScore,
insightDate: i.insightDate,
viewed: i.viewed,
dismissed: i.dismissed,
note1: i.note1,
note2: i.note2,
})),
});
}
case 'dismiss_memory_echo_insight': {
const d = {};
if ('viewed' in args) d.viewed = args.viewed;
if ('dismissed' in args) d.dismissed = args.dismissed;
const insight = await prisma.memoryEchoInsight.update({
where: { id: args.id, userId: uid },
data: d,
});
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
}
// ═══ FILTERS & STATISTICS ═══
case 'get_notes_by_label': {
const label = args.label.trim();
const extra = {};
if (!args.includeArchived) extra.isArchived = false;
if (args.notebookId) {
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
// Labels are stored as a JSON string like ["tag1","tag2"].
const notes = await prisma.note.findMany({
where: {
...noteWhere(uid, extra),
labels: { contains: label },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
});
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
}
case 'get_note_statistics': {
const baseWhere = { userId: uid };
const activeWhere = { ...baseWhere, trashedAt: null };
const [
totalNotes,
archivedNotes,
trashedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
notesByType,
notesByColor,
] = await Promise.all([
prisma.note.count({ where: activeWhere }),
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
prisma.notebook.count({ where: { userId: uid } }),
prisma.label.count({ where: { notebook: { userId: uid } } }),
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
]);
return textResult({
totalNotes,
archivedNotes,
trashedNotes,
activeNotes: totalNotes - archivedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
});
}
// ═══ TRASH ═══
case 'trash_note': {
const note = await prisma.note.update({

View File

@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
fullDetails: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
offset: z.number().int().min(0).default(0),
});
/**
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
*/
export const searchNotesSchema = z.object({
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(50),
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
});
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
*/
export const togglePinSchema = z.object({
id: idSchema,
pinned: z.boolean().optional(),
});
/**
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
*/
export const toggleArchiveSchema = z.object({
id: idSchema,
archived: z.boolean().optional(),
});
/**
* batch_move_notes input schema
*/
export const batchMoveNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
notebookId: idSchema.optional().nullable(),
});
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
* batch_delete_notes input schema
*/
export const batchDeleteNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
});
/**
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
*/
export const createNotebookSchema = z.object({
name: z.string().min(1).max(200),
color: colorSchema.default('default'),
color: z.string().max(50).default('#3B82F6'),
icon: z.string().max(50).optional().nullable(),
parentId: idSchema.optional().nullable(),
order: z.number().int().optional(),
});
/**
* get_notebooks input schema
*/
export const getNotebooksSchema = z.object({
includeHierarchy: boolSchema(false),
includeTrashed: boolSchema(false),
});
export const getNotebooksSchema = z.object({});
/**
* get_notebook input schema
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
export const updateNotebookSchema = z.object({
id: idSchema,
name: z.string().min(1).max(200).optional(),
color: colorSchema.optional(),
color: z.string().max(50).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
order: z.number().int().optional(),
parentId: idSchema.optional().nullable(),
});
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
*/
export const createLabelSchema = z.object({
name: z.string().min(1).max(100),
color: colorSchema.default('default'),
color: z.string().max(50).optional().nullable(),
notebookId: idSchema,
});
/**
* get_labels input schema
*/
export const getLabelsSchema = z.object({
limit: z.number().int().min(1).max(500).default(100),
notebookId: idSchema.optional().nullable(),
});
/**
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
/**
* get_due_reminders input schema
*/
export const getDueRemindersSchema = z.object({
before: isoDateSchema.optional().nullable(),
after: isoDateSchema.optional().nullable(),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
export const getDueRemindersSchema = z.object({});
/**
* export_notes input schema
*/
export const exportNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
format: z.enum(['json', 'markdown']).default('json'),
});
export const exportNotesSchema = z.object({});
/**
* import_notes input schema
*/
export const importNotesSchema = z.object({
notes: z.array(
z.object({
title: z.string().optional(),
content: z.string(),
color: colorSchema.optional(),
labels: labelsSchema,
data: z.object({
version: z.string().optional(),
data: z.object({
notes: z.array(z.any()).optional(),
labels: z.array(z.any()).optional(),
notebooks: z.array(z.any()).optional(),
}).optional(),
}),
});
/**
* find_similar_notes input schema
*/
export const findSimilarNotesSchema = z.object({
id: idSchema,
limit: z.number().int().min(1).max(50).default(10),
threshold: z.number().min(0).max(1).default(0.7),
});
/**
* get_memory_echo_insights input schema
*/
export const getMemoryEchoInsightsSchema = z.object({
includeViewed: boolSchema(false),
includeDismissed: boolSchema(false),
limit: z.number().int().min(1).max(100).default(20),
});
/**
* dismiss_memory_echo_insight input schema
*/
export const dismissMemoryEchoInsightSchema = z.object({
id: idSchema,
viewed: boolSchema(true),
dismissed: boolSchema(true),
});
/**
* get_notes_by_label input schema
*/
export const getNotesByLabelSchema = z.object({
label: z.string().min(1).max(100),
notebookId: idSchema.optional().nullable(),
})
).min(1).max(100),
notebookId: idSchema.optional().nullable(),
overwrite: z.boolean().optional().default(false),
includeArchived: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* get_note_statistics input schema
*/
export const getNoteStatisticsSchema = z.object({
includeArchived: boolSchema(false),
includeTrashed: boolSchema(false),
});
/**
* get_upcoming_reminders input schema
*/
export const getUpcomingRemindersSchema = z.object({
hours: z.number().int().min(1).max(168).default(24),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* update_reminder input schema
*/
export const updateReminderSchema = z.object({
id: idSchema,
reminder: isoDateSchema,
isReminderDone: z.boolean().optional(),
reminderRecurrence: recurrenceSchema,
reminderLocation: z.string().max(500).optional().nullable(),
});
// ═══════════════════════════════════════════════════════════════
@@ -427,6 +476,13 @@ export const toolSchemas = {
get_due_reminders: getDueRemindersSchema,
export_notes: exportNotesSchema,
import_notes: importNotesSchema,
find_similar_notes: findSimilarNotesSchema,
get_memory_echo_insights: getMemoryEchoInsightsSchema,
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
get_notes_by_label: getNotesByLabelSchema,
get_note_statistics: getNoteStatisticsSchema,
get_upcoming_reminders: getUpcomingRemindersSchema,
update_reminder: updateReminderSchema,
};
// ═══════════════════════════════════════════════════════════════
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map((e) => ({
errors: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/test.js', 'test/**/*.test.js'],
},
});

View File

@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
import {
CreditCard,
Gauge,
Coins,
Package,
Activity,
CheckCircle2,
XCircle,
AlertTriangle,
Users,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PriceHealth = {
key: string
priceId: string
configured: boolean
source: string
stripeAmount: number | null
stripeCurrency: string | null
stripeActive: boolean | null
error: string | null
}
type BillingAdminData = {
billingConfig: Record<string, string>
@@ -19,7 +44,6 @@ type BillingAdminData = {
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
@@ -31,6 +55,35 @@ type BillingAdminData = {
credits: number
defaultDisplay: string
}>
stripeHealth?: {
secretConfigured: boolean
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
publishableConfigured: boolean
webhookSecretConfigured: boolean
billingEnabled: boolean
trialDays: number
prices: PriceHealth[]
}
subscriptionStats?: {
byTier: Record<string, number>
byStatus: Record<string, number>
cancelAtPeriodEnd: number
usersWithoutSub: number
trialing: number
pastDue: number
paidActive: number
recent: Array<{
email: string
name: string | null
tier: string
status: string
trialEndsAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
hasStripeSub: boolean
updatedAt: string
}>
}
}
const SUBSCRIPTION_PRICE_KEYS = [
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [showTestGuide, setShowTestGuide] = useState(true)
const health = initialData.stripeHealth
const stats = initialData.subscriptionStats
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const modeLabel =
health?.secretMode === 'test'
? t('admin.billing.modeTest')
: health?.secretMode === 'live'
? t('admin.billing.modeLive')
: health?.secretMode === 'placeholder'
? t('admin.billing.modePlaceholder')
: t('admin.billing.modeMissing')
return (
<div className="space-y-8">
<div>
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div>
{/* Stripe health */}
{health && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Activity className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
</div>
</div>
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
<div className="min-w-0">
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.publishableConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
<p className="text-[11px] text-muted-foreground">
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.webhookSecretConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
<p className="text-[11px] text-muted-foreground">
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
<p className="text-[11px] text-muted-foreground">
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
</p>
</div>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
</tr>
</thead>
<tbody>
{(health.prices ?? []).map((row) => (
<tr key={row.key} className="border-t border-border/50">
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
<td className="p-2.5 font-mono truncate max-w-[180px]">
{row.configured ? row.priceId : '—'}
</td>
<td className="p-2.5">{row.source}</td>
<td className="p-2.5">
{!row.configured ? (
<span className="text-rose-600">{t('admin.billing.missing')}</span>
) : row.error ? (
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
) : row.stripeAmount != null ? (
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
</span>
) : (
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Subscription stats */}
{stats && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Users className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
</div>
</div>
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.entries(stats.byTier).map(([tier, count]) => (
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
<span>{tier}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))}
<li className="flex justify-between text-xs text-muted-foreground pt-1">
<span>{t('admin.billing.usersWithoutSub')}</span>
<span className="tabular-nums">{stats.usersWithoutSub}</span>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.keys(stats.byStatus).length === 0 ? (
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
) : (
Object.entries(stats.byStatus).map(([status, count]) => (
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
<span>{status}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))
)}
</ul>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
{stats.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
) : (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
</tr>
</thead>
<tbody>
{stats.recent.map((row) => (
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
<td className="p-2.5">{row.tier}</td>
<td className="p-2.5">
{row.status}
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
</td>
<td className="p-2.5 text-muted-foreground">
{row.trialEndsAt
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
: row.currentPeriodEnd
? formatStableUtc(row.currentPeriodEnd)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* How to test Stripe */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setShowTestGuide((v) => !v)}
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<BookOpen className="h-5 w-5" />
</div>
<div className="flex-1">
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
</div>
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showTestGuide && (
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
<ol className="list-decimal ps-5 space-y-2">
<li>{t('admin.billing.testStep1')}</li>
<li>{t('admin.billing.testStep2')}</li>
<li>
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
{' — '}{t('admin.billing.testStep3')}
</li>
<li>{t('admin.billing.testStep4')}</li>
<li>{t('admin.billing.testStep5')}</li>
<li>{t('admin.billing.testStep6')}</li>
</ol>
<p className="text-xs border-t border-border/50 pt-3">
{t('admin.billing.testCardHint')}
</p>
</div>
)}
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -106,7 +418,9 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
const meta = priceMap[key]
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
@@ -115,8 +429,17 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
{meta?.stripeAmount != null && (
<p className="text-[11px] text-muted-foreground">
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
</p>
)}
{meta?.error && (
<p className="text-[11px] text-rose-600">{meta.error}</p>
)}
</div>
))}
)
})}
</div>
</div>
<div className="border-t border-border/50 pt-4">
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}

View File

@@ -0,0 +1,92 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Mail, ArrowLeft, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { resendSignupVerification } from '@/app/actions/auth-verify'
import { toast } from 'sonner'
function CheckEmailContent() {
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const initialEmail = searchParams.get('email') ?? ''
const [email, setEmail] = useState(initialEmail)
const [sending, setSending] = useState(false)
const handleResend = async () => {
const target = email.trim()
if (!target) {
toast.error(t('auth.verifyMissingEmail'))
return
}
setSending(true)
const result = await resendSignupVerification(target, language)
setSending(false)
if (result.success) {
toast.success(t('auth.verifyResent'))
} else {
toast.error(t('auth.verifyResendFailed'))
}
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
<div className="space-y-8 text-center">
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
<Mail size={28} className="text-[var(--color-brand-accent)]" />
</div>
<div className="space-y-2">
<h1 className="text-2xl md:text-3xl font-serif font-bold">
{t('auth.checkEmailTitle')}
</h1>
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
{initialEmail
? t('auth.checkEmailDescription', { email: initialEmail })
: t('auth.checkEmailDescriptionGeneric')}
</p>
</div>
<div className="space-y-1.5 text-start">
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
{t('auth.email')}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.emailPlaceholder')}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
/>
</div>
<button
type="button"
onClick={handleResend}
disabled={sending}
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
>
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
</button>
<Link
href="/login"
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
>
<ArrowLeft size={14} />
{t('auth.backToLogin')}
</Link>
</div>
</div>
)
}
export default function CheckEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<CheckEmailContent />
</Suspense>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { confirmEmail } from '@/app/actions/auth-verify'
function VerifyEmailContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
useEffect(() => {
if (!token) {
setStatus('invalid')
return
}
let cancelled = false
;(async () => {
const result = await confirmEmail(token)
if (cancelled) return
if (result.success) setStatus('ok')
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
})()
return () => {
cancelled = true
}
}, [token])
if (status === 'loading') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
</div>
)
}
if (status === 'ok') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
<CheckCircle2 size={28} className="text-emerald-600" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
</div>
<Link href="/login?verified=1" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.signIn')}
</button>
</Link>
</div>
)
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertCircle size={28} className="text-red-500" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{status === 'expired'
? t('auth.verifyExpiredDescription')
: t('auth.verifyInvalidDescription')}
</p>
</div>
<Link href="/check-email" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.resendVerification')}
</button>
</Link>
</div>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<VerifyEmailContent />
</Suspense>
)
}

View File

@@ -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 (
<div className="p-8 text-sm text-destructive">
Fixture invalide : {parsed.issues[0]?.message}
</div>
)
}
return <PageView page={parsed.page} demoMode="interactive" />
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import {
@@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
@@ -129,6 +130,15 @@ export default function InsightsPage() {
loadInitialData()
}, [])
useEffect(() => {
const clusterParam = searchParams.get('cluster')
if (!clusterParam || clusters.length === 0) return
const match = clusters.find(
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
)
if (match) setSelectedClusterId(match.id)
}, [searchParams, clusters])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(

View File

@@ -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 (
<InteractivePublishedPage
publishedContent={note.publishedContent}
isStale={isStale}
/>
)
}
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 }

View File

@@ -4,18 +4,38 @@ import { motion } from 'motion/react'
import { Shield } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useState } from 'react'
export default function PricingPage() {
const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
return (
@@ -42,7 +62,9 @@ export default function PricingPage() {
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
{t('landing.pricing.savePercent')}
</span>
</div>
</div>
</div>
@@ -62,9 +84,22 @@ export default function PricingPage() {
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
{plan.hasTrial && (
<p className={`text-[11px] font-semibold mb-3 ${plan.popular ? 'text-ochre' : 'text-brand-accent'}`}>
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
</div>
<div className="flex-1 space-y-4 mb-10">
{plan.hasTrial && (
<div className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-medium">{t('landing.pricing.trialFeature', { days: trialDays })}</span>
</div>
)}
{[0, 1, 2, 3, 4, 5].map(j => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
@@ -82,7 +117,9 @@ export default function PricingPage() {
onClick={() => router.push('/register')}
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</button>
</div>
))}

View File

@@ -2,13 +2,24 @@ import { headers } from 'next/headers'
import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export const metadata = {
title: 'Privacy Policy — Memento',
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
export async function generateMetadata({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
return {
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
description: locale === 'fr'
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
}
}
type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = []
@@ -353,7 +364,9 @@ export default async function PrivacyPage({
</header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">Privacy</p>
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
</p>
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>

View File

@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
}
}
async function getStripeHealth(billingConfig: Record<string, string>) {
const secret = process.env.STRIPE_SECRET_KEY ?? ''
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
if (!secret) secretMode = 'missing'
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
else if (secret.startsWith('sk_live_')) secretMode = 'live'
else if (secret.startsWith('sk_test_')) secretMode = 'test'
else secretMode = 'placeholder'
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
const billingEnabled = await isBillingEnabled()
const priceKeys = [
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
] as string[]
const canCallStripe = secretMode === 'test' || secretMode === 'live'
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
if (canCallStripe) {
try {
const { getStripe } = await import('@/lib/stripe')
stripe = getStripe()
} catch {
stripe = null
}
}
const prices = await Promise.all(
priceKeys.map(async (key) => {
const priceId = (billingConfig[key] || process.env[key] || '').trim()
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
let stripeAmount: number | null = null
let stripeCurrency: string | null = null
let stripeActive: boolean | null = null
let error: string | null = null
if (configured && stripe) {
try {
const price = await stripe.prices.retrieve(priceId)
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
stripeCurrency = price.currency?.toUpperCase() ?? null
stripeActive = price.active
} catch (e) {
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
}
}
return {
key,
priceId: configured ? priceId : '',
configured,
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
stripeAmount,
stripeCurrency,
stripeActive,
error,
}
}),
)
return {
secretConfigured: secretMode === 'test' || secretMode === 'live',
secretMode,
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
billingEnabled,
trialDays: SUBSCRIPTION_TRIAL_DAYS,
prices,
}
}
async function getSubscriptionStats() {
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
prisma.subscription.groupBy({
by: ['tier'],
_count: { _all: true },
}),
prisma.subscription.groupBy({
by: ['status'],
_count: { _all: true },
}),
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
prisma.subscription.findMany({
where: {
OR: [
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 15,
select: {
tier: true,
status: true,
trialEndsAt: true,
currentPeriodEnd: true,
cancelAtPeriodEnd: true,
updatedAt: true,
stripeSubscriptionId: true,
user: { select: { email: true, name: true } },
},
}),
])
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
for (const row of byTierRaw) {
byTier[row.tier] = row._count._all
}
const byStatus: Record<string, number> = {}
for (const row of byStatusRaw) {
byStatus[row.status] = row._count._all
}
const usersWithoutSub = await prisma.user.count({
where: { subscription: null },
})
return {
byTier,
byStatus,
cancelAtPeriodEnd,
usersWithoutSub,
trialing: byStatus.TRIALING ?? 0,
pastDue: byStatus.PAST_DUE ?? 0,
paidActive:
(byStatus.ACTIVE ?? 0) +
(byStatus.TRIALING ?? 0),
recent: recent.map((s) => ({
email: s.user.email,
name: s.user.name,
tier: s.tier,
status: s.status,
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
hasStripeSub: Boolean(s.stripeSubscriptionId),
updatedAt: s.updatedAt.toISOString(),
})),
}
}
export async function getBillingAdminData() {
await checkAdmin()
const { getSystemConfig } = await import('@/lib/config')
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
}
})
const [stripeHealth, subscriptionStats] = await Promise.all([
getStripeHealth(billingConfig),
getSubscriptionStats(),
])
return {
entitlements,
billingConfig,
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
creditAllocations,
creditCosts,
creditPacks,
stripeHealth,
subscriptionStats,
}
}

View File

@@ -0,0 +1,11 @@
'use server'
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
export async function confirmEmail(token: string) {
return verifyEmailToken(token)
}
export async function resendSignupVerification(email: string, locale?: string) {
return resendVerificationEmail(email, locale)
}

View File

@@ -2,20 +2,49 @@
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
const emailRaw = formData.get('email');
const passwordRaw = formData.get('password');
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
// Surface a clear message when credentials are valid but email is unverified.
if (email && password.length >= 6) {
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user?.password) {
const match = await bcrypt.compare(password, user.password);
if (match && !user.emailVerified) {
return 'EMAIL_NOT_VERIFIED';
}
}
} catch (preCheckErr) {
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
}
}
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
email,
password,
redirectTo: '/home',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
if (
error.type === 'CredentialsSignin' &&
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
) {
return 'EMAIL_NOT_VERIFIED';
}
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
import { sendVerificationEmail } from '@/lib/auth/email-verification';
const RegisterSchema = z.object({
email: z.string().email(),
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
}
const { email, password, name } = validatedFields.data;
const normalizedEmail = email.toLowerCase();
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existingUser) {
return 'User already exists.';
}
const hashedPassword = await bcrypt.hash(password, 10);
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
const role = isAdmin ? 'ADMIN' : 'USER';
await prisma.user.create({
data: {
email: email.toLowerCase(),
email: normalizedEmail,
password: hashedPassword,
name,
role,
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
emailVerified: isAdmin ? new Date() : null,
},
});
// Attempt to sign in immediately after registration
// We cannot import signIn here directly if it causes circular deps or issues,
// but usually it works. If not, redirecting to login is fine.
// Let's stick to redirecting to login but with a clear success message?
// Or better: lowercase the email to fix the potential bug.
if (!isAdmin) {
const mailResult = await sendVerificationEmail({
email: normalizedEmail,
name,
});
if (!mailResult.success) {
console.error('[register] verification email failed:', mailResult.error);
}
}
} catch (error) {
console.error('Registration Error:', error);
return 'Database Error: Failed to create user.';
}
redirect('/login');
if (isAdmin) {
redirect('/login?verified=1');
}
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
}

View File

@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 })
}
}

View File

@@ -0,0 +1,214 @@
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(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().nullish(),
})
const requestSchema = z.object({
/** undefined = legacy deterministic full-page (fallback path) */
action: z.enum(['plan', 'section']).optional(),
content: z.string().min(40).max(500_000),
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().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
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 5/20 crédits — le reste est facturé par section) ──
if (parsed.action === 'plan') {
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 5,
})
} 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 (billed 3/20 crédits per section — no free LLM) ──
if (parsed.action === 'section') {
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
return NextResponse.json(
{ error: 'section, sectionId and pageTitle are required' },
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 3,
})
} 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 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: 'Requête invalide',
issues: error.issues.slice(0, 10).map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
{ 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 })
}
}

View File

@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import {
shouldOfferSubscriptionTrial,
SUBSCRIPTION_TRIAL_DAYS,
} from '@/lib/billing/trial';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const offerTrial = await shouldOfferSubscriptionTrial(userId);
const subscriptionData: {
metadata: { userId: string; tier: string };
trial_period_days?: number;
} = {
metadata: { userId, tier },
};
if (offerTrial) {
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
}
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
}
} catch (embeddedErr) {
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
});
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
} catch (error) {
console.error('[billing/create-checkout]', error);
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';

View File

@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
export const dynamic = 'force-dynamic';
@@ -23,47 +25,16 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
: (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
const sub = await stripe.subscriptions.retrieve(subId);
const syncUserId =
(checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
userId;
if (syncUserId === userId) {
await syncSubscriptionFromStripe(sub, userId);
}
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
@@ -112,6 +83,8 @@ export async function GET(req: NextRequest) {
}
}
const trialEligible = await shouldOfferSubscriptionTrial(userId);
return NextResponse.json({
tier,
effectiveTier,
@@ -120,6 +93,9 @@ export async function GET(req: NextRequest) {
currentPeriodStart: subscription?.currentPeriodStart ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices,
creditPacks,
billingEnabled,

View File

@@ -12,6 +12,8 @@ import {
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
break;
}
case 'customer.subscription.trial_will_end': {
const subscription = event.data.object as Stripe.Subscription;
const userId = await resolveUserIdFromStripeEvent(subscription);
if (!userId) {
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
break;
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
name: true,
aiSettings: { select: { preferredLanguage: true } },
},
});
if (!user?.email) break;
await syncSubscriptionFromStripe(subscription, userId);
const trialEndsAt = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
const appUrl =
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
'https://memento-note.com';
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
const mailResult = await sendTrialEndingReminder({
to: user.email,
name: user.name,
trialEndsAt,
billingUrl: `${appUrl}/settings/billing`,
locale: preferred === 'auto' ? 'en' : preferred,
});
if (!mailResult.success) {
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
}
break;
}
default:
break;
}

View File

@@ -59,6 +59,7 @@ export async function GET() {
const [
recentNotes,
inboxCount,
inboxPreview,
dueFlashcards,
upcomingReminders,
unviewedInsights,
@@ -83,6 +84,13 @@ export async function GET() {
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.note.findMany({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 3,
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
@@ -177,6 +185,12 @@ export async function GET() {
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,

View File

@@ -5,8 +5,12 @@ 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, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
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
@@ -88,18 +92,69 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
}
}
/** All human-facing text of a PageSpecV1 — fed to moderation. */
function collectPageText(page: PageSpecV1): string[] {
const out: string[] = [
page.hero.kicker,
page.hero.title,
page.hero.subtitle ?? '',
page.hero.meta ?? '',
page.footer ?? '',
]
if (page.overview) {
out.push(page.overview.lead)
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
}
for (const section of page.sections) {
out.push(section.title)
for (const b of section.blocks) {
if (b.type === 'prose') out.push(b.md)
else if (b.type === 'formula') out.push(b.caption ?? '')
else if (b.type === 'callout') out.push(b.title, b.md)
else if (b.type === 'demo') {
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
for (const act of b.demo.acts) {
out.push(act.title)
for (const st of act.steps) out.push(st.speak)
}
for (const panel of b.demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) out.push(n.label ?? '')
}
}
} else if (b.type === 'chart') {
out.push(b.caption ?? '')
for (const s of b.payload.series) out.push(s.label ?? '')
} else if (b.type === 'stats') {
for (const it of b.items) out.push(it.value, it.label)
} else if (b.type === 'table') {
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
} else if (b.type === 'image') {
out.push(b.alt, b.caption ?? '')
} else if (b.type === 'sim') {
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
}
}
}
return out.filter((s) => s && s.trim())
}
export async function POST(request: NextRequest) {
const session = await auth()
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 body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
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 +166,98 @@ 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: PageValidationResult | null = null
if (pageSpec) {
// Client-provided page (from the preview dialog): must validate as-is.
// Never silently substitute a different page than the one previewed.
const checked = validateInteractivePage(pageSpec)
if (!checked.ok) {
return NextResponse.json(
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
{ status: 422 }
)
}
validatedPage = checked
}
if (!validatedPage) {
// 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 as const, page: generated.page }
}
// Moderate ALL human-facing text of the page (prose, callouts, demo
// narration, tables, captions) — not just titles.
const textForModeration = collectPageText(validatedPage.page).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 })
}

View File

@@ -34,6 +34,8 @@ export const authConfig = {
nextUrl.pathname === '/login' ||
nextUrl.pathname === '/register' ||
nextUrl.pathname === '/forgot-password' ||
nextUrl.pathname === '/check-email' ||
nextUrl.pathname === '/verify-email' ||
nextUrl.pathname.startsWith('/reset-password');
if (isAdminPage) {

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
@@ -35,12 +35,22 @@ export function DashboardAgentCarousel({
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const directionRef = useRef(1)
const goPrev = () => {
directionRef.current = -1
setIdx(i => Math.max(0, i - 1))
}
const goNext = () => {
directionRef.current = 1
setIdx(i => Math.min(suggestions.length - 1, i + 1))
}
const navActions = suggestions.length > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
onClick={goPrev}
disabled={idx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
@@ -52,7 +62,7 @@ export function DashboardAgentCarousel({
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
onClick={goNext}
disabled={idx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
@@ -84,6 +94,7 @@ export function DashboardAgentCarousel({
) : (
<AgentSlide
current={suggestions[idx]}
direction={directionRef.current}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
@@ -98,6 +109,7 @@ export function DashboardAgentCarousel({
function AgentSlide({
current,
direction,
actingId,
formatFrequency,
onAccept,
@@ -106,6 +118,7 @@ function AgentSlide({
t,
}: {
current: AgentSuggestion
direction: number
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
@@ -115,7 +128,11 @@ function AgentSlide({
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
: {
initial: { opacity: 0, x: 14 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -14 * direction },
}
return (
<AnimatePresence mode="wait">

View File

@@ -1,5 +1,6 @@
'use client'
import { motion, useReducedMotion } from 'motion/react'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
@@ -42,14 +43,19 @@ export function DashboardWidgetShell({
export function DashboardInboxWidget({
count,
notes,
loading,
onOpen,
onSelect,
}: {
count: number
notes: Array<{ id: string; title: string | null; notebookId: string | null }>
loading: boolean
onOpen: () => void
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
const reduced = !!useReducedMotion()
return (
<DashboardWidgetShell
widgetId="inbox"
@@ -58,19 +64,42 @@ export function DashboardInboxWidget({
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
) : (
<div className="space-y-1.5">
{notes.map((note, idx) => (
<motion.button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
initial={reduced ? false : { opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
>
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
{note.title || t('homeDashboard.untitled')}
</p>
</motion.button>
))}
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
className="w-full flex items-center justify-between gap-2 px-1 pt-1 text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
<span className="text-[9px] font-mono uppercase font-bold text-concrete">
{t('homeDashboard.inboxSeeAll', { count })}
</span>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">
{t('homeDashboard.widgetOpen')}
</span>
</button>
</div>
)}
</DashboardWidgetShell>
)

View File

@@ -19,12 +19,22 @@ interface BridgeNote {
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const EASE = [0.16, 1, 0.3, 1] as const
function clusterPoint(index: number, count: number): { x: number; y: number } {
if (count === 1) return { x: 50, y: 42 }
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
const rx = count <= 3 ? 28 : 36
const ry = count <= 3 ? 24 : 30
return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry }
}
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onOpenCluster?: (clusterId: number) => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
bridgeNotes,
loading,
onOpenInsights,
onOpenCluster,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
const reduced = !!prefersReducedMotion
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
@@ -80,21 +93,64 @@ export function DashboardMindOrbit({
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
<motion.div
className="relative h-[176px]"
initial={reduced ? false : { clipPath: 'circle(0% at 50% 44%)' }}
animate={{ clipPath: 'circle(120% at 50% 44%)' }}
transition={{ duration: reduced ? 0 : 0.62, ease: EASE }}
>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden
>
{points.map((point, idx) => {
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
return (
<motion.path
key={`spoke-${topClusters[idx].clusterId}`}
d={`M 50 44 L ${point.x} ${point.y}`}
fill="none"
stroke={color}
strokeWidth={0.7}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 0.4 }}
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
/>
)
})}
</svg>
<div
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
aria-hidden
/>
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const size = Math.round(52 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
const point = points[idx]
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
whileHover={reduced ? undefined : { scale: 1.06 }}
whileTap={reduced ? undefined : { scale: 0.97 }}
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
className="absolute flex flex-col items-center gap-1 group"
style={{
left: `${point.x}%`,
top: `${point.y}%`,
width: size + 16,
}}
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
@@ -114,13 +170,16 @@ export function DashboardMindOrbit({
</motion.button>
)
})}
</div>
</motion.div>
{topBridge?.note && (
<button
<motion.button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
@@ -132,7 +191,7 @@ export function DashboardMindOrbit({
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
</motion.button>
)}
</div>
)

View File

@@ -41,7 +41,8 @@ export function DashboardNextPaths({
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const rtl = language === 'ar' || language === 'fa'
if (loading) {
return (
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
const reduced = !!prefersReducedMotion
const ease = [0.16, 1, 0.3, 1] as const
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
whileHover={reduced ? undefined : { y: -1 }}
key={hero.id}
initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }}
animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }}
transition={{ duration: reduced ? 0 : 0.48, ease }}
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
>
<div className="flex items-start gap-3">
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
{rest.map((path, idx) => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
<motion.button
key={path.id}
type="button"
onClick={() => onAction(path)}
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
>
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
</motion.button>
)
})}
</div>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useReducedMotion } from 'motion/react'
import { Inbox, Send, Bell, Mail } from 'lucide-react'
import { Inbox, Send, Bell, Mail, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
@@ -152,7 +152,7 @@ interface MindMapData {
}
interface DashboardViewProps {
onNoteSelect: (noteId: string, notebookId: string | null) => void
onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
}
interface GmailStatus {
@@ -217,6 +217,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const [data, setData] = useState<{
recentNotes: BriefingNote[]
inboxCount: number
inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }>
dueFlashcards: number
upcomingReminders: BriefingReminder[]
insights: BriefingInsight[]
@@ -491,9 +492,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
} : prev)
}, [])
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => {
const handleOpenFromInsight = useCallback(async (
insight: BriefingInsight,
noteId: string,
peekNoteId?: string | null,
) => {
await markInsightViewed(insight.id)
onNoteSelect(noteId, null)
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
onNoteSelect(noteId, null, peek)
}, [markInsightViewed, onNoteSelect])
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
@@ -636,18 +642,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
break
case 'compare':
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
break
case 'addLink':
if (path.noteId) {
onNoteSelect(path.noteId, path.notebookId ?? null)
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
}
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
break
case 'openInsight': {
const insight = insights.find(i => i.id === path.insightId)
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId)
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
break
}
case 'createBridge': {
@@ -772,8 +774,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
onClick={handleCapture}
disabled={!captureText.trim() || capturing}
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
aria-busy={capturing}
>
<Send size={12} />
{capturing
? <Loader2 size={12} className="animate-spin" />
: <Send size={12} />}
</button>
</div>,
)
@@ -952,6 +957,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
bridgeNotes={mindMap?.bridgeNotes ?? []}
loading={mindMapLoading}
onOpenInsights={() => router.push('/insights')}
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
prefersReducedMotion={!!prefersReducedMotion}
/>,
@@ -983,8 +989,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
return wrap(
<DashboardInboxWidget
count={inboxCount}
notes={data?.inboxPreview ?? []}
loading={briefingLoading}
onOpen={() => router.push('/home?forceList=1')}
onSelect={onNoteSelect}
/>,
)
case 'revision':
@@ -1114,7 +1122,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
</header>
<div className="pb-24">
<DashboardWidgetGrid renderWidget={renderWidget} />
<DashboardWidgetGrid
renderWidget={renderWidget}
isWidgetEmpty={(id) => {
if (briefingLoading) return false
if (id === 'sentiment') return !sentimentLoading && (!sentiment?.available || !sentiment?.dominantEmotion)
if (id === 'reminders') return reminders.length === 0
if (id === 'revision') return dueFlashcards === 0
if (id === 'inbox') return inboxCount === 0
return false
}}
/>
</div>
</div>
</div>

View File

@@ -40,6 +40,7 @@ import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
}
function SortableWidget({
@@ -139,7 +140,7 @@ function ZoneColumn({
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
@@ -228,8 +229,11 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const mainWidgets = visibleWidgetsInZone(layout, 'main')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const sideWidgets = visibleWidgetsInZone(layout, 'side')
.filter(w => editMode || !isWidgetEmpty?.(w.id))
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0

View File

@@ -657,7 +657,10 @@ export function HomeClient({
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
if (!openNoteId) {
setEditingNote(null)
return
}
let cancelled = false
const run = async () => {
@@ -816,8 +819,15 @@ export function HomeClient({
const handleEditorClose = useCallback(() => {
setEditingNote(null)
// Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets.
if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) {
router.replace('/home', { scroll: false })
return
}
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
params.delete('peekNote')
params.delete('from')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
@@ -831,10 +841,17 @@ export function HomeClient({
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const handleDashboardNoteSelect = useCallback((
noteId: string,
notebookId: string | null,
peekNoteId?: string | null,
) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
params.set('from', 'dashboard')
// Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes.
if (notebookId && !peekNoteId) params.set('notebook', notebookId)
if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId)
router.push(`/home?${params.toString()}`)
}, [router])

View File

@@ -149,7 +149,7 @@ export interface IntelligenceHubProps {
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string, peekNoteId?: string | null) => void
dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
@@ -334,7 +334,7 @@ export function IntelligenceHub({
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
>
<GitCompare size={9} />

View File

@@ -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 (
<div
className={cn(
'flex flex-col items-center justify-center gap-0.5 text-center leading-snug',
className
)}
>
{lines.map((parts, i) => (
<div
key={i}
className={cn(
'flex flex-wrap items-center justify-center gap-x-1',
i === 0
? lit
? 'text-[13px] font-semibold tracking-tight'
: 'text-[12.5px] font-semibold tracking-tight'
: 'text-[12px] font-medium opacity-90'
)}
>
{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 (
<span
key={j}
className="katex-node inline-block [&_.katex]:text-[0.95em]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
return <span key={j}>{p.value}</span>
})}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,971 @@
'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 {
// Overview = full scene at full brightness (matches edges behavior)
if (state.overview) return 1
if (!(id in state.revealed)) return 0
if (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<string, string[]>()
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<string, NodePos>; width: number; height: number } {
const sizes = new Map(
nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
)
const positions = new Map<string, NodePos>()
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<string, number>()
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<number, string[]>()
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 (
<g className="demo-annotations" pointerEvents="none">
{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 (
<circle
key={key}
cx={pos.x}
cy={pos.y}
r={22}
fill="none"
stroke={color}
strokeWidth={2.5}
opacity={0.9}
/>
)
}
if (ann.kind === 'badge') {
const n = ann.badgeIndex ?? i + 1
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<circle r={11} fill={outline} opacity={0.95} />
<text
textAnchor="middle"
dominantBaseline="central"
fill="#fff"
fontSize={11}
fontWeight={700}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{badgeGlyph(n)}
</text>
</g>
)
}
if (ann.kind === 'callout') {
const text = ann.text ?? ''
const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
return (
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
<rect
x={8}
y={-14}
width={w}
height={28}
rx={8}
fill={dark ? '#1a1a1a' : '#ffffff'}
stroke={color}
strokeWidth={1.5}
filter={shadowId ? `url(#${shadowId})` : undefined}
/>
<text
x={18}
y={5}
fontSize={12}
fontWeight={500}
fill={dark ? '#f0f0f0' : '#1a1a1a'}
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{text}
</text>
</g>
)
}
if (ann.kind === 'arrow') {
return (
<path
key={key}
d={`M ${pos.x - 28} ${pos.y - 28} L ${pos.x - 6} ${pos.y - 6}`}
stroke={color}
strokeWidth={2.5}
strokeLinecap="round"
markerEnd={`url(#${markerId})`}
fill="none"
/>
)
}
return null
})}
</g>
)
}
function SvgScenePanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'svg-scene' }>
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 (
<div
className="relative w-full overflow-hidden"
style={{
background: boardBg,
aspectRatio: `${width} / ${height}`,
}}
>
<svg
viewBox={`0 0 ${width} ${height}`}
className="absolute inset-0 h-full w-full"
aria-hidden
>
<defs>
<pattern
id={dotsId}
width="18"
height="18"
patternUnits="userSpaceOnUse"
>
<circle cx="1.2" cy="1.2" r="1" fill={gridStroke} />
</pattern>
<marker
id={markerId}
markerWidth="10"
markerHeight="8"
refX="9"
refY="4"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L10,4 L0,8 Z" fill={outline} />
</marker>
</defs>
<rect width={width} height={height} fill={`url(#${dotsId})`} />
{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 (
<path
key={e.id}
d={edgePath(a, b)}
stroke={stroke}
strokeWidth={widthStroke}
strokeDasharray={e.style === 'dashed' ? '7 5' : undefined}
strokeLinecap="round"
fill="none"
opacity={op}
markerEnd={`url(#${markerId})`}
style={{ transition }}
/>
)
})}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId={markerId}
getAnchor={(id, kind) => {
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 }
}}
/>
</svg>
{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 (
<div
key={n.id}
className={cn(
'absolute flex flex-col justify-center rounded-2xl px-3 py-2.5 backdrop-blur-[2px]',
dark ? 'bg-[#141820]/ee text-zinc-50' : 'bg-white/95 text-zinc-900'
)}
style={{
left: `${((p.x - p.w / 2) / width) * 100}%`,
top: `${((p.y - p.h / 2) / height) * 100}%`,
width: `${(p.w / width) * 100}%`,
minHeight: `${(p.h / height) * 100}%`,
opacity: op,
borderStyle: 'solid',
borderTopWidth: lit ? 2 : 1.5,
borderRightWidth: lit ? 2 : 1.5,
borderBottomWidth: lit ? 2 : 1.5,
borderLeftWidth: 5,
borderTopColor: lit ? outline : accent,
borderRightColor: lit ? outline : accent,
borderBottomColor: lit ? outline : accent,
borderLeftColor: accent,
boxShadow: lit
? `0 0 0 3px ${outline}33, 0 10px 28px -8px ${outline}66`
: dark
? '0 8px 24px -10px rgba(0,0,0,0.65)'
: '0 8px 22px -10px rgba(15,23,42,0.18)',
transition,
}}
>
<DemoRichLabel text={label} lit={lit} />
</div>
)
})}
</div>
)
}
function ChartPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'chart' }>
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<string, number | string> = { 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 (
<div className="w-full h-52 min-h-[13rem] min-w-0">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 12, right: 16, left: 4, bottom: 8 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis
dataKey="i"
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
width={40}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
borderRadius: 10,
border: '1px solid var(--border)',
background: dark ? '#18181b' : '#fff',
fontSize: 12,
}}
/>
{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 (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
fillOpacity={op}
radius={[4, 4, 0, 0]}
isAnimationActive={!reducedMotion}
/>
)
}
if (panel.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={op * 0.22}
strokeOpacity={op}
strokeWidth={2}
isAnimationActive={!reducedMotion}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeOpacity={op}
strokeWidth={2.5}
dot={false}
isAnimationActive={!reducedMotion}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
)
}
function HeatmapPanel({
panel,
state,
reducedMotion,
dark,
}: {
panel: Extract<Panel, { type: 'heatmap-matrix' }>
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)'
// Intensity ∝ value — normalized so real-world scales (not just [0,1]) work
const maxAbsV = Math.max(
1e-9,
...values.flat().map((v) => Math.abs(v))
)
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])
const uid = useId().replace(/:/g, '')
const markerId = `demo-arrowhead-hm-${uid}`
return (
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
<defs>
<marker
id={markerId}
markerWidth="9"
markerHeight="9"
refX="7"
refY="3.5"
orient="auto"
>
<path d="M0,0 L7,3.5 L0,7 Z" fill={fillIntent} />
</marker>
</defs>
{colLabels?.map((lab, i) => (
<text
key={`c-${i}`}
x={labelW + i * cell + cell / 2}
y={labelH / 2 + 1}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{rowLabels?.map((lab, i) => (
<text
key={`r-${i}`}
x={labelW / 2}
y={labelH + i * cell + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={500}
fill={dark ? '#a1a1aa' : '#71717a'}
>
{lab}
</text>
))}
{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 (
<rect
key={id}
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill="none"
stroke={ghostStroke}
strokeWidth={1}
strokeDasharray="3 2"
opacity={0.8}
/>
)
}
const vNorm = Math.min(1, Math.abs(v) / maxAbsV)
const fillOpacity = 0.14 + vNorm * 0.78
const textFill =
vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
return (
<g key={id} opacity={op} style={{ transition }}>
<rect
x={x + 1.5}
y={y + 1.5}
width={cell - 3}
height={cell - 3}
rx={5}
fill={fillIntent}
fillOpacity={fillOpacity}
stroke={lit ? fillIntent : 'transparent'}
strokeWidth={lit ? 2 : 0}
/>
<text
x={x + cell / 2}
y={y + cell / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontWeight={600}
fill={textFill}
>
{v.toFixed(2)}
</text>
</g>
)
})
)}
<AnnotationsOverlay
annotations={state.annotations}
dark={dark}
markerId={markerId}
getAnchor={(id, kind) => {
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 }
}}
/>
</svg>
)
}
export function DemoSceneView({
scene,
state,
reducedMotion,
className,
}: DemoSceneViewProps) {
const dark = useDarkMode()
return (
<div
className={cn(
'grid gap-4',
scene.panels.length === 2 ? 'md:grid-cols-2' : 'grid-cols-1',
className
)}
>
{scene.panels.map((panel) => (
<div
key={panel.id}
className={cn(
'overflow-hidden rounded-xl border shadow-sm',
dark
? 'border-white/10 bg-[#0f1115]'
: 'border-black/10 bg-[#faf9f6]'
)}
>
{panel.type === 'svg-scene' && (
<SvgScenePanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
)}
{panel.type === 'chart' && (
<div className="p-3">
<ChartPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
{panel.type === 'heatmap-matrix' && (
<div className="p-3">
<HeatmapPanel
panel={panel}
state={state}
reducedMotion={reducedMotion}
dark={dark}
/>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -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 (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
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
}

View File

@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
</div>
)
}
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
return (
<div
className={cn(
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
className
)}
data-demo-id={demo.id}
data-mode={mode}
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
{demo.disclaimer && (
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
{demo.disclaimer}
</p>
)}
<div className="p-3 sm:p-4">
<DemoSceneView
scene={scene}
state={state}
reducedMotion={reducedMotion || mode === 'static'}
/>
</div>
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
<DemoSpeak
speak={state.speak}
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
/>
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
{progressLabel}
</span>
</div>
{mode === 'interactive' && (
<div className="flex flex-wrap items-center gap-2 pb-2">
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex - 1)
}}
aria-label="Previous step"
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex + 1)
}}
aria-label="Next step"
>
<StepForward className="h-4 w-4" />
</button>
<div className="mx-0.5 h-5 w-px bg-border" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={prevAct}
aria-label="Previous act"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={skipAct}
aria-label="Next act"
>
<SkipForward className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={resetAct}
aria-label="Reset act"
title="R"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
role="group"
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
>
{SPEEDS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setSpeedIdx(i)}
aria-pressed={speedIdx === i}
className={cn(
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
speedIdx === i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{s}×
</button>
))}
</div>
</div>
)}
</div>
<noscript>
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
{act.steps.map((s) => (
<li key={s.stepId}>{s.speak}</li>
))}
</ol>
</noscript>
</div>
)
}

View File

@@ -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<Phase>('idle')
const [page, setPage] = useState<PageSpecV1 | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<string | null>(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 (aligned with API min 40 words)
const wordCount = content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 40) {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle className="flex items-center gap-2 text-base">
<Clapperboard className="h-4 w-4 text-brand-accent" />
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</DialogTitle>
<DialogDescription className="text-xs">
{phase === 'generating'
? generatingHint
: t('richTextEditor.publishInteractivePagePreviewHint') ||
'Aperçu — vérifiez puis publiez sur lURL publique'}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
{phase === 'generating' ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
<p>{generatingHint}</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
{elapsedSec}s
</p>
</div>
) : null}
{phase === 'error' ? (
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
<p className="font-medium">
{t('richTextEditor.publishInteractivePageFailed') ||
'Échec de la page interactive'}
</p>
<p className="mt-2 text-muted-foreground">{error}</p>
</div>
) : null}
{phase === 'preview' || phase === 'publishing' ? (
page ? <PageView page={page} demoMode="interactive" /> : null
) : null}
</div>
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={phase === 'publishing'}
>
{t('general.cancel') || 'Annuler'}
</Button>
<Button
type="button"
onClick={handlePublish}
disabled={phase !== 'preview' || !page}
className="gap-2"
>
{phase === 'publishing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
{t('richTextEditor.publishInteractivePageConfirm') ||
'Publier la page'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
import { AlertCircle } from 'lucide-react'
const STRINGS = {
fr: {
stale: 'Le contenu source a évolué — cette page interactive est à régénérer.',
invalid: 'Page interactive indisponible',
},
en: {
stale: 'The source content has changed — this interactive page needs regeneration.',
invalid: 'Interactive page unavailable',
},
}
/**
* 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
let lang = 'fr'
try {
const raw = JSON.parse(publishedContent)
const result = validateInteractivePage(raw)
if (result.ok) {
page = result.page
lang = result.page.lang || 'fr'
} else {
error = result.issues[0]?.message || 'PageSpec invalide'
}
} catch {
error = 'JSON de page interactive illisible'
}
const t = lang.startsWith('fr') ? STRINGS.fr : STRINGS.en
if (!page) {
return (
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p>{error || t.invalid}</p>
</div>
)
}
return (
<div>
{isStale ? (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
{t.stale}
</div>
) : null}
<PageView page={page} demoMode="interactive" />
</div>
)
}

View File

@@ -0,0 +1,391 @@
'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 { StepsBlockView } from '@/components/interactive-page/steps-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<IntentId>()
const collectFromPanels = (panels: InteractiveDemoV1['scene']['panels']) => {
for (const panel of 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)
}
}
}
collectFromPanels(demo.scene.panels)
for (const act of demo.acts) {
if (act.scene) collectFromPanels(act.scene.panels)
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<IntentId, { fr: string; en: string }> = {
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 (
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{fr ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => (
<span key={id} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: intentColor(id, dark) }}
/>
{fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
</span>
))}
</div>
)
}
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 (
<span
className="inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
style={{
color,
backgroundColor: `${color}22`,
border: `1px solid ${color}44`,
}}
>
{label}
</span>
)
}
function ChartBlockView({
block,
}: {
block: Extract<PageBlock, { type: 'chart' }>
}) {
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<string, number | string> = { 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 (
<figure
className="my-6 rounded-xl border p-4"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="h-56 min-h-[14rem] min-w-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
/>
<XAxis dataKey="i" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} width={36} axisLine={false} tickLine={false} />
<Tooltip />
{series.map((s) => {
const stroke = intentColor(s.intent, dark)
if (block.payload.chartType === 'bar') {
return (
<Bar
key={s.id}
dataKey={s.id}
fill={stroke}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
)
}
if (block.payload.chartType === 'area') {
return (
<Area
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
fill={stroke}
fillOpacity={0.22}
strokeWidth={2}
isAnimationActive={false}
/>
)
}
return (
<Line
key={s.id}
type="monotone"
dataKey={s.id}
stroke={stroke}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
)
})}
</Chart>
</ResponsiveContainer>
</div>
{block.caption ? (
<figcaption className="mt-3 text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
export function PageBlockView({
block,
demoMode = 'static',
lang = 'fr',
}: {
block: PageBlock
demoMode?: 'interactive' | 'static'
lang?: string
}) {
if (block.type === 'prose') {
return <PageMd md={block.md} className="my-4 text-[17px] leading-[1.7]" />
}
if (block.type === 'formula') {
return <PageFormula tex={block.tex} caption={block.caption} />
}
if (block.type === 'callout') {
const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
return (
<aside
className={cn(
'my-5 rounded-xl border px-4 py-3.5 shadow-sm',
style.border,
style.bg
)}
>
<p
className={cn(
'mb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]',
style.badge
)}
>
{block.kind} · {block.title}
</p>
<PageMd md={block.md} className="text-[15px] [&_p]:my-1" />
</aside>
)
}
if (block.type === 'demo') {
return (
<figure className="my-8">
{block.caption ? (
<p className="mb-3 text-[15px] leading-relaxed text-muted-foreground">
{block.caption}
</p>
) : null}
<InteractiveDemoPlayer demo={block.demo} mode={demoMode} />
<DemoLegend demo={block.demo} lang={lang} />
{demoMode === 'static' ? (
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm text-muted-foreground">
{block.demo.acts.flatMap((a) =>
a.steps.map((s) => (
<li key={s.id}>
<strong>{a.title}</strong> {s.speak}
</li>
))
)}
</ol>
</noscript>
) : null}
</figure>
)
}
if (block.type === 'sim') {
return <SimBlockView block={block} lang={lang} />
}
if (block.type === 'steps') {
return <StepsBlockView block={block} lang={lang} />
}
if (block.type === 'chart') {
return <ChartBlockView block={block} />
}
if (block.type === 'stats') {
return (
<div className="my-8 grid grid-cols-2 gap-3 md:grid-cols-4">
{block.items.map((item, i) => (
<div
key={`${item.label}-${i}`}
className="rounded-[14px] border px-4 py-3.5"
style={{ background: 'var(--pp-paper)', borderColor: 'var(--pp-line)' }}
>
<p
className="font-extrabold leading-tight tabular-nums"
style={{
fontSize: 'clamp(22px, 2.4vw, 30px)',
color: 'var(--pp-plum)',
}}
>
{item.value}
</p>
<p className="mt-1.5 text-[13px] leading-snug" style={{ color: 'var(--pp-ink)' }}>
{item.label}
</p>
</div>
))}
</div>
)
}
if (block.type === 'table') {
return (
<figure
className="my-6 overflow-x-auto rounded-xl border"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<table className="w-full min-w-[460px] border-collapse text-[13.5px]">
{block.caption ? (
<caption
className="px-3 py-2.5 text-left text-xs"
style={{ fontFamily: 'var(--pp-mono)', color: 'var(--pp-muted)' }}
>
{block.caption}
</caption>
) : null}
<thead>
<tr style={{ borderBottom: '1px solid var(--pp-line)', background: 'var(--pp-paper)' }}>
{block.columns.map((c) => (
<th
key={c}
className="px-3 py-2.5 text-left font-semibold tracking-tight"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr
key={ri}
style={{ borderBottom: ri < block.rows.length - 1 ? '1px solid var(--pp-line)' : undefined }}
className="last:border-0"
>
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-2.5 align-top">
<PageMd md={cell} className="text-sm [&_p]:my-0" />
</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
)
}
if (block.type === 'image') {
return (
<figure className="my-6">
{ }
<img
src={block.src}
alt={block.alt}
className="mx-auto max-h-[480px] w-auto max-w-full rounded-xl border border-border/50 shadow-sm"
/>
{block.caption ? (
<figcaption className="mt-2 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}
return null
}
export { IntentBadge }

View File

@@ -0,0 +1,92 @@
'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) => {
// function replacement — $', $`, $& in KaTeX HTML must not be interpreted
out = out.replace(`%%KATEX${i}%%`, () => frag)
})
return sanitizeRichHtml(out)
}, [md])
return (
<div
className={cn(
'prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:tracking-tight',
className
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
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 (
<figure
className="my-5 overflow-x-auto rounded-xl border px-4 py-4 md:px-5"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
borderLeft: '4px solid var(--pp-plum)',
}}
>
<div
className="text-[1.15em] [&_.katex-display]:m-0"
dangerouslySetInnerHTML={{ __html: html }}
/>
{caption ? (
<figcaption
className="mt-2.5 text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{caption}
</figcaption>
) : null}
</figure>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { intentColor } from '@/lib/interactive-demo/intent-colors'
import type { IntentId } from '@/lib/interactive-demo/types'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
import { cn } from '@/lib/utils'
function collectIntents(page: PageSpecV1): IntentId[] {
const set = new Set<IntentId>()
for (const card of page.overview?.cards ?? []) {
if (card.intent) set.add(card.intent)
}
for (const section of page.sections) {
for (const block of section.blocks) {
if (block.type === 'stats') {
for (const item of block.items) {
if (item.intent) set.add(item.intent)
}
}
if (block.type === 'chart') {
for (const s of block.payload.series) {
if (s.intent) set.add(s.intent)
}
}
if (block.type === 'demo') {
const panels = [
...block.demo.scene.panels,
...block.demo.acts.flatMap((a) => a.scene?.panels ?? []),
]
for (const panel of panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
}
if (panel.type === 'chart') {
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
}
}
}
}
}
return [...set]
}
const INTENT_LABELS_FR: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flux',
cache: 'Mémoire',
compute: 'Calcul',
output: 'Résultat',
warning: 'Attention',
}
const INTENT_LABELS_EN: Record<IntentId, string> = {
highlight: 'Focus',
flow: 'Flow',
cache: 'Memory',
compute: 'Compute',
output: 'Result',
warning: 'Warning',
}
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
const [active, setActive] = useState(page.sections[0]?.id ?? '')
const dark = useDarkMode()
const intents = useMemo(() => collectIntents(page), [page])
useEffect(() => {
const nodes = page.sections
.map((s) => document.getElementById(s.id))
.filter(Boolean) as HTMLElement[]
if (!nodes.length) return
const obs = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
const top = visible[0]?.target?.id
if (top) setActive(top)
},
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [page.sections])
return (
<div
className="sticky top-0 z-30 backdrop-blur-sm"
style={{
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
borderTop: '1px solid var(--pp-line)',
borderBottom: '1px solid var(--pp-line)',
}}
>
<nav
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
aria-label="Sections"
style={{ fontFamily: 'var(--pp-mono)' }}
>
{page.sections.map((s, i) => (
<a
key={s.id}
href={`#${s.id}`}
className={cn(
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
active === s.id ? 'font-semibold' : 'hover:opacity-80'
)}
style={
active === s.id
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{i + 1} · {s.title}
</a>
))}
</nav>
{intents.length > 0 ? (
<div
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
style={{
borderTop: '1px solid var(--pp-line)',
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-muted)',
}}
>
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
</span>
{intents.map((id) => {
const color = intentColor(id, dark)
return (
<span
key={id}
className="inline-flex items-center gap-1.5"
>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
{page.lang.startsWith('fr')
? INTENT_LABELS_FR[id]
: INTENT_LABELS_EN[id]}
</span>
)
})}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,282 @@
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
import { PageMd } from '@/components/interactive-page/page-md'
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
import type { PageSpecV1 } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
export type PageViewProps = {
page: PageSpecV1
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
demoMode?: 'interactive' | 'static'
className?: string
paper?: boolean
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
function useScrollReveal(enabled: boolean) {
useEffect(() => {
if (!enabled) {
document
.querySelectorAll<HTMLElement>('[data-scroll-init]')
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
return
}
const nodes = Array.from(
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
)
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.setAttribute('data-scroll-visible', 'true')
obs.unobserve(e.target)
}
}
},
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
)
nodes.forEach((n) => obs.observe(n))
return () => obs.disconnect()
}, [enabled])
}
/**
* PageView — Kimi/AttnRes-style explainer page.
* Design tokens replicated from the reference page (paper, plum accent,
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
*/
export function PageView({
page,
demoMode = 'interactive',
className,
paper = true,
}: PageViewProps) {
const reducedMotion = usePrefersReducedMotion()
useScrollReveal(!reducedMotion)
const paperStyle = {
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
} as CSSProperties
return (
<article
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
data-page-id={page.id}
style={paperStyle}
>
<style>{`
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
.interactive-page {
--pp-paper: #F4F0E8;
--pp-paper-deep: #EAE4D9;
--pp-card: #FFFDF8;
--pp-ink: #242422;
--pp-muted: #686762;
--pp-line: #D6D0C6;
--pp-plum: #9F3F70;
--pp-plum-soft: #F2DCE8;
--pp-blue: #3F6F9F;
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--pp-ink);
}
.dark .interactive-page {
--pp-paper: #17140F;
--pp-paper-deep: #201C15;
--pp-card: #221E17;
--pp-ink: #EDE7DB;
--pp-muted: #A39C8D;
--pp-line: #3B352A;
--pp-plum: #D07AA6;
--pp-plum-soft: #3A2530;
--pp-blue: #7FA8CC;
}
.interactive-page.page-paper {
background-color: var(--pp-paper);
background-image: radial-gradient(
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
transparent 0.7px
);
background-size: 18px 18px;
}
.pp-card {
background: var(--pp-card);
border: 1px solid var(--pp-line);
border-radius: 14px;
}
.pp-mono {
font-family: var(--pp-mono);
}
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
opacity: 0;
transform: translateY(12px);
}
.interactive-page [data-scroll-init] {
transition: opacity 420ms ease, transform 420ms ease;
will-change: opacity, transform;
}
.interactive-page [data-scroll-visible] {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
`}</style>
{/* Without JS the scroll-reveal never fires — show everything */}
<noscript>
<style>{`
.interactive-page [data-scroll-init] {
opacity: 1 !important;
transform: none !important;
}
`}</style>
</noscript>
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
<p
className="pp-mono mb-2.5 text-xs uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
>
{page.hero.kicker}
</p>
<h1
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
>
{page.hero.title}
</h1>
{page.hero.subtitle ? (
<p
className="mt-2.5 font-semibold"
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
>
{page.hero.subtitle}
</p>
) : null}
{page.hero.meta ? (
<p
className="pp-mono mt-3 leading-relaxed"
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
>
{page.hero.meta}
</p>
) : null}
</header>
<PageStickyNav page={page} />
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
{/* ── One-minute overview ── */}
{page.overview ? (
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
<p
className="pp-mono mb-3 text-[11px] uppercase"
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
>
{page.lang.startsWith('fr')
? 'Lessentiel en une minute'
: 'One-minute overview'}
</p>
<PageMd
md={page.overview.lead}
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
/>
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{page.overview.cards.map((card) => (
<div
key={card.badge + card.title}
className="rounded-xl border p-4"
style={{
background: 'var(--pp-paper)',
borderColor: 'var(--pp-line)',
}}
>
<IntentBadge label={card.badge} intent={card.intent} />
<h3 className="mt-3 text-base font-semibold tracking-tight">
{card.title}
</h3>
<PageMd
md={card.body}
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
/>
</div>
))}
</div>
</section>
) : null}
{/* ── Sections ── */}
<div className="space-y-20">
{page.sections.map((section, i) => (
<section
key={section.id}
id={section.id}
className="scroll-mt-28"
data-scroll-init
>
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
<span
className="pp-mono text-sm font-semibold"
style={{ color: 'var(--pp-plum)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
{section.title}
</h2>
<div>
{section.blocks.map((block, bi) => {
const narrow =
block.type === 'prose' ||
block.type === 'formula' ||
block.type === 'callout'
return (
<div
key={`${section.id}-${bi}`}
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
>
<PageBlockView
block={block}
demoMode={demoMode}
lang={page.lang}
/>
</div>
)
})}
</div>
</section>
))}
</div>
{page.footer ? (
<footer
className="pp-mono mt-16 border-t pt-5 text-xs italic"
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
>
{page.footer}
</footer>
) : null}
</div>
</article>
)
}

View File

@@ -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 = (
<GenericFormulaView
sim={sim as Extract<typeof sim, { simId: 'generic-formula' }>}
lang={lang}
/>
)
} else {
if (!plugin) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
if (plugin.family === 'anim') {
const AnimScene = ANIM_VIEWS[sim.simId]
if (!AnimScene) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
</div>
)
}
body = (
<AnimPlayerShell beats={plugin.beats} lang={lang} disclaimer={plugin.disclaimer}>
{(stepIndex) => <AnimScene step={stepIndex} lang={lang} />}
</AnimPlayerShell>
)
} else {
const View = SIMULATOR_VIEWS[sim.simId]
if (!View) {
return (
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
</div>
)
}
const preset = (sim as { preset?: Record<string, number> }).preset
body = (
<View
preset={preset}
title={title}
disclaimer={sim.disclaimer}
lang={lang}
/>
)
}
}
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{kindLabel}
</span>
</div>
{body}
{block.caption ? (
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
{block.caption}
</figcaption>
) : null}
</figure>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
import type { StepsBlock } from '@/lib/interactive-page'
import { cn } from '@/lib/utils'
function renderTex(tex: string, displayMode: boolean): string {
try {
return katex.renderToString(tex, { displayMode, throwOnError: false })
} catch {
return tex
}
}
/**
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
* line by line, current line highlighted, transformation rule in the margin.
* No boxes, no LLM drawing — pure KaTeX + deterministic chrome.
*/
export function StepsBlockView({
block,
lang,
}: {
block: StepsBlock
lang: string
}) {
const fr = lang.startsWith('fr')
const steps = block.steps
const beats = useMemo(
() =>
steps.map((s, i) => ({
id: `st${i + 1}`,
speak: {
fr: s.speak || s.rule || (fr ? `Étape ${i + 1}` : `Step ${i + 1}`),
en: s.speak || s.rule || `Step ${i + 1}`,
},
})),
[steps, fr]
)
const rendered = useMemo(() => steps.map((s) => renderTex(s.tex, true)), [steps])
return (
<figure
className="my-8 rounded-2xl border p-4 md:p-5"
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
>
{block.title ? (
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">{block.title}</h3>
<span
className="text-[10px] font-semibold uppercase tracking-[0.16em]"
style={{ color: 'var(--pp-muted)' }}
>
{fr ? 'Dérivation pas à pas' : 'Step-by-step derivation'}
</span>
</div>
) : null}
<AnimPlayerShell beats={beats} lang={lang}>
{(stepIndex) => (
<div className="space-y-1.5 p-3 md:p-4" style={{ background: 'var(--pp-paper)' }}>
{steps.slice(0, stepIndex + 1).map((s, i) => {
const current = i === stepIndex
return (
<div
key={i}
className={cn(
'flex items-start gap-3 rounded-lg border-l-4 px-3 py-2',
current ? 'shadow-sm' : 'border-transparent'
)}
style={{
borderLeftColor: current ? 'var(--pp-plum)' : 'transparent',
background: current ? 'var(--pp-card)' : 'transparent',
opacity: current ? 1 : 0.72,
transition: 'opacity 400ms ease, background 400ms ease',
}}
>
<span
className="mt-1 shrink-0 text-[10px] font-semibold tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{String(i + 1).padStart(2, '0')}
</span>
<div
className="min-w-0 flex-1 overflow-x-auto text-[1.02em] [&_.katex-display]:my-1"
dangerouslySetInnerHTML={{ __html: rendered[i] }}
/>
{s.rule ? (
<span
className="mt-0.5 shrink-0 rounded-md px-2 py-1 text-[11px] leading-snug"
style={{
fontFamily: 'var(--pp-mono)',
color: 'var(--pp-plum)',
background: 'color-mix(in oklab, var(--pp-plum) 10%, transparent)',
maxWidth: '38%',
}}
>
{s.rule}
</span>
) : null}
</div>
)
})}
</div>
)}
</AnimPlayerShell>
{block.caption ? (
<figcaption
className="mt-3 text-center text-sm"
style={{ color: 'var(--pp-muted)' }}
>
{block.caption}
</figcaption>
) : null}
{/* Without JS: full derivation visible + rules listed */}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-2 text-sm">
{steps.map((s, i) => (
<li key={i}>
<span dangerouslySetInnerHTML={{ __html: rendered[i] }} />
{s.rule ? <em className="ml-2 text-muted-foreground">({s.rule})</em> : null}
</li>
))}
</ol>
</noscript>
</figure>
)
}

View File

@@ -9,6 +9,7 @@ import Link from 'next/link'
import Image from 'next/image'
import { useLanguage } from '@/lib/i18n'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useEffect, useRef, useState, type ReactNode } from 'react'
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
@@ -68,11 +69,30 @@ export function LandingPage() {
return () => { root.style.overflow = prev }
}, [menuOpen])
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
const NAV = [
@@ -467,7 +487,9 @@ export function LandingPage() {
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button>
</div>
</div>
@@ -493,8 +515,19 @@ export function LandingPage() {
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
</div>
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat.startsWith('landing.')) return null
@@ -514,7 +547,9 @@ export function LandingPage() {
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link>
</div>
))}

View File

@@ -1,12 +1,15 @@
'use client';
import { useActionState } from 'react';
import { useActionState, useRef, useState, Suspense } from 'react';
import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { GoogleSignInButton } from '@/components/google-sign-in-button';
import { resendSignupVerification } from '@/app/actions/auth-verify';
import { toast } from 'sonner';
function AuthDivider({ label }: { label: string }) {
return (
@@ -41,7 +44,7 @@ function LoginButton() {
);
}
export function LoginForm({
function LoginFormInner({
allowRegister = true,
googleAuthEnabled = false,
authError,
@@ -51,7 +54,11 @@ export function LoginForm({
authError?: string;
}) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
const { t } = useLanguage();
const { t, language } = useLanguage();
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const [resending, setResending] = useState(false);
const verified = searchParams.get('verified') === '1';
const oauthError =
authError === 'SessionRequired'
? t('auth.sessionExpired')
@@ -59,6 +66,21 @@ export function LoginForm({
? t('auth.oauthAccountNotLinked')
: authError ?? null;
const showUnverified = errorMessage === 'EMAIL_NOT_VERIFIED';
const handleResend = async () => {
const email = emailRef.current?.value?.trim() ?? '';
if (!email) {
toast.error(t('auth.verifyMissingEmail'));
return;
}
setResending(true);
const result = await resendSignupVerification(email, language);
setResending(false);
if (result.success) toast.success(t('auth.verifyResent'));
else toast.error(t('auth.verifyResendFailed'));
};
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
<div className="space-y-8">
@@ -71,6 +93,12 @@ export function LoginForm({
</p>
</div>
{verified && (
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
{t('auth.emailVerifiedBanner')}
</p>
)}
{oauthError && (
<p className="text-sm text-red-500 text-center px-2" role="alert">
{oauthError}
@@ -94,6 +122,7 @@ export function LoginForm({
<Mail size={16} />
</div>
<input
ref={emailRef}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
id="email"
type="email"
@@ -136,13 +165,26 @@ export function LoginForm({
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
<div className="space-y-2" aria-live="polite" aria-atomic="true">
{showUnverified && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
{resending ? t('auth.sending') : t('auth.resendVerification')}
</button>
</div>
)}
{errorMessage && !showUnverified && (
<p className="text-sm text-red-500">
{errorMessage === 'Invalid credentials.'
? t('auth.invalidCredentials')
: errorMessage}
</p>
)}
</div>
</form>
@@ -164,3 +206,15 @@ export function LoginForm({
</div>
);
}
export function LoginForm(props: {
allowRegister?: boolean;
googleAuthEnabled?: boolean;
authError?: string;
}) {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<LoginFormInner {...props} />
</Suspense>
);
}

View File

@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter()
const searchParams = useSearchParams()
const peekNoteId = searchParams.get('peekNote')
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
const stripPeekFromUrl = useCallback(() => {
if (!searchParams.get('peekNote')) return
const params = new URLSearchParams(searchParams.toString())
params.delete('peekNote')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
}, [router, searchParams])
useEffect(() => {
const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}
}, [noteId, t])
// Dashboard « Comparer / Lier » : ouvrir la note liée à droite dès que léditeur est monté.
useEffect(() => {
if (!peekNoteId || peekNoteId === noteId) return
let cancelled = false
void getNoteById(peekNoteId).then((fetched) => {
if (cancelled) return
if (fetched) {
setPeekState(prev => (prev?.note.id === fetched.id ? prev : { note: fetched }))
} else {
toast.error(t('notePeek.loadFailed'))
}
})
return () => { cancelled = true }
}, [peekNoteId, noteId, t])
const handleClosePeek = useCallback(() => {
setPeekState(null)
}, [])
stripPeekFromUrl()
}, [stripPeekFromUrl])
const handleOpenPeekFully = useCallback(() => {
if (!peekState) return
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
}))
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id)
params.delete('peekNote')
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null)
}, [noteId, peekState, router, searchParams])
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
blockId={peekState.blockId}
onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully}
onBackToDashboard={
searchParams.get('from') === 'dashboard'
? () => router.replace('/home')
: undefined
}
/>
)}
</AnimatePresence>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { X, Maximize2 } from 'lucide-react'
import { X, Maximize2, LayoutGrid } from 'lucide-react'
import type { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
@@ -17,6 +17,7 @@ interface NoteEditorSplitPeekProps {
blockId?: string
onClose: () => void
onOpenFully: () => void
onBackToDashboard?: () => void
}
function PeekEditorBody({ blockId }: { blockId?: string }) {
@@ -54,7 +55,7 @@ function PeekEditorBody({ blockId }: { blockId?: string }) {
)
}
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully, onBackToDashboard }: NoteEditorSplitPeekProps) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
@@ -77,6 +78,16 @@ export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: Not
{t('notePeek.label')}
</span>
<div className="flex items-center gap-1 shrink-0">
{onBackToDashboard && (
<button
type="button"
onClick={onBackToDashboard}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<LayoutGrid size={12} />
{t('notes.backToDashboard')}
</button>
)}
<button
type="button"
onClick={onOpenFully}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -19,10 +20,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'
@@ -47,12 +49,16 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
const { requestAiConsent } = useAiConsent()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
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 +349,33 @@ 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
setPublishLoading(true)
try {
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)
} finally {
setPublishLoading(false)
}
}
const handlePublishWithAi = async () => {
@@ -539,7 +572,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
<span className="text-sm font-medium">
{fromDashboard ? t('notes.backToDashboard') : t('notes.backToCollection')}
</span>
</button>
<div className="flex items-center gap-1.5 sm:gap-2">
@@ -664,6 +699,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.publishSimpleHint')}
</p>
<button
type="button"
onClick={handlePublishInteractivePage}
disabled={publishLoading}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
>
{publishLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Clapperboard size={14} />
)}
{t('richTextEditor.publishInteractivePage') ||
'Page interactive'}
</button>
<p className="text-[10px] text-muted-foreground text-center -mt-1">
{t('richTextEditor.publishInteractivePageHint') ||
'Hero, sections, démos Play/Step — 20 crédits'}
</p>
<div className="border-t border-border/50 pt-3 space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles size={13} className="text-brand-accent shrink-0" />
@@ -679,7 +733,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</p>
{/* Sélection template */}
<div className="space-y-1">
{PUBLISH_TEMPLATES.map((tpl) => (
{classicPublishTemplates.map((tpl) => (
<label
key={tpl}
className={cn(
@@ -1009,6 +1063,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}}
/>
<InteractivePagePublishDialog
open={interactivePageOpen}
onOpenChange={setInteractivePageOpen}
noteId={note.id}
content={interactivePageContent}
language={language}
onPublished={(slug) => {
setPublishMeta({
isPublic: true,
slug,
template: 'interactive-page',
})
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
@@ -70,7 +71,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -298,6 +299,9 @@ const slashCommands: SlashItem[] = [
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) },
},
{
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
},
{
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
command: (e) => { insertToggleBlock(e) },
@@ -385,7 +389,7 @@ function useImageInsert() {
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
@@ -589,6 +593,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
InteractiveDemoExtension,
ToggleExtension,
CalloutExtension,
OutlineExtension,
@@ -951,6 +956,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
setChartSuggestionsOpen(true)
}, [editor, requestAiConsent])
const handleGenerateInteractiveDemo = useCallback(async () => {
if (!editor || !editor.isEditable) return
const consented = await requestAiConsent()
if (!consented) return
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
let content = ''
let selection: string | null = null
try {
content = editor.getHTML() || editor.state.doc.textContent || ''
const { from, to, empty } = editor.state.selection
if (!empty) {
const parts: string[] = []
editor.state.doc.nodesBetween(from, to, (node) => {
const latex = (node.attrs as { latex?: string } | undefined)?.latex
if (typeof latex === 'string' && latex.trim()) {
parts.push(
node.type.name === 'mathEquationBlock'
? `$$${latex}$$`
: `$${latex}$`
)
return false
}
if (node.isText && node.text) {
parts.push(node.text)
}
return true
})
selection = parts.join(' ').trim() || null
}
} catch (err) {
console.warn('[interactive-demo] content extract fallback', err)
content = editor.state.doc.textContent || ''
}
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
const words = sourceForWords.split(/\s+/).filter(Boolean).length
if (words < 20) {
toast.error(
t('interactiveDemo.needMoreText') ||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
)
return
}
const toastId = toast.loading(
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
)
try {
const { generateInteractiveDemo } = await import(
'@/lib/ai/services/interactive-demo-client.service'
)
const result = await generateInteractiveDemo({
content,
selection,
lang: language || 'fr',
noteId: noteId || undefined,
})
if (!result.ok) {
if (result.quotaExceeded) {
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
id: toastId,
})
} else {
toast.error(
typeof result.error === 'string'
? result.error
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
return
}
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
toast.error(
t('interactiveDemo.insertFailed') || 'Impossible dinsérer la démo',
{ id: toastId }
)
return
}
window.dispatchEvent(new Event('ai-usage-changed'))
toast.success(
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
{ id: toastId }
)
} catch (err) {
console.error('[interactive-demo] generate error', err)
toast.error(
t('interactiveDemo.generateFailed') || 'Échec de la génération',
{ id: toastId }
)
}
}, [editor, requestAiConsent, t, language, noteId])
const insertCitationInEditor = useCallback((
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
@@ -1321,7 +1420,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
{editor && (
<SlashCommandMenu
editor={editor}
onInsertImage={imageInsert.requestInsert}
onSuggestCharts={handleOpenChartSuggestions}
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
/>
)}
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
@@ -1910,7 +2016,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
}
}
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [isOpen, setIsOpen] = useState(false)
@@ -1968,6 +2074,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
@@ -2083,6 +2190,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
) {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (
item.title === 'Interactive Demo'
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
) {
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
@@ -2092,7 +2204,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
// Charger les favoris fréquents lors de l'ouverture
useEffect(() => {

View File

@@ -10,6 +10,7 @@ import { toast } from 'sonner';
import { format } from 'date-fns';
import { motion } from 'motion/react';
import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year';
@@ -22,6 +23,9 @@ interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
trialEndsAt?: string | null;
trialEligible?: boolean;
trialDays?: number;
billingEnabled?: boolean;
prices?: {
PRO: {
@@ -250,6 +254,12 @@ export function BillingPlans() {
const effectiveTier = status?.effectiveTier ?? 'BASIC';
const isPaid = effectiveTier !== 'BASIC';
const isTrialing = (status?.status ?? '').toUpperCase() === 'TRIALING';
const trialEligible = !!status?.trialEligible;
const trialDays = status?.trialDays ?? SUBSCRIPTION_TRIAL_DAYS;
const trialCta = (fallback: string) =>
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
const plans = [
{
@@ -280,6 +290,7 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
@@ -289,7 +300,7 @@ export function BillingPlans() {
],
current: effectiveTier === 'PRO',
popular: true,
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.proCta') || 'Passer au Plan Pro'),
buttonClass: effectiveTier === 'PRO'
? 'bg-paper text-concrete cursor-default'
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
@@ -302,6 +313,7 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
@@ -310,7 +322,7 @@ export function BillingPlans() {
t('billing.businessFeature6'),
],
current: effectiveTier === 'BUSINESS',
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
buttonClass: effectiveTier === 'BUSINESS'
? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
@@ -415,7 +427,9 @@ export function BillingPlans() {
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
status?.status === 'active' || status?.status === 'ACTIVE'
isTrialing
? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20'
: status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
@@ -425,6 +439,12 @@ export function BillingPlans() {
)}
</div>
{isTrialing && status?.trialEndsAt && (
<p className="text-xs text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/20 rounded-xl px-3 py-2">
{t('billing.trialEndsOn', { date: formatDate(status.trialEndsAt) })}
</p>
)}
{isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
@@ -439,10 +459,18 @@ export function BillingPlans() {
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
{isTrialing
? t('billing.trialEndsLabel')
: status?.cancelAtPeriodEnd
? t('billing.expiresOn')
: t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
{isTrialing && status?.trialEndsAt
? formatDate(status.trialEndsAt)
: status?.currentPeriodEnd
? formatDate(status.currentPeriodEnd)
: '—'}
</p>
</div>
</div>
@@ -619,6 +647,7 @@ export function BillingPlans() {
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
interactive_demo: t('usageMeter.featureInteractiveDemo'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),

View File

@@ -32,6 +32,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
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',

View File

@@ -1433,7 +1433,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
label: t('nav.dashboard') || 'Dashboard',
onClick: () => {
setActiveView('dashboard')
router.push('/home')
router.replace('/home')
},
isActive: isDashboardRoute,
},

View File

@@ -0,0 +1,231 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
import { PageMd } from '@/components/interactive-page/page-md'
import type { AnimBeat, SimI18n } from '@/lib/simulators'
import { cn } from '@/lib/utils'
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const SPEEDS = [0.5, 1, 2, 4]
function stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
}
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
}
export type AnimPlayerShellProps = {
beats: AnimBeat[]
lang: string
disclaimer?: SimI18n
/** Scene renderer — receives the current beat index (0-based). */
children: (stepIndex: number) => React.ReactNode
}
/**
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
* + narration panel, same pacing rules as InteractiveDemoPlayer.
*/
export function AnimPlayerShell({
beats,
lang,
disclaimer,
children,
}: AnimPlayerShellProps) {
const fr = lang.startsWith('fr')
const reducedMotion = usePrefersReducedMotion()
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const atLast = stepIndex >= beats.length - 1
useEffect(() => {
clearTimer()
if (!playing) return
const beat = beats[stepIndex]
if (!beat) return
const speak = fr ? beat.speak.fr : beat.speak.en
timerRef.current = setTimeout(
() => {
if (stepIndex >= beats.length - 1) {
setPlaying(false)
return
}
setStepIndex((s) => s + 1)
},
reducedMotion ? 250 : stepDurationMs(speak, speed)
)
return clearTimer
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') 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.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
setStepIndex((s) => Math.max(0, s - 1))
} else if (e.code === 'KeyR') {
e.preventDefault()
setPlaying(false)
setStepIndex(0)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [beats.length])
const beat = beats[stepIndex]
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
return (
<div
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
{children(stepIndex)}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-plum)' }}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
}}
aria-label={fr ? 'Étape suivante' : 'Next step'}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<StepForward className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setPlaying(false)
setStepIndex(0)
}}
aria-label="Reset"
title="R"
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: 'var(--pp-ink)' }}
>
<RotateCcw className="h-4 w-4" />
</button>
<span
className="ml-2 text-xs tabular-nums"
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
>
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
</span>
<div
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
style={{ borderColor: 'var(--pp-line)' }}
role="group"
aria-label={fr ? 'Vitesse' : 'Speed'}
>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
)}
style={
speed === s
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
: { color: 'var(--pp-muted)' }
}
>
{s}×
</button>
))}
</div>
</div>
<div
className="mt-2 rounded-lg border px-4 py-3"
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
>
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
</div>
{disclaimer ? (
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
{fr ? disclaimer.fr : disclaimer.en}
</p>
) : null}
<noscript>
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
{beats.map((b) => (
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
))}
</ol>
</noscript>
</div>
)
}

View File

@@ -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', 'η']
// PV 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 (
<svg
viewBox="0 0 680 300"
className="w-full"
role="img"
aria-label={fr ? 'Animation du cycle de Carnot' : 'Carnot cycle animation'}
style={{ background: paper, display: 'block' }}
>
{/* ══ Piston apparatus ══ */}
<g>
{/* cylinder walls */}
<rect x={70} y={40} width={110} height={195} fill={card} stroke={ink} strokeWidth={2} rx={4} />
{/* gas: cold + hot crossfade */}
<g style={{ transform: `translateY(${pistonY - 45}px)`, transition: EASE }}>
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={blue} opacity={GAS_COLD[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={plum} opacity={GAS_HOT[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
</g>
{/* piston */}
<g style={{ transform: `translateY(${pistonY - 95}px)`, transition: EASE }}>
<rect x={74} y={95} width={102} height={14} fill={ink} rx={3} opacity={0.9} />
<rect x={118} y={60} width={14} height={36} fill={ink} rx={3} opacity={0.9} />
<rect x={105} y={48} width={40} height={12} fill={ink} rx={4} opacity={0.9} />
</g>
{/* hot plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={plum} opacity={showHotPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showHotPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={plum}>
{fr ? 'Source chaude' : 'Hot'} T_h
</text>
) : null}
{/* cold plate */}
<rect x={60} y={240} width={130} height={12} rx={4} fill={blue} opacity={showColdPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
{showColdPlate ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={blue}>
{fr ? 'Source froide' : 'Cold'} T_c
</text>
) : null}
{/* insulation */}
<rect x={60} y={240} width={130} height={12} rx={4} fill="none" stroke={muted} strokeWidth={2} strokeDasharray="6 4" opacity={showInsulation ? 1 : 0} style={{ transition: 'opacity 500ms ease' }} />
{showInsulation ? (
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={muted}>
{fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'}
</text>
) : null}
{/* Q_h arrow in */}
<g opacity={showQh ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={95} y1={238} x2={95} y2={196} stroke={plum} strokeWidth={5} strokeLinecap="round" />
<polygon points="95,190 89,202 101,202" fill={plum} />
<text x={70} y={210} fontSize={12} fontWeight={700} fill={plum}>Q_h</text>
</g>
{/* Q_c arrow out */}
<g opacity={showQc ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={155} y1={196} x2={155} y2={238} stroke={blue} strokeWidth={5} strokeLinecap="round" />
<polygon points="155,244 149,232 161,232" fill={blue} />
<text x={164} y={226} fontSize={12} fontWeight={700} fill={blue}>Q_c</text>
</g>
{/* W arrows */}
<g opacity={showWout ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={180} x2={210} y2={120} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,112 204,124 216,124" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
<g opacity={showWin ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
<line x1={210} y1={120} x2={210} y2={180} stroke={ink} strokeWidth={4} strokeLinecap="round" />
<polygon points="210,188 204,176 216,176" fill={ink} />
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
</g>
{/* temperature label inside gas */}
<text x={125} y={pistonY + 40} textAnchor="middle" fontSize={13} fontWeight={700} fill={ink} style={{ transition: EASE }}>
{T_LABEL[s]}
</text>
</g>
{/* ══ PV diagram ══ */}
<g>
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
PV
</text>
{/* axes */}
<line x1={330} y1={250} x2={660} y2={250} stroke={ink} strokeWidth={1.5} />
<line x1={330} y1={250} x2={330} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={655} y={266} fontSize={11} fill={muted} textAnchor="end">V</text>
<text x={322} y={40} fontSize={11} fill={muted} textAnchor="end">P</text>
{/* isotherms */}
<path d="M 395 56 Q 460 130 660 218" fill="none" stroke={plum} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={200} fontSize={10} fill={plum}>T_h</text>
<path d="M 400 175 Q 520 208 660 230" fill="none" stroke={blue} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
<text x={620} y={242} fontSize={10} fill={blue}>T_c</text>
{/* cycle area (final beat) */}
<polygon points={CYCLE} fill={plum} opacity={s === 4 ? 0.14 : 0} style={{ transition: 'opacity 600ms ease' }} />
{s === 4 ? (
<text x={505} y={150} fontSize={15} fontWeight={800} fill={plum} textAnchor="middle">W</text>
) : null}
{/* cycle segments, revealed progressively */}
<polyline points={`${A.x},${A.y} ${B.x},${B.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 0 ? 1 : 0.15} />
<polyline points={`${B.x},${B.y} ${C.x},${C.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${C.x},${C.y} ${D.x},${D.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polyline points={`${D.x},${D.y} ${A.x},${A.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* current state marker */}
<circle cx={marker.x} cy={marker.y} r={6} fill={plum} stroke={card} strokeWidth={2} style={{ transition: EASE }} />
</g>
</svg>
)
}

View File

@@ -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<T extends string>({
value,
onChange,
options,
ariaLabel,
}: {
value: T
onChange: (v: T) => void
options: { id: T; label: string }[]
ariaLabel: string
}) {
return (
<div
className="inline-flex max-w-full flex-wrap rounded-lg border border-border/60 bg-background/80 p-0.5"
role="group"
aria-label={ariaLabel}
>
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
className={cn(
'rounded-md px-2 py-1 text-[11px] font-semibold transition-colors',
value === o.id
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={value === o.id}
>
{o.label}
</button>
))}
</div>
)
}
/** 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 (
<g>
<line
x1={x1}
y1={y1}
x2={bx}
y2={by}
stroke={color}
strokeWidth={t}
strokeLinecap="round"
opacity={0.9}
/>
<polygon
points={`${x2},${y2} ${bx + px * headHalf},${by + py * headHalf} ${bx - px * headHalf},${by - py * headHalf}`}
fill={color}
opacity={0.95}
/>
<text
x={lx}
y={ly}
fontSize={11}
fill={color}
fontWeight={600}
dominantBaseline="middle"
textAnchor={textAnchor}
>
{label} {formatQty(value)}
{unit ? ` ${unit}` : ''}
</text>
</g>
)
}
export function CarnotCycleView({
preset,
disclaimer,
lang,
}: {
preset?: Record<string, number>
title?: string
disclaimer?: string
lang: string
}) {
const fr = lang.startsWith('fr')
const dark = useDarkMode()
const [tempUnit, setTempUnit] = useState<TempUnit>('K')
const [mode, setMode] = useState<CarnotMode>('fridge')
const [qty, setQty] = useState<CarnotQuantity>('energy')
const [values, setValues] = useState<Record<string, number>>(() => {
const env: Record<string, number> = {}
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 (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Segmented
value={mode}
onChange={setMode}
ariaLabel={fr ? 'Mode machine' : 'Machine mode'}
options={MODES.map((m) => ({ id: m.id, label: fr ? m.fr : m.en }))}
/>
<Segmented
value={qty}
onChange={setQty}
ariaLabel={fr ? 'Énergie ou puissance' : 'Energy or power'}
options={QTY.map((q) => ({ id: q.id, label: fr ? q.fr : q.en }))}
/>
<Segmented
value={tempUnit}
onChange={setTempUnit}
ariaLabel={fr ? 'Unité de température' : 'Temperature unit'}
options={TEMP_UNITS.map((u) => ({ id: u.id, label: u.label }))}
/>
</div>
<div className="grid gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<div>
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="mx-auto w-full max-w-md"
role="img"
aria-label={fr ? sim.title.fr : sim.title.en}
>
<rect
x={cx - 105}
y={hotY - 18}
width={210}
height={34}
rx={10}
fill={`${cHot}14`}
stroke={cHot}
strokeWidth={1.5}
/>
<text
x={cx}
y={hotY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cHot}
dominantBaseline="middle"
>
{fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)}
</text>
<rect
x={cx - 105}
y={coldY - 18}
width={210}
height={34}
rx={10}
fill={`${cCold}14`}
stroke={cCold}
strokeWidth={1.5}
/>
<text
x={cx}
y={coldY}
textAnchor="middle"
fontSize={11.5}
fontWeight={600}
fill={cCold}
dominantBaseline="middle"
>
{fr ? 'Source froide' : 'Cold'} · T_c ={' '}
{formatTemp(values.t_cold, tempUnit)}
</text>
<circle
cx={cx}
cy={midY}
r={r}
fill={dark ? '#141820' : '#ffffff'}
stroke={text}
strokeWidth={1.5}
/>
<text
x={cx}
y={midY}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fill={text}
dominantBaseline="middle"
>
{fr ? 'Machine' : 'Engine'}
</text>
{phys.ok && !isEngine ? (
<>
{/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */}
<FlowArrow
x1={cx - qOffset}
y1={coldY - 22}
x2={cx - qOffset}
y2={midY + r + 2}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + qOffset}
y1={midY - r - 2}
x2={cx + qOffset}
y2={hotY + 22}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx + r + 78}
y1={midY}
x2={cx + r + 4}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
{phys.ok && isEngine ? (
<>
{/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */}
<FlowArrow
x1={cx + qOffset}
y1={hotY + 22}
x2={cx + qOffset}
y2={midY - r - 2}
value={phys.qh}
maxRef={maxRef}
color={cHot}
label={qLabel('h')}
unit={unitE}
/>
<FlowArrow
x1={cx - qOffset}
y1={midY + r + 2}
x2={cx - qOffset}
y2={coldY - 22}
value={phys.qc}
maxRef={maxRef}
color={cCold}
label={qLabel('c')}
unit={unitE}
labelSide="left"
/>
<FlowArrow
x1={cx + r + 4}
y1={midY}
x2={cx + r + 78}
y2={midY}
value={phys.w}
maxRef={maxRef}
color={cWork}
label={workSym}
unit={unitE}
labelSide="above"
/>
</>
) : null}
<text x={10} y={SVG_H - 10} fontSize={10} fill={dark ? '#8b8b93' : '#6b7280'}>
{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'}
</text>
</svg>
<p className="mt-2 text-center text-[11px] text-muted-foreground">{lawLine}</p>
{phys.ok && phys.entropyOk ? (
<p className="mt-1 text-center text-[10px] text-muted-foreground/80">
{fr
? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h'
: '2nd law (reversible): Q_c/T_c = Q_h/T_h'}
</p>
) : null}
</div>
<div className="space-y-3">
<div>
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{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 (
<SimSlider
key={`${p.id}-${tempUnit}`}
symbol={p.symbol}
label={fr ? p.label.fr : p.label.en}
unit={meta.unitLabel}
min={meta.min}
max={meta.max}
step={meta.step}
value={
tempUnit === 'K'
? Math.round(displayVal)
: Math.round(displayVal * 10) / 10
}
intent={p.intent}
onChange={(v) => {
const k = displayToKelvin(v, tempUnit)
const clamped = Math.min(p.max, Math.max(p.min, k))
setValues((s) => ({ ...s, [p.id]: clamped }))
}}
/>
)
})}
<SimSlider
key={`load-${mode}-${qty}`}
symbol={loadMeta.symbol}
label={loadMeta.label}
unit={unitE}
min={10}
max={500}
step={5}
value={Math.round(modeLoad * 10) / 10}
intent="flow"
onChange={(v) => {
const fridgeQc = fridgeLoadFromModeLoad(
values.t_cold,
values.t_hot,
v,
mode
)
setValues((s) => ({
...s,
q_cold: Math.min(500, Math.max(10, fridgeQc)),
}))
}}
/>
<p className="px-1 text-[10px] text-muted-foreground">{loadMeta.hint}</p>
</div>
</div>
<div>
<SimHeading>
{fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'}
</SimHeading>
<div className="grid grid-cols-2 gap-2">
{mode === 'fridge' || mode === 'heat_pump' ? (
<>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{R}`}
label={fr ? 'COP frigo' : 'Fridge COP'}
value={phys.copR}
intent="output"
digits={2}
/>
<SimOutputCard
symbol={String.raw`\mathrm{COP}_{HP}`}
label={fr ? 'COP PAC' : 'Heat-pump COP'}
value={phys.copHP}
intent="output"
digits={2}
/>
</>
) : (
<SimOutputCard
symbol={String.raw`\eta`}
label={fr ? 'Rendement moteur' : 'Engine efficiency'}
value={phys.eta * 100}
unit="%"
intent="highlight"
digits={1}
/>
)}
<SimOutputCard
symbol={workSym}
label={
isEngine
? fr
? `${workName} fourni`
: `${workName} out`
: fr
? `${workName} minimale`
: `Minimum ${workName.toLowerCase()}`
}
value={phys.w}
unit={unitE}
intent="compute"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_h` : String.raw`\dot{Q}_h`
}
label={
isEngine
? fr
? 'Chaleur absorbée'
: 'Heat absorbed'
: fr
? 'Chaleur côté chaud'
: 'Hot-side heat'
}
value={phys.qh}
unit={unitE}
intent="flow"
digits={1}
/>
<SimOutputCard
symbol={
qty === 'energy' ? String.raw`Q_c` : String.raw`\dot{Q}_c`
}
label={
isEngine
? fr
? 'Chaleur rejetée (froid)'
: 'Heat rejected (cold)'
: fr
? 'Chaleur côté froid'
: 'Cold-side heat'
}
value={phys.qc}
unit={unitE}
intent="cache"
digits={1}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{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.'}
</p>
</div>
</div>
</div>
{disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,207 @@
'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<Record<string, number>>(() => {
const env: Record<string, number> = {}
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<string, number> = {}
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
// Full env: params + chained computed (visual.expr may reference computed ids)
const fullEnv = { ...values, ...results }
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({ ...fullEnv, [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(fullEnv) }
}, [visual, sim.params, values, results])
const accent = intentColor('highlight', dark)
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
return (
<div>
{sim.intro ? (
<p className="mb-4 text-[15px] leading-relaxed text-muted-foreground">
{sim.intro}
</p>
) : null}
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
<div className="space-y-2">
{sim.params.map((p) => (
<SimSlider
key={p.id}
symbol={p.symbol}
label={p.label}
unit={p.unit}
min={p.min}
max={p.max}
step={p.step}
value={values[p.id]}
intent={p.intent}
onChange={(v) => setValues((s) => ({ ...s, [p.id]: v }))}
/>
))}
</div>
</div>
<div className="space-y-3">
<SimHeading>{fr ? 'Résultats' : 'Results'}</SimHeading>
{sim.visual.kind === 'gauges' ? (
<div className="space-y-2">
{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 (
<div key={c.id} className="rounded-xl border border-border/50 bg-background/70 px-3 py-2">
<div className="flex items-baseline justify-between text-xs">
<span className="text-muted-foreground">{c.label}</span>
<span className="font-semibold tabular-nums" style={{ color }}>
{Number.isFinite(v) ? v.toFixed(2) : '—'}
{c.unit ? ` ${c.unit}` : ''}
</span>
</div>
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full transition-[width] duration-150"
style={{
width: `${Math.min(100, (Math.abs(v || 0) / maxRef) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</div>
)
})}
</div>
) : null}
{sim.visual.kind === 'bars' ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sim.computed.map((c) => ({
name: c.label,
value: Number.isFinite(results[c.id]) ? results[c.id] : 0,
}))}
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="name" tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Bar dataKey="value" fill={accent} radius={[4, 4, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
) : null}
{sim.visual.kind === 'curve' && curve ? (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={curve.pts} margin={{ top: 8, right: 8, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis
dataKey="x"
type="number"
domain={[curve.xParam.min, curve.xParam.max]}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => String(Math.round(v))}
/>
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
<Tooltip />
<Line type="monotone" dataKey="y" stroke={accent} strokeWidth={2.5} dot={false} isAnimationActive={false} />
{Number.isFinite(curve.currentY) ? (
<ReferenceDot x={curve.currentX} y={curve.currentY} r={5} fill={accent} stroke="none" />
) : null}
</LineChart>
</ResponsiveContainer>
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
{sim.computed.map((c) => (
<SimOutputCard
key={c.id}
symbol={c.symbol}
label={c.label}
value={results[c.id]}
unit={c.unit}
intent={c.intent}
/>
))}
</div>
</div>
</div>
{sim.disclaimer ? (
<p className="mt-3 text-xs italic text-muted-foreground">{sim.disclaimer}</p>
) : null}
</div>
)
}

View File

@@ -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<string, number>
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<string, ComponentType<CatalogSimViewProps>> = {
'carnot-cycle': CarnotCycleView,
}
/** Registry: catalog simId → bespoke animated scene (step-driven). */
export const ANIM_VIEWS: Record<string, ComponentType<CatalogAnimViewProps>> = {
'carnot-cycle-anim': CarnotCycleAnimView,
'ts-diagram': TsDiagramView,
}

View File

@@ -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 (
<span
className={cn('inline-block max-w-full [&_.katex]:text-[1em]', className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
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 (
<label className="block rounded-xl border border-border/50 bg-background/70 px-3 py-2.5">
<span className="flex items-baseline justify-between gap-2">
<span className="flex items-baseline gap-2 text-sm font-medium">
<SimKaTeX tex={symbol} />
<span className="text-xs text-muted-foreground">{label}</span>
</span>
<span
className="rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums"
style={{ color: accent, backgroundColor: `${accent}18` }}
>
{value}
{unit ? ` ${unit}` : ''}
</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sim-slider mt-2 w-full"
style={{ accentColor: accent }}
aria-label={label}
/>
<span className="mt-0.5 flex justify-between text-[10px] tabular-nums text-muted-foreground/70">
<span>
{min}
{unit ? ` ${unit}` : ''}
</span>
<span>
{max}
{unit ? ` ${unit}` : ''}
</span>
</span>
</label>
)
}
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 (
<div className="rounded-xl border border-border/50 bg-background/70 px-3 py-2.5 text-center">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-1 flex items-baseline justify-center gap-1.5">
<SimKaTeX tex={symbol} className="text-sm" />
<span
className={cn('text-lg font-semibold tabular-nums', !finite && 'text-muted-foreground')}
style={finite ? { color: accent } : undefined}
>
{finite ? value.toFixed(digits) : '—'}
{finite && unit ? ` ${unit}` : ''}
</span>
</div>
</div>
)
}
/** Small section heading inside a simulator card. */
export function SimHeading({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<p
className={cn(
'mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground',
className
)}
>
{children}
</p>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Ts 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 (
<svg
viewBox="0 0 680 320"
className="w-full"
role="img"
aria-label={fr ? 'Diagramme Ts du cycle de Carnot' : 'Ts diagram of the Carnot cycle'}
style={{ background: paper, display: 'block' }}
>
{/* axes */}
<line x1={X0} y1={Y0} x2={650} y2={Y0} stroke={ink} strokeWidth={1.5} />
<line x1={X0} y1={Y0} x2={X0} y2={30} stroke={ink} strokeWidth={1.5} />
<text x={648} y={Y0 + 18} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">s</text>
<text x={X0 - 8} y={40} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">T</text>
{/* isotherm T_h */}
<line x1={X0} y1={YH} x2={650} y2={YH} stroke={plum} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YH + 4} fontSize={11} fill={plum} textAnchor="end" fontWeight={600}>T_h</text>
{/* isotherm T_c */}
<line x1={X0} y1={YC} x2={650} y2={YC} stroke={blue} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
<text x={X0 - 8} y={YC + 4} fontSize={11} fill={blue} textAnchor="end" fontWeight={600}>T_c</text>
{/* Q_h area (beat ≥ 0, shown from beat 0) */}
<rect
x={S1} y={YH} width={qhW} height={Y0 - YH}
fill={plum} opacity={s >= 0 ? 0.10 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 0 ? (
<text x={(S1 + S2) / 2} y={(YH + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={plum}>Q_h</text>
) : null}
{/* Q_c area (from beat 2) */}
<rect
x={S1} y={YC} width={qhW} height={Y0 - YC}
fill={blue} opacity={s >= 2 ? 0.16 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s >= 2 && s < 4 ? (
<text x={(S1 + S2) / 2} y={(YC + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={blue}>Q_c</text>
) : null}
{/* W = cycle area (beat 4) */}
<rect
x={S1} y={YH} width={qhW} height={YC - YH}
fill={plum} opacity={s === 4 ? 0.18 : 0}
style={{ transition: 'opacity 600ms ease' }}
/>
{s === 4 ? (
<text x={(S1 + S2) / 2} y={(YH + YC) / 2} textAnchor="middle" fontSize={17} fontWeight={800} fill={plum}>W</text>
) : null}
{/* cycle edges, drawn progressively */}
{/* top: b1 (isothermal expansion) */}
<line x1={S1} y1={YH} x2={S2} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 0 ? 1 : 0.15} />
<polygon points={`${S2 - 14},${YH - 5} ${S2 - 4},${YH} ${S2 - 14},${YH + 5}`} fill={ink} opacity={s >= 0 ? 1 : 0.15} />
{/* right: b2 (adiabatic expansion) */}
<line x1={S2} y1={YH} x2={S2} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S2 - 5},${YC - 14} ${S2},${YC - 4} ${S2 + 5},${YC - 14}`} fill={ink} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* bottom: b3 (isothermal compression) */}
<line x1={S2} y1={YC} x2={S1} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 + 14},${YC - 5} ${S1 + 4},${YC} ${S1 + 14},${YC + 5}`} fill={ink} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* left: b4 (adiabatic compression) */}
<line x1={S1} y1={YC} x2={S1} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
<polygon points={`${S1 - 5},${YH + 14} ${S1},${YH + 4} ${S1 + 5},${YH + 14}`} fill={ink} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
{/* entropy ticks */}
<text x={S1} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
<text x={S2} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s</text>
{/* state marker */}
<circle cx={m.x} cy={m.y} r={6} fill={plum} stroke={paper} strokeWidth={2} style={{ transition: EASE }} />
</svg>
)
}

View File

@@ -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 (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="font-medium">
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
</p>
<ul className="mt-1 text-muted-foreground list-disc list-inside">
{parsed.issues.slice(0, 5).map((iss, i) => (
<li key={i}>
{iss.path ? `${iss.path}: ` : ''}
{iss.message}
</li>
))}
</ul>
</div>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
</NodeViewWrapper>
)
}
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()
}

View File

@@ -40,6 +40,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
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',

View File

@@ -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 aujourdhui, **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 → `<PageView spec={…} />` (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.

View File

@@ -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<GenerateInteractiveDemoResponse> {
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,
}
}

View File

@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 <think> 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 46 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.<slug>",
"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<typeof extractSourceAssets>,
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<GenerateInteractiveDemoResult> {
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,
}
}

View File

@@ -0,0 +1,211 @@
import type {
PageSection,
PageSpecV1,
PageValidationIssue,
} from '@/lib/interactive-page'
export type PagePlanDemoKind = 'steps' | '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<string, unknown>,
timeoutMs = 55_000
): Promise<{ res: Response; data: Record<string, never> } | { 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<GeneratePlanResponse> {
let out: Awaited<ReturnType<typeof postJson>>
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<GenerateSectionResponse> {
let out: Awaited<ReturnType<typeof postJson>>
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<GenerateInteractivePageResponse> {
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,
}
}

View File

@@ -0,0 +1,309 @@
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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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)
}
export function buildPageFromNote(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> {
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<string, unknown>[] = [
{
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<string, unknown>,
block: Record<string, unknown>
): Record<string, unknown> {
const sections = Array.isArray(page.sections)
? ([...page.sections] as Record<string, unknown>[])
: []
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<string, unknown>[])]
: []
blocks.push(block)
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
}
/**
* Deterministic step-by-step block from extracted formulas (math notes).
* Replaces the old generic box-diagram fallback — boxes are banned.
*/
function buildDeterministicSteps(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> | null {
const fr = lang.startsWith('fr')
const formulas = assets.formulas.filter((f) => f.length <= 120).slice(0, 6)
if (formulas.length < 3) return null
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, a) => a.indexOf(s) === i)
return {
type: 'steps',
title: fr ? 'Dérivation pas à pas' : 'Step-by-step derivation',
steps: formulas.map((tex, i) => ({
tex,
...(i === 0
? { rule: fr ? 'Point de départ' : 'Starting point' }
: {}),
speak:
sentences[i]?.slice(0, 120) ||
(fr ? `Étape **${i + 1}** de la dérivation.` : `Step **${i + 1}** of the derivation.`),
})),
caption: fr
? 'Formules extraites de la note, déroulées pas à pas.'
: 'Formulas extracted from the note, walked through step by step.',
}
}
/**
* Instant reliable page: deterministic skeleton + deterministic steps (math)
* or Play/Step demo (other content). No LLM round-trip for the page itself.
*/
export async function generateInteractivePageFromContent(
input: GenerateInteractivePageInput
): Promise<GenerateInteractivePageResult> {
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 stepsBlock = buildDeterministicSteps(input.content, lang, assets)
if (stepsBlock) {
pageObj = injectDemo(pageObj, stepsBlock)
}
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<string, unknown>[]).map((sec) => ({
...sec,
blocks: Array.isArray(sec.blocks)
? (sec.blocks as Record<string, unknown>[]).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 }
}

View File

@@ -0,0 +1,559 @@
/**
* 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 = 3
// ── Shared helpers ───────────────────────────────────────────────────────────
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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<typeof extractSourceAssets>): 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 = ['steps', '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().nullish(),
})
const pagePlanSchema = z.object({
heroTitle: z.string().min(1),
heroSubtitle: z.string().nullish(),
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).nullish(),
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
sections: z.array(planSectionSchema).min(2).max(5),
})
export type PagePlanSection = z.infer<typeof planSectionSchema>
export type PagePlan = z.infer<typeof pagePlanSchema>
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 <think> 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)} } ], // 34 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 25
}
COUVERTURE (règle n°1):
- The page covers the CORE of the source: its 25 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):
- Dérivation, démonstration, résolution d'équation, calcul pas à pas (maths, physique) → "steps" — JAMAIS de svg-scene pour du contenu mathématique
- 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<GeneratePagePlanResult> {
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"):
- 12 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 <think> blocks.
SCHEMA:
{
"title": string,
"blocks": [ Block, ... ] // 26 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 }] } // 25, 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
- { "type": "steps", "title?": string, "steps": [{ "tex": string, "rule?": string, "speak?": string }] (212), "caption?": string } // step-by-step derivation: tex = KaTeX of the equation state, rule = transformation applied (short, e.g. "on sépare les variables")
IntentId: ${JSON.stringify(INTENT_IDS)}
BLOCK "steps" (Symbolab-style derivation) — MANDATORY for math/derivation content:
- Each step = the equation state AFTER applying the rule; steps must chain logically (each follows from the previous).
- rule = the transformation applied to reach THIS state (short verb phrase). speak = 1 sentence teacher narration.
- FORBIDDEN: using "demo" svg-scene (boxes with arrows) for equations, derivations, proofs, or calculus content — always "steps" instead.
SimRef — TWO forms:
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": 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?" }] (14), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (16), "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.<slug>", "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<typeof extractSourceAssets>,
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 === 'steps'
? `STEPS_REQUIRED: include ONE "steps" block — the step-by-step derivation of this section's key result. Real equations from the note, logically chained, a short "rule" per step.
STEPS_GOAL: ${section.demoGoal || 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/steps 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). 26 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
? { ...(candidate as Record<string, unknown>), id: sectionId }
: candidate,
// schema requires ≥2 sections — inert filler for the wrap check
{ id: 's99', title: '—', blocks: [{ type: 'prose', md: '—' }] },
],
}
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<typeof extractSourceAssets>
): 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<GeneratePageSectionResult> {
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 }
}

View File

@@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
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) ─────────────────────────────────── */

View File

@@ -214,6 +214,32 @@ export function normalizeSlideDeck(input: {
}
return { ...s, stats: valid.slice(0, 4) }
}
// Equation without real formulas → degrade to bullets (avoid shipping f(x)=?)
if (s.type === 'equation') {
const eqs = Array.isArray(s.equations) ? s.equations : []
const real = eqs.filter((eq: any) => String(eq?.latex || '').trim() && !/f\s*\(\s*x\s*\)\s*=\s*\?/i.test(String(eq.latex)))
if (real.length === 0) {
return {
type: 'bullets',
title: trimStr(s.title || 'Points clés', 90),
items: cleanList(
[...(s.explanation ? [String(s.explanation)] : []), ...eqs.map((eq: any) => eq?.label).filter(Boolean)],
4,
140,
),
}
}
return { ...s, equations: real.slice(0, 4) }
}
// Image without URL → degrade to bullets (avoid placeholder in final deck)
if (s.type === 'image' && !String(s.url || '').trim()) {
const caption = String(s.caption || '').trim()
return {
type: 'bullets',
title: trimStr(s.title || 'Illustration', 90),
items: caption ? [caption] : cleanList(s.items, 3, 140),
}
}
return s
})

View File

@@ -49,6 +49,7 @@ const SlideTypeEnum = z.enum([
'chart',
'table',
'quote',
'image',
'summary',
])
@@ -61,6 +62,8 @@ const OutlineSlideSchema = z.object({
keyPoints: z.array(z.string()).min(1).max(6),
/** For equation slides: latex strings to include */
formulas: z.array(z.string()).optional(),
/** For image slides: 0-based index into assets.images */
imageIndex: z.number().optional(),
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
})
@@ -96,6 +99,8 @@ const ExpandedSlideSchema = z.object({
author: z.string().optional(),
context: z.string().optional(),
notes: z.string().optional(),
url: z.string().optional(),
caption: z.string().optional(),
})
export interface GenerateSlideDeckParams {
@@ -138,6 +143,51 @@ const LANG_NAMES: Record<string, string> = {
hi: 'Hindi',
}
/** Detect content language from text via Unicode script ranges + Latin word frequency. */
function detectContentLanguage(text: string): string {
if (!text) return 'English'
// Non-Latin scripts (deterministic)
if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) {
if (/[\u06CC\u0698\u06AF\u06A9\u067E\u0686]/.test(text)) return 'Persian (Farsi)'
return 'Arabic'
}
if (/[\uAC00-\uD7AF]/.test(text)) return 'Korean'
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'Japanese'
if (/[\u4E00-\u9FFF]/.test(text)) return 'Chinese'
if (/[\u0400-\u04FF]/.test(text)) return 'Russian'
if (/[\u0900-\u097F]/.test(text)) return 'Hindi'
// Latin script — distinguish via accented chars + function word frequency
const lower = text.toLowerCase()
const wc = (re: RegExp) => (lower.match(re) || []).length
// French: distinctive accents (é è ê à ô etc) + function words
const frAccents = (lower.match(/[éèêëàâäïîôöùûüç]/g) || []).length
const frWords = wc(/\b(dans|avec|pour|cette|être|avoir|fait|plus|sans|sous|entre|après|très|bien|aussi|même|encore|toujours|jamais|pendant|depuis|chez|leurs|mes|tes|ses|notre|votre|celui|ceux|celle|aucun|autre|chaque)\b/g)
const frScore = frAccents + frWords
// Spanish
const esAccents = (lower.match(/[áíóúñ¿¡]/g) || []).length
const esWords = wc(/\b(pero|como|más|para|con|por|una|los|las|del|al|también|puede|antes|después|siempre|nunca|también|aquí|allí|muy|bien|también|nosotros|vosotros|ellos|suyo|nuestro)\b/g)
const esScore = esAccents + esWords
// German
const deUmlauts = (lower.match(/[äöüß]/g) || []).length
const deWords = wc(/\b(und|ist|ein|eine|von|mit|für|auf|nicht|auch|sich|bei|zum|zur|den|des|dem|wir|sie|hat|war|wird|sind|kann|muss|noch|schon|immer|wieder)\b/g)
const deScore = deUmlauts * 2 + deWords
// Pick highest scorer (must beat English baseline)
const scores: [string, number][] = [
['French', frScore],
['Spanish', esScore],
['German', deScore],
]
scores.sort((a, b) => b[1] - a[1])
// Require a minimum signal (accents/words) to avoid false positives on short English text
if (scores[0][1] >= 6) return scores[0][0]
return 'English'
}
// ── LLM helpers ──────────────────────────────────────────────────────────────
export function extractJsonPayload(text: string): unknown | null {
@@ -223,6 +273,18 @@ function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAsset
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
: ''
const imageRule = assets.hasImages
? lang === 'fr'
? `MATÉRIEL VISUEL: ${assets.images.length} image(s) disponible(s). Inclus 1 à ${Math.min(3, assets.images.length)} slide(s) de type "image" pour illustrer le propos. Pour chaque slide image, donne l'index (imageIndex, 0-based) de l'image à utiliser.`
: `VISUAL MATERIAL: ${assets.images.length} image(s) available. Include 1 to ${Math.min(3, assets.images.length)} slide(s) of type "image" to illustrate key points. For each image slide, provide imageIndex (0-based) of the image to use.`
: ''
const chartRule = assets.hasNumbers
? lang === 'fr'
? `DONNÉES NUMÉRIQUES: ${assets.numbers.length} valeurs détectées. Tu DOIS inclure au moins 1 slide de type "chart". chartType: "bar" (comparer des quantités), "line" (tendance temporelle), "donut" (proportions d'un tout).`
: `NUMERIC DATA: ${assets.numbers.length} values detected. You MUST include at least 1 slide of type "chart". chartType: "bar" (compare quantities), "line" (time trend), "donut" (proportions of a whole).`
: ''
if (lang === 'fr') {
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
@@ -233,10 +295,13 @@ Règles (DeckForge + think-cell):
- keyPoints: 25 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
- ATTENTION: le type "equation" est RÉSERVÉ aux formules mathématiques réelles (avec LaTeX). N'utilise JAMAIS "equation" pour un concept métaphorique (équilibre, rapport de forces, etc.) → utilise "bullets" ou "comparison" à la place.
- Slide 1 = title, dernière = summary
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
${mathRule}
${imageRule}
${chartRule}
Réponds en JSON OutlineSchema.`
}
return `You are DeckForge/PPTAgent narrative architect for Memento.
@@ -248,10 +313,13 @@ Rules:
- keyPoints: 25 CONCRETE facts from the note (numbers, names, formulas) — no filler
- One actionable idea per slide (title = insight, not section label)
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
- WARNING: type "equation" is RESERVED for real mathematical formulas (with LaTeX). NEVER use "equation" for metaphorical concepts (balance of power, concessions, etc.) → use "bullets" or "comparison" instead.
- First=title, last=summary
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
${mathRule}
${imageRule}
${chartRule}
JSON OutlineSchema only.`
}
@@ -268,7 +336,10 @@ OBLIGATOIRE selon type:
- comparison: title + left/right avec points[2-4] chacun
- timeline: title + events[2-5]
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
- chart: title + data[2+] SEULEMENT si chiffres réels
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entrées)
chartType: "bar" (comparer quantités) | "line" (tendance temporelle) | "donut" (proportions) | "horizontal-bar" (libellés longs) | "radar" (comparer dimensions)
IMPORTANT: value DOIT être un number (pas string). Utilise les chiffres fournis dans le prompt.
- image: title + url (fourni dans le prompt) + caption (1 phrase décrivant ce que l'image illustre)
- summary: title + items[3-5] actionnables
- quote: quote non vide
@@ -284,7 +355,11 @@ REQUIRED by type:
- cards: title + cards[2-4]
- comparison: left/right points[2-4] each
- timeline: events[2-5]
- stats/chart: only with real numbers provided
- stats: only with real numbers provided
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entries)
chartType: "bar" (compare quantities) | "line" (time trend) | "donut" (proportions) | "horizontal-bar" (long labels) | "radar" (compare dimensions)
IMPORTANT: value MUST be a number (not string). Use the numbers provided in the prompt.
- image: title + url (provided in prompt) + caption (1 sentence describing what the image illustrates)
- summary: items[3-5]
- quote: non-empty quote
@@ -356,6 +431,72 @@ function enforceMathOutline(
}
}
/** Force image slides into outline when server extracted images (like enforceMathOutline for formulas). */
function enforceImageOutline(
outline: z.infer<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
if (!assets.hasImages || assets.images.length === 0) return outline
const hasImg = outline.slides.some((s) => s.type === 'image')
if (hasImg) return outline
// Inject 1 image slide after title (or after first equation slide)
const title = outline.slides.find((s) => s.type === 'title') || outline.slides[0]!
const insertAfter = outline.slides.findIndex((s) => s.type === 'title')
const insertIdx = insertAfter >= 0 ? insertAfter + 1 : 1
const imgSlide: z.infer<typeof OutlineSlideSchema> = {
position: insertIdx + 1,
type: 'image',
headline: assets.keySentences[0]?.slice(0, 60) || 'Illustration',
keyPoints: [assets.images[0]!],
imageIndex: 0,
narrativeRole: 'evidence',
}
const before = outline.slides.slice(0, insertIdx)
const after = outline.slides.slice(insertIdx, maxSlides - 1)
return {
...outline,
slides: [...before, imgSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
}
}
function pickChartType(count: number): 'bar' | 'horizontal-bar' | 'donut' {
if (count <= 4) return 'donut'
if (count <= 6) return 'bar'
return 'horizontal-bar'
}
function enforceChartOutline(
outline: z.infer<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
if (!assets.hasNumbers || assets.numbers.length < 2) return outline
const hasChart = outline.slides.some((s) => s.type === 'chart')
if (hasChart) return outline
const titleIdx = outline.slides.findIndex((s) => s.type === 'title')
const insertIdx = titleIdx >= 0 ? titleIdx + 1 : 1
const chartSlide: z.infer<typeof OutlineSlideSchema> = {
position: insertIdx + 1,
type: 'chart',
headline: assets.keySentences[0]?.slice(0, 60) || 'Données clés',
keyPoints: assets.numbers.slice(0, 4).map((n) => `${n.label}: ${n.raw}`),
narrativeRole: 'data',
}
const before = outline.slides.slice(0, insertIdx)
const after = outline.slides.slice(insertIdx, maxSlides - 1)
return {
...outline,
slides: [...before, chartSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
}
}
function fallbackExpandFromOutline(
o: z.infer<typeof OutlineSlideSchema>,
assets: SourceAssets,
@@ -371,10 +512,17 @@ function fallbackExpandFromOutline(
}
if (type === 'equation') {
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
if (!forms.length) {
return {
type: 'bullets',
title,
items: (o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)).slice(0, 5),
}
}
return {
type: 'equation',
title,
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
equations: forms.map((latex, i) => ({
latex,
label: o.keyPoints[i] || `Formule ${i + 1}`,
})),
@@ -420,6 +568,16 @@ function fallbackExpandFromOutline(
})),
}
}
if (type === 'image') {
const imgIdx = o.imageIndex ?? 0
const url = assets.images[imgIdx] || assets.images[0] || ''
return {
type: 'image',
title,
url,
caption: o.keyPoints[0] || assets.keySentences[0] || '',
}
}
// default bullets — use keyPoints + sentences to fill density
const items = [
...o.keyPoints,
@@ -490,15 +648,15 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
}
}
const noteLang = notes[0]?.language
const contentLang =
params.contentLanguage ||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
const noteLang = notes.find((n) => n.language)?.language
const combined = notes.map((n) => n.content || '').join('\n\n')
const assets = extractSourceAssets(combined)
const contentLang =
params.contentLanguage ||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : detectContentLanguage(combined))
const perNoteLimit = notes.length > 5 ? 1200 : 8000
const notesText = notes
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', perNoteLimit)}`)
.join('\n\n')
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
const limit = slideLimitFromWordCount(wordCount)
@@ -526,10 +684,7 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
: '',
assets.numbers.length
? `CHIFFRES:\n${assets.numbers
.slice(0, 10)
.map((n) => `- ${n.label}: ${n.raw}`)
.join('\n')}`
? `DONNÉES NUMÉRIQUES (JSON pour slides chart/stats — utilise ces valeurs exactes):\n${JSON.stringify(assets.numbers.slice(0, 10).map((n) => ({ label: n.label.slice(0, 24), value: n.value })))}`
: '',
assets.keySentences.length
? `PHRASES CLÉS:\n${assets.keySentences
@@ -537,6 +692,9 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
.map((s) => `- ${s}`)
.join('\n')}`
: '',
assets.images.length
? `IMAGES DISPONIBLES (utilise imageIndex 0-based pour les slides image):\n${assets.images.map((url, i) => `${i}. ${url}`).join('\n')}`
: '',
]
.filter(Boolean)
.join('\n\n')
@@ -556,68 +714,49 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
outline = enforceMathOutline(outline, assets, targetMax)
}
// ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ──
const expanded: z.infer<typeof ExpandedSlideSchema>[] = []
for (const slideOutline of outline.slides) {
try {
const formulaHint =
slideOutline.type === 'equation'
? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}`
: assets.formulas.length && slideOutline.type === 'bullets'
? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})`
: ''
// Images → force image slides when images exist but LLM didn't create any
if (assets.hasImages) {
outline = enforceImageOutline(outline, assets, targetMax)
}
const one = await llmObject({
model,
schema: ExpandedSlideSchema,
system: expandOneSystem(lang),
prompt:
lang === 'fr'
? `Langue: ${contentLang}.
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
type: ${slideOutline.type}
headline: ${slideOutline.headline}
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
role: ${slideOutline.narrativeRole || 'evidence'}
${formulaHint}
// Charts → force chart slides when numbers exist but LLM didn't create any
if (assets.hasNumbers) {
outline = enforceChartOutline(outline, assets, targetMax)
}
Contexte note (extrait):\n${notesText.slice(0, 6000)}
// ── Stage 2: Expand slides ──
// Single-pass: outline LLM call + deterministic fill (1 LLM call total)
let mode = 'outline+deterministic-fill'
Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).`
: `Language: ${contentLang}.
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
type: ${slideOutline.type}
headline: ${slideOutline.headline}
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
${formulaHint}
Note excerpt:\n${notesText.slice(0, 6000)}
Expand into full ExpandedSlide JSON (non-empty body).`,
})
// Force type/title from outline if model drifts
const expanded: z.infer<typeof ExpandedSlideSchema>[] = outline.slides.map((slideOutline) => {
const one = fallbackExpandFromOutline(slideOutline, assets)
one.type = slideOutline.type
if (!one.title) one.title = slideOutline.headline
// Inject formulas if equation empty
if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) {
const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas
one.equations = forms.slice(0, 4).map((latex, i) => ({
latex,
label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`,
}))
}
expanded.push(one)
} catch (e) {
console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e)
expanded.push(fallbackExpandFromOutline(slideOutline, assets))
}
}
return one
})
// ── Stage 3: Normalize + STEM inject + substance gate ──
// Rescue chart slides with missing/partial data before normalization drops them
const rescued = expanded.map((slide) => {
if (slide.type !== 'chart') return slide
const validData = (slide.data || []).filter(
(d: any) => d && typeof d.value === 'number' && Number.isFinite(d.value) && String(d.label || '').trim(),
)
if (validData.length < 2 && assets.numbers.length >= 2) {
return {
...slide,
data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20), value: n.value })),
chartType: slide.chartType || pickChartType(assets.numbers.length),
}
}
return slide
})
let normalized = normalizeSlideDeck({
title: outline.title,
theme,
slides: expanded as unknown[],
slides: rescued as unknown[],
})
// Always inject formulas if missing (deterministic — never ship STEM without equations)
@@ -630,7 +769,6 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
}
let gate = assertDeckHasSubstance(normalized, stemOpts)
let mode = 'outline+per-slide-expand'
if (!gate.ok) {
// Deterministic fill for empty body slides using outline + assets
@@ -669,6 +807,30 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
if (!normalized.theme) normalized.theme = theme
// GUARANTEE: if numbers were extracted but no chart survived the pipeline, force-inject one
if (assets.hasNumbers && assets.numbers.length >= 2) {
const hasChart = normalized.slides.some((s) => s.type === 'chart')
if (!hasChart) {
const chartSlide: Record<string, unknown> = {
type: 'chart',
title: lang === 'fr' ? 'Données clés' : 'Key data',
chartType: pickChartType(assets.numbers.length),
data: assets.numbers.slice(0, 6).map((n) => ({
label: (n.label.slice(0, 20) || n.raw),
value: n.value,
})),
}
const summaryIdx = normalized.slides.findIndex((s) => s.type === 'summary')
if (summaryIdx >= 0) {
normalized.slides.splice(summaryIdx, 0, chartSlide)
} else if (normalized.slides.length < 8) {
normalized.slides.push(chartSlide)
} else {
normalized.slides[normalized.slides.length - 2] = chartSlide
}
}
}
const canvas = await persist(params.userId, normalized, params.actionId, mode)
return {
success: true,

View File

@@ -4,24 +4,55 @@
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
*/
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
export interface SourceAssets {
formulas: string[]
numbers: Array<{ label: string; value: number; raw: string }>
keySentences: string[]
hasMath: boolean
hasNumbers: boolean
hasImages: boolean
wordCount: number
images: string[]
}
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
export function extractFormulas(raw: string): string[] {
if (!raw) return []
const found: string[] = []
// 3+ consecutive plain words at brace depth 0 = prose tail captured after a
// formula (KaTeX eats spaces in math mode → must never reach $…$).
const PROSE_TAIL_RE =
/(?:[.!?]?\s+)[A-ZÀ-ÖØ-Þ]?[a-zA-ZÀ-ÿ']{2,}\s+[a-zA-ZÀ-ÿ']{2,}\s+[a-zA-ZÀ-ÿ']{2,}[\s\S]*$/g
const braceDepth = (s: string): number => {
let d = 0
for (const ch of s) {
if (ch === '{') d++
else if (ch === '}') d = Math.max(0, d - 1)
}
return d
}
const push = (s: string) => {
const t = s.replace(/\s+/g, ' ').trim()
let t = s.replace(/\s+/g, ' ').trim()
t = t.replace(/^\$+|\$+$/g, '').trim()
PROSE_TAIL_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = PROSE_TAIL_RE.exec(t)) !== null) {
if (braceDepth(t.slice(0, m.index)) === 0) {
t = t.slice(0, m.index).trim()
break
}
}
t = t.replace(/^\$+|\$+$/g, '').trim()
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
}
// TipTap / published HTML: data-latex="..."
for (const m of raw.matchAll(/data-latex=["']([^"']+)["']/gi)) {
push(decodeHtmlEntities(m[1] || ''))
}
// $$ ... $$
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
// \[ ... \]
@@ -47,20 +78,34 @@ export function extractFormulas(raw: string): string[] {
return found.slice(0, 24)
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
if (!raw) return []
// Normalize Persian (۰-۹) and Arabic (٠-٩) numerals to Western (0-9)
const text = raw
.replace(/[\u06F0-\u06F9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 0x30))
.replace(/[\u0660-\u0669]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 0x30))
const out: Array<{ label: string; value: number; raw: string }> = []
const re =
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null && out.length < 20) {
while ((m = re.exec(text)) !== null && out.length < 20) {
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
const value = parseFloat(numStr)
if (!Number.isFinite(value)) continue
const unit = m[2] || ''
// crude label: 40 chars before
const start = Math.max(0, m.index - 40)
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
const ctx = text.slice(start, m.index).replace(/\s+/g, ' ').trim()
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
}
@@ -88,16 +133,20 @@ export function extractKeySentences(raw: string, max = 16): string[] {
}
export function extractSourceAssets(raw: string): SourceAssets {
const formulas = extractFormulas(raw)
const numbers = extractNumbers(raw)
const plain = raw.replace(/<[^>]+>/g, ' ')
const formulas = extractFormulas(plain)
const numbers = extractNumbers(plain)
const keySentences = extractKeySentences(raw)
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
const images = extractPublishImageUrls(raw).slice(0, 8)
const wordCount = plain.split(/\s+/).filter(Boolean).length
return {
formulas,
numbers,
keySentences,
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(plain),
hasNumbers: numbers.length >= 2,
hasImages: images.length > 0,
wordCount,
images,
}
}

View File

@@ -277,13 +277,13 @@ function renderBarChart(data: { label: string; value: number }[], r: Recipe): st
const max = Math.max(...data.map(d => d.value), 1)
const bars = data.map(d => {
const pct = Math.round((d.value / max) * 100)
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
return `<div style="display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;flex:1;min-width:0;height:100%;">
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:80%;border-radius:6px 6px 0 0;"></div>
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
</div>`
}).join('')
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
return `<div style="display:flex;align-items:flex-end;gap:12px;height:220px;">${bars}</div>`
}
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
@@ -325,7 +325,7 @@ function renderLineChart(data: { label: string; value: number }[], r: Recipe): s
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
<defs><linearGradient id="lg-area" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
${gridLines}
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
@@ -486,7 +486,9 @@ function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i)
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}
var katexRetries=0;function ensureKatex(){if(typeof katex!=='undefined'){renderKatex();}else if(katexRetries<8){katexRetries++;setTimeout(ensureKatex,500);}}
setTimeout(ensureKatex,400);
// Particles
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
</script>

View File

@@ -55,17 +55,25 @@ export function buildAuthProviders() {
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) {
if (!passwordsMatch) {
return null;
}
// Password accounts must confirm email (Google OAuth sets emailVerified).
if (!user.emailVerified) {
throw new Error('EMAIL_NOT_VERIFIED');
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
} catch (err) {
if (err instanceof Error && err.message === 'EMAIL_NOT_VERIFIED') {
throw err;
}
return null;
} catch {
return null;
}
},

View File

@@ -0,0 +1,133 @@
import prisma from '@/lib/prisma'
import { sendEmail } from '@/lib/mail'
import { getSystemConfig } from '@/lib/config'
import { getEmailTemplate } from '@/lib/email-template'
const VERIFY_PREFIX = 'email-verify:'
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000 // 24h
export function generateVerificationToken(): string {
const array = new Uint8Array(32)
globalThis.crypto.getRandomValues(array)
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function identifierForEmail(email: string): string {
return `${VERIFY_PREFIX}${email.toLowerCase()}`
}
export async function createEmailVerificationToken(email: string): Promise<string> {
const normalized = email.toLowerCase()
const identifier = identifierForEmail(normalized)
const token = generateVerificationToken()
const expires = new Date(Date.now() + TOKEN_TTL_MS)
// Replace any pending tokens for this email
await prisma.verificationToken.deleteMany({ where: { identifier } })
await prisma.verificationToken.create({
data: { identifier, token, expires },
})
return token
}
export async function sendVerificationEmail(opts: {
email: string
name?: string | null
locale?: string
}): Promise<{ success: boolean; error?: string }> {
const token = await createEmailVerificationToken(opts.email)
const baseUrl = (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
const verifyLink = `${baseUrl}/verify-email?token=${token}`
const isFr = (opts.locale ?? '').toLowerCase().startsWith('fr')
const greet = opts.name?.trim()
? opts.name.trim()
: isFr
? 'Bonjour'
: 'Hi'
const title = isFr ? 'Confirmez votre adresse e-mail' : 'Confirm your email address'
const body = isFr
? `<p>${greet},</p><p>Merci de vous être inscrit sur Memento. Cliquez sur le bouton ci-dessous pour activer votre compte. Ce lien est valable 24 heures.</p>`
: `<p>${greet},</p><p>Thanks for signing up for Memento. Click the button below to activate your account. This link is valid for 24 hours.</p>`
const cta = isFr ? 'Confirmer mon e-mail' : 'Confirm my email'
const subject = isFr
? 'Confirmez votre compte Memento'
: 'Confirm your Memento account'
const html = getEmailTemplate(title, body, verifyLink, cta)
const sysConfig = await getSystemConfig()
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
return sendEmail({ to: opts.email.toLowerCase(), subject, html }, emailProvider)
}
export async function verifyEmailToken(
token: string,
): Promise<{ success: true } | { success: false; error: 'invalid' | 'expired' }> {
if (!token) return { success: false, error: 'invalid' }
const record = await prisma.verificationToken.findFirst({
where: { token },
})
if (!record || !record.identifier.startsWith(VERIFY_PREFIX)) {
return { success: false, error: 'invalid' }
}
if (record.expires < new Date()) {
await prisma.verificationToken.deleteMany({
where: { identifier: record.identifier },
})
return { success: false, error: 'expired' }
}
const email = record.identifier.slice(VERIFY_PREFIX.length)
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
return { success: false, error: 'invalid' }
}
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { emailVerified: new Date() },
}),
prisma.verificationToken.deleteMany({
where: { identifier: record.identifier },
}),
])
return { success: true }
}
/**
* Resend verification for an unverified password account.
* Always returns success to avoid email enumeration when the address is unknown.
*/
export async function resendVerificationEmail(
email: string,
locale?: string,
): Promise<{ success: boolean; error?: string }> {
const normalized = email.toLowerCase().trim()
if (!normalized) return { error: 'missing_email', success: false }
const user = await prisma.user.findUnique({ where: { email: normalized } })
if (!user || !user.password) {
return { success: true }
}
if (user.emailVerified) {
return { success: true }
}
const result = await sendVerificationEmail({
email: user.email,
name: user.name,
locale,
})
if (!result.success) {
return { success: false, error: 'send_failed' }
}
return { success: true }
}

View File

@@ -0,0 +1,2 @@
/** Free trial length on first Pro / Business checkout. Safe for client imports. */
export const SUBSCRIPTION_TRIAL_DAYS = 7

View File

@@ -0,0 +1,55 @@
import { sendEmail } from '@/lib/mail'
function formatTrialEnd(date: Date, locale: string): string {
try {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date)
} catch {
return date.toISOString().slice(0, 10)
}
}
/**
* Best-effort reminder ~3 days before Stripe ends a trial.
* Failures are logged by the caller; never throw to the webhook.
*/
export async function sendTrialEndingReminder(opts: {
to: string
name?: string | null
trialEndsAt: Date
billingUrl: string
locale?: string
}): Promise<{ success: boolean; error?: string }> {
const locale = opts.locale?.startsWith('fr') ? 'fr' : 'en'
const endLabel = formatTrialEnd(opts.trialEndsAt, locale === 'fr' ? 'fr-FR' : 'en-US')
const greet = opts.name?.trim() ? opts.name.trim() : locale === 'fr' ? 'Bonjour' : 'Hi'
const subject =
locale === 'fr'
? 'Votre essai Memento se termine bientôt'
: 'Your Memento trial is ending soon'
const html =
locale === 'fr'
? `
<p>${greet},</p>
<p>Votre période d'essai Memento se termine le <strong>${endLabel}</strong>.</p>
<p>Après cette date, votre abonnement démarrera automatiquement avec le moyen de paiement enregistré.</p>
<p>Pour gérer votre abonnement ou votre carte&nbsp;:</p>
<p><a href="${opts.billingUrl}">Ouvrir la facturation</a></p>
<p>— L'équipe Memento</p>
`
: `
<p>${greet},</p>
<p>Your Memento trial ends on <strong>${endLabel}</strong>.</p>
<p>After that date, your subscription will start automatically using the payment method on file.</p>
<p>To manage your subscription or card:</p>
<p><a href="${opts.billingUrl}">Open billing settings</a></p>
<p>— The Memento team</p>
`
return sendEmail({ to: opts.to, subject, html })
}

View File

@@ -0,0 +1,34 @@
import { prisma } from '@/lib/prisma'
export { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
/**
* Offer a trial only to users who never held a real Stripe subscription.
* Users with cus_mock / price_mock leftovers from local tests are still eligible
* if they never had a non-mock stripeSubscriptionId.
*/
export async function shouldOfferSubscriptionTrial(userId: string): Promise<boolean> {
const sub = await prisma.subscription.findUnique({
where: { userId },
select: {
stripeSubscriptionId: true,
tier: true,
status: true,
trialEndsAt: true,
},
})
if (!sub) return true
const stripeSubId = sub.stripeSubscriptionId
if (stripeSubId && !stripeSubId.startsWith('sub_mock')) {
return false
}
// Already on a paid / trial tier locally without a Stripe id (admin override)
if (sub.tier !== 'BASIC' && (sub.status === 'ACTIVE' || sub.status === 'TRIALING')) {
return false
}
return true
}

View File

@@ -56,7 +56,9 @@ export const CREDIT_COSTS: Record<string, number> = {
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 {

View File

@@ -1,4 +1,4 @@
export const DASHBOARD_LAYOUT_VERSION = 6 as const
export const DASHBOARD_LAYOUT_VERSION = 7 as const
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
@@ -10,8 +10,6 @@ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'mind-map',
'sentiment',
'inbox',
'revision',
'stats',
'reminders',
'flashcards-progress',
'agents',
@@ -115,13 +113,13 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// Colonne latérale (droite) — cartes compactes + widgets IA
{ id: 'sentiment', visible: true, order: 6, zone: 'side' },
{ id: 'inbox', visible: true, order: 7, zone: 'side' },
{ id: 'revision', visible: true, order: 8, zone: 'side' },
{ id: 'stats', visible: true, order: 9, zone: 'side' },
{ id: 'reminders', visible: true, order: 10, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 11, zone: 'side' },
{ id: 'agents', visible: true, order: 12, zone: 'side' },
{ id: 'pinned', visible: true, order: 13, zone: 'side' },
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
{ id: 'reminders', visible: true, order: 8, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 9, zone: 'side' },
{ id: 'agents', visible: true, order: 10, zone: 'side' },
{ id: 'pinned', visible: true, order: 11, zone: 'side' },
// Catalogue — masqués par défaut (chiffres déjà dans le bandeau du haut)
{ id: 'revision', visible: false, order: 12, zone: 'side' },
{ id: 'stats', visible: false, order: 13, zone: 'side' },
{ id: 'daily-review', visible: false, order: 14, zone: 'side' },
{ id: 'agent-activity', visible: false, order: 15, zone: 'side' },
{ id: 'gmail', visible: false, order: 16, zone: 'side' },

View File

@@ -14,9 +14,9 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
</style>
</head>
<body>
<div className="container">
<div className="header">
<a href="${process.env.NEXTAUTH_URL}" className="logo">
<div class="container">
<div class="header">
<a href="${process.env.NEXTAUTH_URL || 'https://memento-note.com'}" class="logo">
📒 Memento
</a>
</div>
@@ -24,8 +24,8 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
<div>
${content}
</div>
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" className="button">${actionText || 'Click here'}</a></div>` : ''}
<div className="footer">
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" class="button">${actionText || 'Click here'}</a></div>` : ''}
<div class="footer">
<p>This email was sent from your Memento instance.</p>
</div>
</div>

View File

@@ -50,11 +50,16 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
const isFirstRender = useRef(true)
// Load saved preference from localStorage AFTER hydration
// Load saved preference from cookie only (explicit picker). localStorage
// without cookie used to override note-based detection with a stale 'en'.
useEffect(() => {
const saved = localStorage.getItem('user-language') as SupportedLanguage
if (saved && SUPPORTED_LANGS.includes(saved) && saved !== initialLanguage) {
setLanguageState(saved)
const cookie = document.cookie
.split(';')
.map(s => s.trim())
.find(s => s.startsWith('user-language='))
?.split('=')[1] as SupportedLanguage | undefined
if (cookie && SUPPORTED_LANGS.includes(cookie) && cookie !== initialLanguage) {
setLanguageState(cookie)
}
}, [initialLanguage])

View File

@@ -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<string>(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 ≈ 12 sentences, ~25 words max — tempo is decided at writing time.
*/
export const SPEAK_WRITING_RULE =
'speak ≈ 12 phrases, ~25 mots max — le tempo de la démo se décide à lécriture, pas au rendu.'

View File

@@ -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"
}
]
}
]
}
]
}

View File

@@ -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'

View File

@@ -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<IntentId | 'neutral', string> = {
neutral: '#5c5c5c',
highlight: '#5a7a9a', // prune / slate-blue
flow: '#4a7d68',
cache: '#7a6a8a',
compute: '#8a6b4a',
output: '#4a7a8a',
warning: '#9a6548',
}
export const INTENT_PALETTE_DARK: Record<IntentId | 'neutral', string> = {
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: '12',
note: 'Demo tempo is decided at writing time, not at render.',
} as const

View File

@@ -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<string>(INTENT_IDS)
const PATTERN_SET = new Set<string>(PATTERN_IDS)
const SCOPE_SET = new Set<string>(SCOPE_IDS)
const ANN_KIND_SET = new Set<string>(ANNOTATION_KINDS)
const CHART_TYPE_SET = new Set<string>(CHART_TYPES)
const TRANSITION_SET = new Set<string>(TRANSITIONS)
const PANEL_TYPE_ALIASES: Record<string, string> = {
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<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: 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<string, unknown> = {}
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<string, unknown> | 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<string, unknown> {
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<string, unknown>[]
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<string, unknown> | 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<string, unknown>): {
ids: Set<string>
wildcardAllowed: boolean
firstId?: string
} {
const ids = new Set<string>()
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<string, unknown>,
elementIds: Set<string>,
wildcardAllowed: boolean
): Record<string, unknown> {
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<string, unknown>
): Record<string, unknown> | 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<string, unknown>[]
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<string, unknown>
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<string, unknown>[]
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
}

View File

@@ -0,0 +1,227 @@
import { collectSceneElementIds } from './validate'
import type {
Annotation,
DemoAct,
DemoScene,
DemoStep,
InteractiveDemoV1,
ScopeId,
} from './types'
const SCOPE_RANK: Record<ScopeId, number> = {
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<string, ScopeId>
/** 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<string, ScopeId>,
annotations: ResolvedAnnotation[],
scopes: ScopeId[]
): { revealed: Map<string, ScopeId>; annotations: ResolvedAnnotation[] } {
const drop = new Set(scopes)
const nextRevealed = new Map<string, ScopeId>()
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<string, ScopeId>,
id: string,
scope: ScopeId
): void {
const prev = revealed.get(id)
revealed.set(id, prev ? longestScope(prev, scope) : scope)
}
function applyStep(
step: DemoStep,
elementIds: Set<string>,
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
badgeCounter: { n: number }
): {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
spotlight: string[]
overview: boolean
} {
// Drop previous step's transient
;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient']))
const spotlightSet = new Set<string>()
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<string, ScopeId>
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<string, ScopeId>()
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
}

View File

@@ -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().min(0).max(5).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().max(50),
cols: z.number().int().positive().max(50),
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<typeof interactiveDemoV1Schema>
/** Re-export allowlists for callers that need them at runtime. */
export { PANEL_TYPES, PATTERN_IDS }

View File

@@ -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[] }

Some files were not shown because too many files have changed in this diff Show More