PageSourceSearch

https://creaconnect.world/src/app-espaces.js

js creaconnect.world collected 2026-09-24 10:08:31 UTC 329,613 bytes, 4,539 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════════════════
2 * LES SIX ESPACES
3 * ═══════════════════════════════════════════════════════════════════════════
4 *
5 * TROISIÈME MORCEAU DÉTACHÉ DE `src/app.js` — Découvrir, Finance, CFO,
6 * Éducation, Santé, Brevets. 730 lignes qui forment une aire du produit, et
7 * qui étaient noyées entre les studios et le chat.
8 *
9 * ── CE QUE CE REGROUPEMENT REND VISIBLE ─────────────────────────────────────
10 *
11 * Les six écrans partagent quatre choses, et c'est en les voyant côte à côte
12 * qu'on repère celui qui manque à l'appel :
13 *
14 *     `_spaceChatArea(id, …)`      le fil de discussion et sa rangée d'actions
15 *     `SPACE_SUGGESTIONS[id]`      les trois questions d'amorce
16 *     `_etatVivantDeLEspace(id)`  ce qui s'ajoute à la gorge : un état, jamais une identité
17 *     `renderUniversalInputBar`    la barre où l'on écrit
18 *
19 * Découvrir avait les trois premières et pas la quatrième. Résultat :
20 * `bindSpaceChat('espaceDecouvrir', …)` sortait à sa PREMIÈRE ligne, faute de
21 * bouton d'envoi (`if (!sendBtn || !inp) return`). Quatre déclarations, zéro
22 * branchement, zéro erreur — impossible à voir tant que les six écrans étaient
23 * dispersés. `tests/boucle-partout-smoke.mjs` vérifie maintenant les quatre,
24 * pour les six.
25 *
26 * ── CE QUI VIT ENCORE DANS LE TRONC, ET POURQUOI ────────────────────────────
27 *
28 * `bindSpaceChat` est appelé au câblage depuis le tronc, et il inscrit chaque
29 * espace au registre de relance (`inscrireRelanceStudio`) qui vit dans
30 * `src/app-message-gestes.js`. Trois fichiers pour un geste : c'est le prix de
31 * l'ordre de chargement, et c'est tenu par une garde plutôt que par la mémoire.
32 *
33 * ── LA RÈGLE DU DÉCOUPAGE ───────────────────────────────────────────────────
34 *
35 * `src/app.js` est un script classique. Ce fichier se charge APRÈS lui : il
36 * peut donc lire ses `const` (`STATE`, `I`, les tables) et appeler ses
37 * fonctions — elles sont déjà là. L'inverse est interdit : le tronc ne doit
38 * rien appeler d'ici pendant son propre chargement, sous peine de tomber
39 * ENTIER sur un ReferenceError. Les deux moitiés sont tenues par
40 * `tests/scripts-charges-smoke.mjs`.
41 */
42
43/* eslint-disable no-unused-vars */
44
45// Fil de discussion léger commun aux SIX espaces — Découvrir, Finance, CFO,
46// Éducation, Santé, Brevets.
47//
48// Le commentaire disait « 5 espaces », et il avait raison sur le fait sans le
49// savoir : Découvrir déclarait un chat en quatre endroits (suggestions, prompt
50// système, profil d'outils, appel à bindSpaceChat) et n'avait pas de champ où
51// écrire. `bindSpaceChat('espaceDecouvrir', …)` sortait à sa première ligne,
52// faute de bouton d'envoi. Un nombre écrit dans un commentaire ne vaut rien —
53// mais il désignait un trou.
54//
55// N'apparaît qu'une fois la 1ère question posée dans l'espace.
56// `enLigne` : la conversation est posée dans le flux de la page, juste sous la barre
57// de question (espaces dont la saisie est EN HAUT — Éducation, Santé, Brevets), et
58// non dans un encart épinglé en bas de l'écran. Sans cela on écrivait en haut et la
59// réponse apparaissait tout en bas, loin du regard. Finance et CFO gardent l'encart
60// épinglé : leur barre de question est déjà en bas, la conversation juste au-dessus.
61function _spaceChatArea(id, suggestions, enLigne) {
62  const chat = (STATE.studioChats || {})[id] || []
63  if (!chat.length) {
64    if (!suggestions?.length) return ''
65    // 2 colonnes (pas une longue liste verticale à 1 colonne) — chaque item
66    // pré-remplit la barre de question CREA de l'espace et l'envoie directement.
67    return `<div class="max-w-2xl mx-auto w-full px-4 pb-3">
68      <p class="text-center text-[max(0.6875rem,11px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Suggestions pour vous</p>
69      <div class="grid grid-cols-2 gap-1.5">${suggestions.map(s => `<button data-space-suggestion="${escHtml(s)}" data-space-suggestion-id="${id}" class="flex w-full items-start gap-2 rounded-xl px-3 py-2.5 text-left text-[max(0.78125rem,12.5px)] transition-colors hover:bg-black/5 min-w-0" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">
70        ${_icon('search', 'w-3.5 h-3.5 shrink-0 mt-0.5')}<span>${escHtml(s)}</span>
71      </button>`).join('')}</div>
72    </div>`
73  }
74  // Dans le flux, la conversation suit le défilement de la page : pas de hauteur
75  // plafonnée ni de second ascenseur imbriqué, qui obligeraie
75nt à faire défiler
76  // dans un cadre à l'intérieur d'une page qui défile déjà.
77  const cadre = enLigne ? 'mb-5 space-y-2' : 'pb-3 space-y-2 max-h-[38vh] overflow-y-auto'
78  // ET LA MÊME RANGÉE D'ACTIONS QUE LES STUDIOS. Les six espaces n'en avaient
79  // aucune : ni copier, ni relancer, ni supprimer. Une réponse ratée dans
80  // l'espace Santé ne pouvait qu'être relue — il fallait retaper la question.
81  /* ── LE PEIGNE ARRIVE ICI AUSSI ─────────────────────────────────────────
82   *
83   * « Le peigne, c'est le système des pointillés à côté là qui permet de suivre
84   *  tout ce qu'il y a dans le chat. Il faut venir aussi l'ajouter dans les
85   *  studios et dans les autres espaces de discussion. »
86   *
87   * DEUX CHOSES MANQUAIENT, ET LA PREMIÈRE EST UNE ANCRE. Un message d'espace
88   * n'avait aucun identifiant : ni `id`, ni `data-msgid`. Un peigne sans ancre
89   * s'affiche et ne mène nulle part — le dépôt appelle ça un bouton qui ne lève
90   * rien. Chaque message posé porte donc `id="espace-msg-<lieu>-<rang>"`, et
91   * `data-jump` le retrouve par `getElementById`, comme dans les studios.
92   *
93   * LA SECONDE EST UN REPÈRE. Le fil d'un espace coule DANS la page — les trois
94   * qui en ont un (Éducation, Santé, Brevets) passent tous `enLigne`. Il n'a
95   * donc pas de cadre borné où accrocher un peigne en `position:absolute`
96   * comme le fait le chat : il se poserait au milieu de la LISTE et défilerait
97   * avec elle. `position:sticky` le tient au milieu de la fenêtre pendant que le
98   * fil passe derrière — voir `.crea-peigne--espace`. */
99  return `<div class="max-w-2xl mx-auto w-full px-4 ${cadre}" id="space-chatlog-${id}" style="position:relative">
100    ${typeof window.renderStudioMinimapEspace === 'function' ? window.renderStudioMinimapEspace(id, chat) : ''}
101    ${chat.map((m, i) => m.role === 'user'
102      ? `<div id="espace-msg-${escHtml(id)}-${i}" class="flex flex-col items-end"><div class="rounded-2xl px-3 py-2 max-w-[85%] text-[max(0.8125rem,13px)]" style="background:var(--accent);color:white">${escHtml(m.content || '')}</div>${renderStudioMsgActions(id, i, true)}</div>`
103      // LE MÊME AFFICHAGE QUE LE CHAT, pas un second. `renderStepsZone` porte
104      // déjà le tour replié, le plan, la compaction et les tâches de fond : en
105      // réécrire une version pour les espaces, c'est se garantir que l'une des
106      // deux prendra du retard sur l'autre.
107      // La réflexion se lit ICI AUSSI, et par la MÊME fonction que le chat —
108      // pas par une seconde version qui prendrait du retard sur la première.
109      : `<div class="flex justify-start"><div class="max-w-[90%] w-full">
110          ${typeof renderStepsZone === 'function' ? renderStepsZone(m, !!m.pending) : ''}
111          ${typeof renderReflexion === 'function' ? renderReflexion(m, !!m.pending, m.reasoning, 'Réflexion', 'flux') : ''}
112          <div class="rounded-2xl px-3 py-2 text-[max(0.8125rem,13px)] msg-content" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${m.pending && !m.content ? '<span class="opacity-50">…</span>' : renderMarkdown(m.content || '')}</div>
113          ${m.pending && !m.content ? '' : renderStudioMsgActions(id, i, false)}
114        </div></div>`
115    ).join('')}
116  </div>`
117}
118const SPACE_SUGGESTIONS = {
119  espaceDecouvrir: ['Résume les actualités du jour', 'Quels sont les sujets tendance en ce moment ?', "Explique-moi un sujet d'actualité simplement"],
120  espaceProjets: ['Où en sont mes projets ?', 'Quelles tensions demandent une décision ?', 'Quel objectif est le plus en retard ?'],
121  espaceRelations: ['Qui porte quel projet ?', 'Résume mes relations de travail', 'Qui dois-je recontacter ?'],
122  espaceJuridique: ['Qu\'est-ce qui change dans la réglementation ?', 'Explique-moi le RGPD simplement', 'Que dois-je vérifier pour être en conformité ?'],
123  espaceFinance: ["Explique-moi l'évolution récente du Bitcoin", "Qu'est-ce qu'un ETF ?", 'Comment lire un graphique boursier ?'],
124  espaceCfo: ['Comment établir un budget mensuel ?', 'C\'est quoi la règle 50/30/20 ?', 'Comment commencer à investir ?'],
125  espaceEducation: ['Explique-moi la relativité restreinte simplement', 'Résume les grands courants de philosophie politique', 'Aide-moi à préparer un exposé'],
126  espaceBrevets: ['Comment savoir si une idée est brevetable ?', "Différence entre brevet et droit d'auteur ?", "Combien coûte le dépôt d'un brevet ?"],
127  espaceSante: ['Que faire en cas de fièvre légère ?', 'Comment améliorer mon sommeil ?', "C'est quoi une alimentation équilibrée ?"],
128}
129
130function _etatVivantDeLEspace(id) {
131  /* UN ESPACE EST UN LIEU, PAS QUELQU'UN D'AUTRE.
132   *
133   * `_common` ouvrait par « Tu es CREA, l'assistant IA de la plateforme CREA »
134   * puis chaque espace ajoutait son « Ici : … » — dix Créa parallèles, écrites
135   * ici, lues sans l'organisme. Comme les douze studios avant elles.
136   *
137   * L'espace dit maintenant d'où il parle, À L'ENVOI :
138   * `buildSystemPrompt(false, demande, id)`. Son lieu et son « Ici : … »
139   * vivent dans `STUDIOS` (app-domaine-surface.js) et montent par LA gorge —
140   * avec les compétences, les fichiers joints et toutes les gardes, que
141   * `_common` recopiait à moitié.
142   *
143   * Ce qui reste ici est un ÉTAT VIVANT, pas une identité : les cours crypto
144   * réels de l'espace Finance, lus au moment de l'envoi. */
145  const fd = (STATE.spaceData || {}).espaceFinance
146  return (id === 'espaceFinance' || id === 'espaceCfo') && fd?.crypto?.length
147    ? '\n\nCours crypto actuels (réels, CoinGecko) : ' + fd.crypto.map(c => `${c.symbol} ${c.price}$ (${c.change24h >= 0 ? '+' : ''}${(c.change24h || 0).toFixed(1)}%)`).join(', ') + '.'
148    : ''
149}
150
151// Câblage du chat d'un espace — appelé sans condition à chaque render() (comme le
152// reste des bindings CREA) : no-op si les boutons ne sont pas dans le DOM actuel.
153function bindSpaceChat(id) {
154  const sendBtn = document.getElementById('btn-space-send-' + id)
155  const inp = document.getElementById('space-input-' + id)
156  if (!sendBtn || !inp) return
157  // Même chose que dans les studios rapides : l'envoi porte un nom, donc la
158  // rangée d'actions peut le rappeler pour rejouer un message.
159  const envoyer = async (texteImpose = null) => {
160    const text = texteImpose != null ? String(texteImpose) : inp.value?.trim()
161    if (!text && !_hasComposerAttachment()) return
162    STATE.studioChats ||= {}; STATE.studioChats[id] = STATE.studioChats[id] || []
163    // Fichiers joints / blocs collés : consommés ici comme le chat principal (sinon
164    // ils s'affichaient en chip mais leur contenu n'était jamais lu par l'IA — CREA
165    // "lisait mal les fichiers" dans les espaces).
166    const _files = (STATE.attachedFiles || []).map(f => ({ name: f.name, type: f.type, dataUrl: f.dataUrl, extractedText: f.extractedText || undefined }))
167    const _pasteBlocks = (STATE.pasteBlocks || []).map(b => b.text)
168    STATE.attachedFiles = []; STATE.pasteBlocks = []
169    const _quotePrefix = _consumeReplyQuoteContext()
170    STATE.studioChats[id].push({ role: 'user', content: _quotePrefix + text, files: _files.length ? _files : undefined, pasteBlocks: _pasteBlocks.length ? _pasteBlocks : undefined })
171    // Les champs de la boucle sont posés dès la création du message : c'est ce
172    // qui permet à `renderStepsZone` — le MÊME affichage que le chat — de
173    // montrer les étapes, le plan et la compaction ici aussi.
174    STATE.studioChats[id].push({ id: uid(), role: 'assistant', content: '', pending: true, steps: [], _boucleServeur: true, _phase: 'connexion', _debutBoucle: Date.now(), _jetons: 0 })
175    inp.value = ''
176    // Vider aussi STATE.inputDrafts, sinon le prochain render() régénère la barre
177    // avec l'ancien texte (inp.value='' seul ne suffit pas — c'était le bug).
178    setInputDraft('space-input-' + id, '')
179    render()
180    try {
181      const _visionOK = STATE.selectedModel?.provider !== 'local' && STATE.selectedModel?.provider !== 'llama' && STATE.selectedModel?.provider !== 'hf' && STATE.selectedModel?.vision
182      const history = STATE.studioChats[id].slice(0, -1).map(m => ({ role: m.role === 'user' ? 'user' : 'assistant', content: m.role === 'user' ? toApiContent(m, _visionOK) : (m.content || '').slice(0, 4000) }))
183      const msg = STATE.studioChats[id][STATE.studioChats[id].length - 1]; const reperesEspace = {}
184      const majBulle = () => {
185        const logEl = document.getElementById('space-chatlog-' + id)
186        const bulle = logEl?.querySelector('div:last-child .msg-content')
187        if (bulle) bulle.textContent = msg.content || ''
188      }
189      const majReflexion = () => {
190        const logEl = document.getElementById('space-chatlog-' + id)
191        const corps = logEl?.querySelector('div:last-child .reasoning-body')
192        if (corps) corps.textContent = msg.reasoning || ''
193      }
194      // ── L'ESPACE PASSE PAR LA BOUCLE, COMME LE RESTE ───────────────────
195      //
196      // « Il faut les relier aussi là-bas. »
197      //
198      // Il postait sur /api/chat : un seul tour, ZÉRO outil. L'espace Finance
199      // ne pouvait pas aller chercher un cours, l'espace Découvrir parlait de
200      // l'actualité sans jamais la lire, l'espace Santé donnait un avis sans
201      // ouvrir une source. Ils répondaient de mémoire — celle du modèle, arrêtée
202      // à sa date d'entraînement — avec le même aplomb que le chat outillé.
203      //
204      // Ils héritent maintenant de tout ce que la boucle serveur porte : le
205      // plan, la compaction, les trois plafonds, la reprise après coupure, et
206      // les outils DE LEUR DOMAINE (voir PROFILS_DE_SURFACE) — pas ceux du chat.
207      const profil = profilDeSurface(id)
208      const outilsAutorises = (() => { try { return agentToolsFor(id).map((t) =>
208 t.function?.name).filter(Boolean) } catch (e) { return null } })()
209      const sortie = await runAgentBoucleStream({
210        // `runAgentBoucleStream` canonise lui-même : le refaire ici
211        // envelopperait deux fois le même texte.
212        messages: history,
213        /* LA GORGE, SOUS LE LIEU DE L'ESPACE, composée À L'ENVOI : les terrains
214           jugent cette phrase-ci, pas le dernier message du fil de chat. */
215        systemPrompt: buildSystemPrompt(false, text, id) + _etatVivantDeLEspace(id),
216        /* D’OÙ L’ON PARLE, ET CE QUI SURVIT ICI D’UN MESSAGE À L’AUTRE.
217           `surface` : ceinture, si la gorge cessait d’être appelée au-dessus, le
218           plancher poserait le lieu de l’espace au lieu de celui du chat.
219           `memoire` : sa session (donc la reprise après coupure, que le
220           commentaire ci-dessus promettait sans la brancher), son arrêt, et les
221           outils qu’il a déjà ouverts. Voir memoireDuLieu, dans le tronc. */
222        surface: id,
223        memoire: id,
224        model: STATE.selectedModel.id,
225        provider: STATE.selectedModel.provider || undefined,
226        authParams: internalAuth(),
227        outilsAutorises,
228        options: { maxTours: profil.maxTours },
229        onPhase: (phase) => { if (msg._phase !== phase) { msg._phase = phase; render() } },
230        /* ── UN OUTIL A RENDU QUELQUE CHOSE : ON LE MONTRE ────────────────
231         *
232         * `onArtifact` n'était pas branché ici. Le défaut de
233         * `runAgentBoucleStream` est une fonction vide : le serveur produisait
234         * l'artefact, il était payé, et il tombait. Un outil qui rapporte un
235         * tableau, un JSON, un bout de code — dans les six espaces, ça ne se
236         * voyait nulle part.
237         *
238         * MESURÉ AVANT DE BRANCHER, et c'est la seule chose qui autorise ce
239         * fil : le panneau d'artefacts s'affiche BIEN dans un espace — 478×642
240         * dans Réseau, Collab, Coworker et Dispatch. Brancher un signal vers un
241         * panneau absent aurait été refaire, à l'envers, le défaut qu'on répare.
242         *
243         * `artefactDeSortieDOutil` est le poste partagé : il normalise la
244         * sortie (le repli `kind:'json'` existe parce qu'un type inconnu
245         * finissait injecté comme du corps de page), la pose, et ouvre le
246         * panneau. On ne recopie pas ces six lignes — elles l'ont déjà été deux
247         * fois, à trente mille lignes d'écart. */
248        onArtifact: (artefact) => {
249          try { if (artefactDeSortieDOutil(artefact)) render() }
250          catch (e) { /* un artefact mal formé coûte son affichage, jamais le tour d'agent */ }
251        },
252        // LA RÉFLEXION ÉTAIT PRODUITE, FACTURÉE, ET JETÉE.
253        //
254        // Ce rappel n'était pas branché : le défaut de `runAgentBoucleStream`
255        // est une fonction vide. Les jetons de réflexion partaient, revenaient,
256        // étaient payés — et tombaient dans le vide. Le message porte déjà tout
257        // ce qu'il faut pour l'afficher (`renderReflexion` lit `reasoning`).
258        // Le nœud est patché directement, comme la bulle : un rendu complet par
259        // fragment de réflexion fige l'écran — c'est la panne qu'on vient de
260        // retirer d'ailleurs, on ne la réintroduit pas ici.
261        onThinkingDelta: (delta) => {
262          const premier = !msg.reasoning
263          msg.reasoning = (msg.reasoning || '') + delta
264          msg.thinking = true
265          if (premier) { render(); return }
266          majReflexion()
267        },
268        onTextDelta: (delta) => { msg.content = (msg.content || '') + delta; majBulle() },
269        /* LE CINQUIÈME CHEMIN, ET IL AVAIT DÉJÀ ÉTÉ OUBLIÉ UNE FOIS : la frise
270           était éteinte ici, et ici seulement. Un repère qui ne descend pas
271           jusqu'ici rejouerait la même panne, en plus discret. */
272        onToolAncrer: (id) => ouvrirEtape.ancrer(reperesEspace, id, msg), /* LE MÊME poste unique, par LA MÊME porte */
273        onToolStart: (nom, parametres, id) => {
274
275          /* ── LA FRISE ÉTAIT ÉTEINTE ICI, ET ICI SEULEMENT ────────────────
276           *
277           * `marquerReflexion` note, au démarrage de chaque outil, la longueur
278           * qu'avait DÉJÀ le texte (`step._contenuA`). Deux repères consécutifs
279           * découpent ce qui a été écrit entre deux actes ; `friseEntrelace`
280           * s'allume dès qu'une étape en porte un, et le fil se lit alors dans
281           * l'ordre où les choses se sont passées — phrase, acte, phrase, acte.
282           *
283           * Les quatre autres chemins l'appellent (chat, studios ×2, boucle
284           * serveur). Ce cinquième-ci, non : les six espaces montraient une
285           * pile d'actions d'un côté et un bloc de texte de l'autre, sans que
286           * rien ne dise quelle phrase avait décidé quel geste.
287           *
288           * Mesuré sur la vraie fonction : sans l'appel, `friseEntrelace` rend
289           * faux et `segmentContenu` rend '' pour chaque étape. Avec, les
290           * segments se découpent exactement aux endroits où le texte s'était
291           * arrêté pour agir.
292           */
293          const step = ouvrirEtape(msg, nom, parametres, reperesEspace[id]) /* LE POSTE UNIQUE, dans le tronc (src/app.js) : ce fichier se charge APRÈS lui, et n'appelle qu'à l'exécution */
294          render()
295          return step
296        },
297        // Le MÊME geste que les trois autres surfaces. Cette copie-ci rangeait
298        // `d.resultat` BRUT — un objet — là où les lecteurs font String(…) :
299        // l'aperçu d'un résultat s'affichait « [object Object] » dans les six
300        // espaces.
301        onToolDone: (step, d) => { if (cloreEtape(step, d)) render() },
302        onTour: (d) => { if (typeof d?.jetonsCumules === 'number') msg._jetons = d.jetonsCumules; if (typeof d?.jetonsPensee === 'number') msg._jetonsPensee = d.jetonsPensee },
303        // Le MÊME aiguillage que le chat et les studios. Cette copie-ci ne
304        // rangeait ni la vérification ni les blocages : un espace pouvait
305        // repartir pour un tour de contrôle sans que rien ne l'explique.
306        onServerEvent: (evt) => { if (rangerEvenementBoucle(msg, evt)) render() },
307      })
308      msg.pending = false
309      // POURQUOI ÇA S'EST ARRÊTÉ, EN FRANÇAIS — la même phrase qu'ailleurs. Un
310      // espace qui rend la main sans rien dire ressemble à une panne.
311      if (!msg.content && sortie?.sortie && sortie.sortie !== 'termine') msg.content = phraseDeSortie(sortie.sortie, msg.steps)
312      if (!msg.content) msg.content = '❌ Réponse vide'
313      save(); render()
314    } catch (e) {
315      const chat = STATE.studioChats[id]; const last = chat[chat.length - 1]
316      if (last?.pending) { last.pending = false; last.content = '❌ ' + expliquerEchecReseau(e) }
317      render()
318    }
319  }
320  sendBtn.addEventListener('click', () => envoyer())
321  inscrireRelanceStudio(id, (texte) => envoyer(texte))
322  inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click() } })
323}
324
325// ── LES CENTRES D'INTÉRÊT ───────────────────────────────────────────────
326//
327// Ils étaient CINQ. « Il faut trouver tous les centres d'intérêt qui existe
328// parce qu'il y en a beaucoup et les relier au flux. »
329//
330// LES DEUX MOTS COMPTENT AUTANT. « Beaucoup » est la partie visible ; « reliés
331// au flux » est celle qui décide si ça marche. Côté serveur, `Pour vous` fait :
332//
333//     const choisis = (interets || []).filter(i => rubriques.includes(i))
334//
335// — un identifiant absent de `RUBRIQUES` est jeté sans un mot, et si TOUS le
336// sont, le fil retombe sur l'actualité générale. L'usager verrait ses pastilles
337// cochées et recevrait Top. Une pastille qui ne filtre rien est pire qu'une
338// pastille absente : elle a l'air de marcher.
339//
340// Les identifiants sont donc les CLÉS EXACTES de `RUBRIQUES`
341// (server/crea-decouvrir.js), et `tests/decouvrir-interets-smoke.mjs` tient
342// l'égalité dans les deux sens. Les cinq d'origine gardent leur identifiant au
343// caractère près : ils sont enregistrés dans les préférences des comptes
344// existants, les renommer effacerait des choix déjà faits.
345//
346// Les icônes se répètent d'une famille à l'autre, et c'est voulu : le jeu de
347// traits en compte 51, et `_icon()` d'un nom inconnu rend un SVG VIDE — un
348// carré blanc, silencieusement. Mieux vaut une icône juste réemployée qu'un
349// trou qui ne se signale pas.
350const DISCOVER_INTERESTS = [
351  // Technologie, sciences
352  { id: 'Technologie & Science', label: 'Technologie & Science', icon: _icon('computer') },
353  { id: 'Intelligence artificielle', label: 'Intelligence artificielle', icon: _icon('brain') },
354  { id: 'Espace & Astronomie', label: 'Espace & Astronomie', icon: _icon('science') },
355  { id: 'Recherche & Innovation', label: 'Recherche & Innovation', icon: _icon('search') },
356  // Argent, travail
357  { id: 'Entreprise', label: 'Entreprise', icon: _icon('money') },
358  { id: 'Économie & Finance', label: 'Économie & Finance', icon: _icon('chart') },
359  { id: 'Marchés & Bourse', label: 'Marchés & Bourse', icon: _icon('wallet') },
360  { id: 'Cryptomonnaies', label: 'Cryptomonnaies', icon: _icon('card') },
361  { id: 'Startups & Innovation', label: 'Startups & Innovation', icon: _icon('bolt') },
362  { id: 'Emploi & Carrière', label: 'Emploi & Carrière', icon: _icon('briefcase') },
363  { id: 'Immobilier', label: 'Immobilier', icon: _icon('home') },
364  // Le monde, la cité
365  { id: 'Politique', label: 'Politique', icon: _icon('law') },
366  { id: 'International', label: 'International', icon: _icon('globe') },
367  { id: 'France', label: 'France', icon: _icon('home') },
368  { id: 'Justice & Société', label: 'Justice & Société', icon: _icon('law') },
369  { id: 'Droit & Réglementation', label: 'Droit & Réglementation', icon: _icon('doc') },
370  { id: 'Défense & Sécurité', label: 'Défense & Sécurité', icon: _icon('shieldCheck') },
371  { id: 'Médias & Réseaux sociaux', label: 'Médias & Réseaux sociaux', icon: _icon('network') },
372  // Le vivant
373  { id: 'Santé & Médecine', label: 'Santé & Médecine', icon: _icon('health') },
374  { id: 'Bien-être & Psychologie', label: 'Bien-être & Psychologie', icon: _icon('heart') },
375  { id: 'Environnement & Climat', label: 'Environnement & Climat', icon: _icon('cloud') },
376  { id: 'Énergie', label: 'Énergie', icon: _icon('bolt') },
377  { id: 'Agriculture & Alimentation', label: 'Agriculture & Alimentation', icon: _icon('stack') },
378  // Apprendre, créer
379  { id: 'Éducation', label: 'Éducation', icon: _icon('humanities') },
380  { id: 'Histoire & Patrimoine', label: 'Histoire & Patrimoine', icon: _icon('journal') },
381  { id: 'Arts & Culture', label: 'Arts & Culture', icon: _icon('palette') },
382  { id: 'Livres & Littérature', label: 'Livres & Littérature', icon: _icon('doc') },
383  { id: 'Musique', label: 'Musique', icon: _icon('star') },
384  { id: 'Cinéma & Séries', label: 'Cinéma & Séries', icon: _icon('tv') },
385  { id: 'Photographie', label: 'Photographie', icon: _icon('eye') },
386  { id: 'Mode & Design', label: 'Mode & Design', icon: _icon('palette') },
387  { id: 'Architecture & Urbanisme', label: 'Architecture & Urbanisme', icon: _icon('home') },
388  // Loisirs
389  { id: 'Sport', label: 'Sports', icon: _icon('trophy') },
390  { id: 'Jeux vidéo', label: 'Jeux vidéo', icon: _icon('desktop') },
391  { id: 'Divertissement', label: 'Divertissement', icon: _icon('tv') },
392  { id: 'Voyage', label: 'Voyage', icon: _icon('globe') },
393  { id: 'Cuisine & Gastronomie', label: 'Cuisine & Gastronomie', icon: _icon('heart') },
394  { id: 'Automobile & Mobilité', label: 'Automobile & Mobilité', icon: _icon('network') },
395]
396function _weatherIcon(code) {
397  if (code === 0) return '☀️'
398  if (code <= 3) return code === 1 ? '🌤️' : code === 2 ? '⛅' : '☁️'
399  if (code <= 48) return '🌫️'
400  if (code <= 57) return '🌦️'
401  if (code <= 67) return '🌧️'
402  if (code <= 77) return '❄️'
403  if (code <= 82) return '🌦️'
404  if (code <= 86) return '🌨️'
405  return '⛈️'
406}
407// ── LE CHOIX DES CENTRES D'INTÉRÊT — UN SEUL PANNEAU, DEUX PORTES ───────
408//
409// Il s'ouvre depuis « Pour vous » (deuxième clic) et depuis « Personnaliser »
410// dans la colonne. Deux portes, une seule pièce : le dupliquer, c'est se
411// condamner à corriger deux fois — et à en oublier une, ce qui est le motif
412// qu'on passe la journée à défaire ici.
413//
414// Il DÉFILE. Avec trente-huit pastilles, un panneau sans hauteur bornée sort
415// de l'écran par le bas, et les dernières familles deviennent inatteignables
416// sans que rien ne le signale.
417function _decChoixInterets(interests) {
418  const familles = [
419    ['Technologie & sciences', ['Technologie & Science', 'Intelligence artificielle', 'Espace & Astronomie', 'Recherche & Innovation']],
420    ['Argent & travail', ['Entreprise', 'Économie & Finance', 'Marchés & Bourse', 'Cryptomonnaies', 'Startups & Innovation', 'Emploi & Carrière', 'Immobilier']],
421    ['Le monde & la cité', ['Politique', 'International', 'France', 'Justice & Société', 'Droit & Réglementation', 'Défense & Sécurité', 'Médias & Réseaux sociaux']],
422    ['Le vivant', ['Santé & Médecine', 'Bien-être & Psychologie', 'Environnement & Climat', 'Énergie', 'Agriculture & Alimentation']],
423    ['Apprendre & créer', ['Éducation', 'Histoire & Patrimoine', 'Arts & Culture', 'Livres & Littérature', 'Musique', 'Cinéma & Séries', 'Photographie', 'Mode & Design', 'Architecture & Urbanisme']],
424    ['Loisirs', ['Sport', 'Jeux vidéo', 'Divertissement', 'Voyage', 'Cuisine & Gastronomie', 'Automobile & Mobilité']],
425  ]
426  const pastille = (id) => {
427    const it = DISCOVER_INTERESTS.find(x => x.id === id)
428    if (!it) return ''
429    const choisi = interests.includes(it.id)
430    return `<button data-discover-interest="${escHtml(it.id)}" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-medium" style="${choisi ? 'background:var(--accent);color:white;border:1px solid var(--accent)' : 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)'}">${it.icon}${escHtml(it.label)}</button>`
431  }
432  return `<div id="dec-choix-interets" class="absolute top-full left-0 mt-1.5 z-30 rounded-2xl shadow-2xl anim-slide-down" style="background:var(--bg-input);border:1px solid var(--border);width:min(92vw,420px)">
433    <div class="px-3.5 pt-3 pb-2" style="border-bottom:1px solid var(--border)">
434      <p class="text-[max(0.78125rem,12.5px)] font-bold" style="color:var(--t-primary)">Vos centres d'intérêt</p>
435      <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-dim)">Ils décident de ce que « Pour vous » va chercher. Sans aucun choix, le fil reste sur l'actualité générale.</p>
436    </div>
437    <div class="px-3.5 py-2.5" style="max-height:min(52vh,380px);overflow-y:auto">
438      ${familles.map(([titre, ids]) => `<div class="mb-3">
439        <p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1.5" style="color:var(--t-faint)">${escHtml(titre)}</p>
440        <div class="flex flex-wrap gap-1.5">${ids.map(pastille).join('')}</div>
441      </div>`).join('')}
442    </div>
443    <div class="px-3.5 py-2.5 flex items-center gap-2" style="border-top:1px solid var(--border)">
444      <span class="text-[max(0.6875rem,11px)] flex-1" style="color:var(--t-faint)">${interests.length ? `${interests.length} sélectionné${interests.length > 1 ? 's' : ''}` : 'aucun choisi'}</span>
445      <button id="btn-discover-clear-interests" class="rounded-full px-3 py-1.5 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Tout retirer</button>
446      <button id="btn-discover-save-interests" class="rounded-full px-4 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--t-primary);color:var(--bg-primary)">Enregistrer</button>
447    </div>
448  </div>`
449}
450
451function renderDecouvrirSpace() {
452  const d = (STATE.spaceData || {}).espaceDecouvrir || {}
453  const tab = STATE.discoverSpaceTab || 'top'
454  const interests = STATE.discoverInterests || []
455  const weather = d.weather
456  // LE FIL EST RENDU PAR SON MODULE (src/crea-decouvrir-ui.js) : sujets
457  // regroupés, chapô, dates réelles, « N sources », défilement infini. Cet
458  // écran ne dessine plus que ce qui l'entoure. Avant, il fabriquait lui-même
459  // une vignette + trois cartes à partir des quatre premiers articles d'un
460  // appel qui en ramenait douze, et le fil s'arrêtait là.
461  const fil = renderVueInterne() || (window.CREA_DECOUVRIR
462    ? window.CREA_DECOUVRIR.rendre()
463    : `<div class="dec-etat">Module Le Fil indisponible.</div>`)
464  const sujetActif = tab === 'sujets' && STATE.discoverSujetTopic ? STATE.discoverSujetTopic : ''
465  const onglet = (id, libelle, extra) => `<button data-discover-tab="${id}" class="dec-onglet${tab === id ? ' on' : ''}">${escHtml(libelle)}${extra || ''}</button>`
466  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
467    ${_spaceMenuBtn()}
468    <div id="dec-defilement" class="flex-1 overflow-y-auto min-h-0">
469      <div class="w-full px-4 md:px-8 pt-3 pb-8 md:flex md:gap-8">
470        <div class="flex-1 min-w-0">
471          <!-- UNE SEULE LIGNE : le titre a gauche, les onglets au centre,
472               Partager a droite.
473
474               Ils ont tenu leur propre ligne en dessous pendant un temps, au
475               nom de la disposition de reference. L'usager l'a corrige sur
476               photo : ils doivent etre sur la MEME ligne que Decouvrir et
477               Partager. On remplace la justification plutot que de la
478               contredire en silence, sinon le prochain les redescendra en
479               citant la reference.
480
481               flex-wrap garde la ligne lisible en etroit : sur un ecran qui
482               ne peut pas tenir les trois, les onglets passent dessous
483               d'eux-memes plutot que d'etre ecrases. -->
484          <!-- ── LE CHEMIN DU RETOUR ────────────────────────────────────
485               Quand on arrive ici depuis une autre branche — « ce qu'on en
486               dit » d'un projet, la veille de Juridique — le point de départ
487               serait perdu sans ça : il faudrait rouvrir le menu, retrouver
488               la branche, et se rappeler ce qu'on y cherchait. Trois gestes
489               pour défaire un clic, et au bout de deux fois on ne 
489clique
490               plus. C'est la différence entre des écrans côte à côte et des
491               surfaces qui se répondent. -->
492          ${STATE.filRetour?.workMode ? `<button id="btn-fil-retour" class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 mb-2.5 text-[max(0.71875rem,11.5px)] font-medium" style="background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent)">
493            <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" d="M15 19l-7-7 7-7"/></svg>
494            Retour à ${escHtml(STATE.filRetour.libelle || 'l\'espace précédent')}
495          </button>` : ''}
496          <div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 mb-3">
497            ${_spaceTitre('Le Fil', 'text-[max(1.1875rem,19px)] font-bold', 'color:var(--t-primary)')}
498          <div class="dec-onglets">
499            <!-- « POUR VOUS » A UNE FLÈCHE, COMME « SUJETS », ET DEUX TEMPS.
500                 Premier clic 
500: on ouvre le fil personnalisé. Deuxième clic,
501                 alors qu'on y est déjà : on ouvre le choix des centres
502                 d'intérêt. Le geste suit l'intention — on ne va régler ses
503                 sujets qu'une fois qu'on regarde le fil qu'ils produisent, et
504                 le réglage s'ouvre là où on l'a demandé, pas dans une colonne
505                 à l'autre bout de l'écran. -->
506            <span class="relative">
507              <button id="btn-discover-pourvous" class="dec-onglet${tab === 'pourvous' ? ' on' : ''}">Pour vous <svg class="w-3 h-3 inline-block transition-transform ${STATE.discoverInterestsOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg></button>
508              ${STATE.discoverInterestsOpen ? _decChoixInterets(interests) : ''}
509            </span>
510            ${onglet('top', 'Top')}
511            <!-- L'onglet de recherche n'existe QUE pendant une recherche. Un
512                 onglet permanent et vide serait une promesse qu'on ne tient
513                 pas ; celui-ci apparaît avec sa requête et disparaît avec
514                 elle, et le cliquer y ramène depuis Top ou Sujets. -->
515            ${STATE.discoverQuery ? onglet('recherche', '« ' + String(STATE.discoverQuery).slice(0, 22) + ' »') : ''}
516            <!-- « Gardés » n'apparaît qu'une fois qu'on a gardé quelque chose :
517                 un onglet toujours vide serait un reproche permanent. -->
518            ${(STATE.filGardes || []).length ? onglet('gardes', 'Gardés', ` <span style="opacity:.6">${(STATE.filGardes || []).length}</span>`) : ''}
519            <span class="relative">
520              <button id="btn-discover-sujets" class="dec-onglet${tab === 'sujets' ? ' on' : ''}">${escHtml(sujetActif || 'Sujets')} <svg class="w-3 h-3 inline-block transition-transform ${STATE.discoverSujetsOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg></button>
521              ${STATE.discoverSujetsOpen ? `<div class="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 z-30 rounded-2xl p-1.5 shadow-2xl anim-slide-down" style="background:var(--bg-input);border:1px solid var(--border);min-width:230px;max-height:min(60vh,420px);overflow-y:auto">
522                ${DISCOVER_INTERESTS.map(it => `<button data-discover-sujet="${escHtml(it.id)}" class="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[max(0.78125rem,12.5px)]" style="color:var(--t-primary)">${it.icon}${escHtml(it.label)}</button>`).join('')}
523              </div>` : ''}
524            </span>
525          </div>
526            <!-- ── CHERCHER DANS LE FIL ────────────────────────────────────
527                 « Il y a Top, Pour vous, Sujets — mais si tu veux savoir ce
528                  qu'on dit de quelque chose de précis aujourd'hui, rien. »
529
530                 Le moteur savait déjà le faire : NewsAPI, GDELT et les flux
531                 prennent une requête, le regroupement fond les reprises, la
532                 pagination suit. Il manquait la possibilité de l'ÉCRIRE.
533
534                 Un CHAMP, pas un bouton loupe qui ouvre un champ : le geste le
535                 plus fréquent ne se met pas derrière un clic. Il rétrécit à
536                 92 px sous 640 px de large plutôt que de pousser les onglets à
537                 la ligne. -->
538            <div class="flex items-center gap-2 shrink-0">
539              <div class="dec-recherche flex items-center gap-1.5 rounded-full px-2.5 py-1" style="background:var(--bg-input);border:1px solid var(--border)">
540                ${_icon('search', 'w-3.5 h-3.5 shrink-0')}
541                ${/* LA VALEUR VIENT DE CE QU'ON TAPE, PAS DE LA DERNIÈRE RECHERCHE LANCÉE.
542                     Ce champ ne se lance qu'à Entrée ou en le quittant (dix requêtes vers
543                     trois API externes pour un mot de dix lettres, sinon) — mais son nœud
544                     est refait à CHAQUE render(), pour n'importe quelle raison. Ce qu'on
545                     était en train de taper revenait donc à la dernière recherche lancée,
546                     au milieu d'une frappe. `discoverQuerySaisie` retient la frappe sans
547                     rien lancer ; `discoverQuery` reste ce qui a VRAIMENT été cherché. */''}
548                <input id="dec-recherche" type="search" value="${esc
548Html(STATE.discoverQuerySaisie != null ? STATE.discoverQuerySaisie : (STATE.discoverQuery || ''))}" placeholder="Chercher dans le fil"
549                  class="bg-transparent border-0 outline-none text-[max(0.75rem,12px)]" style="color:var(--t-primary);width:150px" />
550                ${STATE.discoverQuery ? `<button id="btn-dec-recherche-vider" title="Effacer la recherche" class="shrink-0" style="color:var(--t-faint);line-height:1">×</button>` : ''}
551              </div>
552              <button id="btn-discover-partager" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium shrink-0" style="border:1px solid var(--border);color:var(--t-secondary)">Partager</button>
553            </div>
554          </div>
555          ${fil}
556        </div>
557        <!-- ── LA COLONNE DE DROITE — UNE LIGNE VERTICALE DE TROIS BLOCS ──
558             « Sur le côté, il reste des blancs en dessous de la ligne de
559              météo… il faudrait mettre d'autres blocs pour que tout ce blanc-là
560              soit rempli. Le bloc météo, on va faire une ligne verticale avec le
561              bloc météo et perspective marché et Entreprise, de la même hauteur,
562              une ligne de trois blocs mais en vertical, pas en horizontal —
563              comme ça les blocs pourront remplir toute la page. »
564
565             Trois choses portent ça, et aucune ne suffit seule :
566               md:h-[...dvh]   donne enfin une hauteur à la colonne. Sans elle,
567                               flex-1 ne partage rien : dans un conteneur de
568                               hauteur automatique, il n'y a pas de reste a
569                               distribuer, et les blocs gardent la taille de leur
570                               contenu — c'est très exactement le blanc dont il
571                               parle.
572               flex-1          sur chacun : la hauteur se partage en trois parts
573                               égales, ce qui est la demande au mot près.
574               overflow-y-auto dans chaque bloc : un bloc trop rempli défile
575                               chez lui au lieu de pousser les deux autres et de
576                               défaire l'égalité.
577
578             dvh et non vh : sur mobile la barre d'adresse se rétracte, et
579             vh fige la hauteur d'avant la rétractation — la colonne dépasserait
580             de la page exactement de la hauteur de la barre.
581
582             Le bloc « Comment fonctionne Découvrir » a été retiré : c'était une
583             notice qu'on lit une fois et qui occupait ensuite le haut de la
584             colonne pour toujours. « Personnaliser » prend sa place — un geste
585             plutôt qu'une explication. -->
586        <aside class="w-full md:w-[300px] shrink-0 mt-6 md:mt-0 flex flex-col gap-3 md:sticky md:top-0 md:h-[calc(100dvh-118px)]">
587          <button id="btn-discover-personnaliser" class="shrink-0 flex items-center justify-center gap-2 rounded-2xl py-2.5 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-primary)">
588            ${_icon('gear', 'w-3.5 h-3.5')}Personnaliser${interests.length ? ` <span class="text-[max(0.6875rem,11px)] font-medium" style="color:var(--t-faint)">· ${interests.length}</span>` : ''}
589          </button>
590          <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
591            <p class="text-[max(0.78125rem,12.5px)] font-bold mb-1" style="color:var(--t-primary)">${escHtml(weather?.location?.name || 'Météo')}</p>
592            ${weather?.current ? `
593              <p class="text-[max(1.625rem,26px)] font-bold mb-2.5" style="color:var(--t-primary)">${Math.round(weather.current.temperature_2m)}°${escHtml(weather.units?.temperature_2m || 'C')}</p>
594              ${weather.daily?.time ? `<div class="flex gap-1.5">${weather.daily.time.map((t, i) => `<div class="flex-1 text-center rounded-lg py-2" style="background:var(--bg-input)">
595                <p class="text-[max(0.5625rem,9px)]" style="color:var(--t-faint)">${escHtml(new Date(t).toLocaleDateString('fr-FR', { weekday: 'short' }))}</p>
596                <p class="text-[max(1rem,16px)]">${_weatherIcon(weather.daily.weather_code?.[i])}</p>
597                <p class="text-[max(0.59375rem,9.5px)]" style="color:var(--t-dim)">${Math.round(weather.daily.temperature_2m_max?.[i])}°</p>
598              </div>`).join('')}</div>` : ''}
599            ` : `<p class="text-[max(0.71875rem,11.5px)]" style="color:var(--t-dim)">${d.loading ? 'Relevé en cours…' : 'Relevé indisponible pour le moment.'}</p>`}
600          </div>
601          <!-- « Perspectives du marché » et « Entreprises tendance » : les deux
602               encarts de la colonne de droite de la référence. Ils n'existaient
603               pas — la colonne s'arrêtait à la météo. -->
604          <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
605            <p class="flex items-center gap-1 text-[max(0.78125rem,12.5px)] font-bold mb-2.5" style="color:var(--t-primary)">Perspectives du marché ${_icon('chevronRight', 'w-3 h-3')}</p>
606            <div class="grid grid-cols-2 gap-2">${_bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')}</div>
607          </div>
608          <div class="flex-1 min-h-0 overflow-y-auto rounded-2xl px-4 py-3" style="background:var(--bg-secondary);border:1px solid var(--border)">
609            <p class="flex items-center gap-1 text-[max(0.78125rem,12.5px)] font-bold mb-1" style="color:var(--t-primary)">Entreprises tendance ${_icon('chevronRight', 'w-3 h-3')}</p>
610            ${_bourseBloc((d.entreprises || []).slice(0, 5), _bourseLigne, d.bourseError, d.loading && !(d.entreprises || []).length, 'py-2')}
611          </div>
612        </aside>
613      </div>
614    </div>
615    <!-- LA BARRE DE QUESTION DE DÉCOUVRIR — PROMISE QUATRE FOIS, CÂBLÉE NULLE PART.
616         Cet espace déclarait un chat en quatre endroits : trois suggestions dans
617         SPACE_SUGGESTIONS, un lieu dans STUDIOS, un profil
618         d'outils dans PROFILS_DE_SURFACE (recherche, actualités, lecture de page,
619         vidéo, images) et un appel à bindSpaceChat au démarrage. Aucun de ces
620         quatre n'a jamais servi : il n'y avait pas de champ, donc bindSpaceChat
621         sortait à la première ligne, faute de bouton d'envoi.
622         Un fil d'actualités est exactement l'endroit où l'on veut demander
623         « explique-moi ce sujet simplement » — et le profil pour y répondre en
624         allant LIRE était déjà écrit. Même gabarit que les cinq autres espaces. -->
625    <!-- BARRE DE QUESTION RETIRÉE ICI — DEMANDE DU PROPRIÉTAIRE, PROVISOIRE.
626         « Supprime la barre de questions de l'espace finance et découvrir,
627          eux ils viennent de masquer la moitié de l'écran. »
628         Le bloc épinglé en bas cumulait trois hauteurs : la grille de
629         suggestions, le fil de conversation (jusqu'à 38 % de la hauteur) et la
630         barre elle-même. Sur un écran de portable il ne restait presque rien
631         pour le contenu — or le contenu EST l'espace : l'actualité ici, les
632         marchés dans Finance.
633         Rien d'autre n'est démonté : le prompt système, le profil d'outils et
634         l'appel à bindSpaceChat restent en place et sortent proprement faute de
635         champ. Remettre les deux lignes ci-dessous suffit à tout réactiver. -->
636  </main>`
637}
638
639const FINANCE_TABS = [
640  { id: 'marches', label: 'Marchés américains' },
641  { id: 'crypto', label: 'Crypto' },
642  { id: 'benefices', label: 'Bénéfices' },
643  { id: 'predictions', label: 'Prédictions' },
644  { id: 'screener', label: 'Screener' },
645  { id: 'politiciens', label: 'Politiciens' },
646  { id: 'liste-suivi', label: 'Liste de suivi' },
647  { id: 'galerie', label: "Galerie d'applications" },
648]
649// Carte "Principaux actifs" — reprend le patron exact de la photo (nom, %, prix,
650// delta, graphique de tendance) avec de vraies données CoinGecko (crypto — pas
651// d'indices actions, cf. bandeau d'avertissement dans renderFinanceSpace).
652function _marketAssetCard(c) {
653  const pct = c.change24h || 0
654  const up = pct >= 0
655  const color = up ? '#16a34a' : '#dc2626'
656  // Delta en $ dérivé du prix + % réels (pas une valeur inventée séparément).
657  const prevPrice = c.price / (1 + pct / 100)
658  const deltaUsd = c.price - prevPrice
659  const digits = c.price < 10 ? 4 : 2
660  return `<div class="rounded-xl p-3.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
661    <div class="flex items-center justify-between mb-1">
662      <span class="text-[max(0.8125rem,13px)] font-semibold" style="color:var(--t-primary)">${escHtml(c.symbol)}</span>
663      <span class="text-[max(0.71875rem,11.5px)] font-semibold" style="color:${color}">${up ? '↗' : '↘'} ${Math.abs(pct).toFixed(2)} %</span>
664    </div>
665    <p class="text-[max(0.9375rem,15px)] font-bold mb-0.5" style="color:var(--t-primary)">${c.price.toLocaleString('fr-FR', { maximumFractionDigits: digits })} $US</p>
666    <p class="text-[max(0.65625rem,10.5px)] mb-1.5" style="color:${color}">${up ? '+' : '-'}${Math.abs(deltaUsd).toLocaleString('fr-FR', { maximumFractionDigits: digits })} $US</p>
667    <div style="height:32px">${_sparklineSvg(c.sparkline, color)}</div>
668  </div>`
669}
670// Ligne "Créer une liste de suivi" — même patron que les lignes Amazon/Apple/Tesla
671// de la photo (avatar, nom, prix, %), avec de vrais actifs crypto (seuls disponibles
672// sans clé de fournisseur de données actions).
673function _watchlistRow(c) {
674  const up = (c.change24h || 0) >= 0
675  return `<div class="flex items-center gap-2.5 py-2" style="border-bottom:1px solid var(--border)">
676    <span class="w-7 h-7 rounded-full flex items-center justify-center text-[max(0.625rem,10px)] font-bold text-white shrink-0" style="background:linear-gradient(135deg,#f59e0b,#ea580c)">${escHtml(c.symbol.slice(0, 3))}</span>
677    <span class="flex-1 min-w-0">
678      <span class="block text-[max(0.75rem,12px)] font-medium truncate" style="color:var(--t-primary)">${escHtml(c.symbol)}</span>
679      <span class="block text-[max(0.625rem,10px)]" style="color:var(--t-faint)">${escHtml(c.id)} · Crypto</span>
680    </span>
681    <span class="text-right shrink-0">
682      <span class="block text-[max(0.75rem,12px)] font-medium" style="color:var(--t-primary)">${c.price.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} $US</span>
683      <span class="block text-[max(0.65625rem,10.5px)]" style="color:${up ? '#16a34a' : '#dc2626'}">${up ? '+' : ''}${(c.change24h || 0).toFixed(2)} %</span>
684    </span>
685    <button data-watchlist-star="${escHtml(c.id)}" class="shrink-0" style="color:var(--t-faint)" title="Suivre">${_icon('star', 'w-3.5 h-3.5')}</button>
686  </div>`
687}
688function renderFinanceSpace() {
689  const d = (STATE.spaceData || {}).espaceFinance || {}
690  const tab = STATE.financeTab || 'marches'
691  const real = tab === 'marches' || tab === 'crypto'
692  const marketSummary = (d.news || [])[0]
693  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
694    ${_spaceMenuBtn()}
695    <div class="flex-1 overflow-y-auto min-h-0">
696      <div class="w-full px-4 md:px-8 pt-3 pb-4 md:flex md:gap-8">
697        <div class="flex-1 min-w-0">
698          ${_spaceTitre('Finance', 'text-[max(0.9375rem,15px)] font-semibold', 'color:var(--t-primary)', { marge: 'mb-3' })}
699          <div class="flex gap-2 overflow-x-auto pb-3 mb-3" style="scrollbar-width:none">${FINANCE_TABS.map(t => `<button data-finance-tab="${t.id}" class="shrink-0 rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-medium" style="${tab === t.id ? 'background:var(--accent);color:white' : 'background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)'}">${t.label}</button>`).join('')}</div>
700          <!-- Le corps n'est plus conditionné au voyant global : chaque bloc
701               s'affiche dès QUE SA source a répondu. Tout masquer tant qu'une
702               seule source traînait faisait attendre quinze secondes des
703               cotations déjà arrivées en une demi-seconde. -->
704          ${real ? `
705            <!-- Le bandeau « les indices actions demandent une clé de
706                 fournisseur, pas encore configurée » a disparu : il est devenu
707                 faux. /api/bourse rend indices, entreprises et secteurs. -->
708            <div class="flex items-center justify-between mb-2">
709              <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">${tab === 'crypto' ? 'Cryptomonnaies' : 'Principaux actifs'}</p>
710              ${(() => {
711                const list = tab === 'crypto' ? (d.crypto || []).map(c => ({ variation: c.change24h })) : (d.indices || [])
712                const chiffres = list.map(x => x.variation).filter(Number.isFinite)
713                if (!chiffres.length) return ''
714                const moy = chiffres.reduce((a, b) => a + b, 0) / chiffres.length
715                return `<span class="text-[max(0.6875rem,11px)] font-medium" style="color:${moy >= 0 ? '#16a34a' : '#dc2626'}">Sentiment ${moy >= 0 ? 'haussier' : 'baissier'}</span>`
716              })()}
717            </div>
718            <div class="grid grid-cols-2 gap-3 mb-6">${tab === 'crypto'
719              ? _bourseBloc((d.crypto || []).slice(0, 6), _marketAssetCard, d.marketsError, d.loading && !(d.crypto || []).length, 'col
719-span-2')
720              : _bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')}</div>
721            ${tab !== 'crypto' ? `
722              <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Entreprises tendance</p>
723              <div class="rounded-2xl px-3 mb-6" style="background:var(--bg-secondary);border:1px solid var(--border)">
724                ${_bourseBloc(d.entreprises, _bourseLigne, d.bourseError, d.loading && !(d.entreprises || []).length, 'py-3')}
725              </div>
726              <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Secteurs d'actions</p>
727              <div class="rounded-2xl px-3.5 py-2 mb-6" style="background:var(--bg-secondary);border:1px solid var(--border)">
728                ${_bourseBloc(d.secteurs, _bourseSecteur, d.bourseError, d.loading && !(d.secteurs || []).length, 'py-3')}
729              </div>` : ''}
730            <div class="flex items-center justify-between mb-2">
731              <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">Résumé du marché</p>
732              ${d.at ? `<span class="text-[max(0.65625rem,10.5px)]" style="color:var(--t-faint)">Mis à jour ${escHtml(relTime(d.at))}</span>` : ''}
733            </div>
734            ${marketSummary ? `<a href="${escHtml(marketSummary.url)}" target="_blank" rel="noopener" class="block rounded-xl p-3.5 mb-2" style="background:var(--bg-secondary);border:1px solid var(--border)">
735              <p class="text-[max(0.84375rem,13.5px)] font-bold mb-1 leading-snug" style="color:var(--t-primary)">${escHtml(marketSummary.title)}</p>
736              <p class="text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(marketSummary.source || '')}</p>
737            </a>` : (d.newsError
738              ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Actualités indisponibles — ${escHtml(d.newsError)}. <button onclick="window.rechargerEspace('${escHtml(STATE.workMode)}')" style="color:var(--accent);text-decoration:underline">Réessayer</button></p>`
739              : `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Aucune actualité pour l'instant.</p>`)}
740          ` : ''}
741          ${!real ? `<div class="text-center py-16"><p class="text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">${escHtml(FINANCE_TABS.find(t => t.id === tab)?.label || '')} — bientôt disponible.</p></div>` : ''}
742        </div>
743        <aside class="w-full md:w-[260px] shrink-0 mt-6 md:mt-0">
744          <p class="flex items-center gap-1.5 text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">${_icon('star', 'w-3.5 h-3.5')}Créer une liste de suivi</p>
745          <div class="rounded-2xl px-3" style="background:var(--bg-secondary);border:1px solid var(--border)">
746            ${_bourseBloc(d.crypto, _watchlistRow, d.marketsError, d.loading && !(d.crypto || []).length, 'py-3')}
747          </div>
748        </aside>
749      </div>
750    </div>
751    <!-- BARRE DE QUESTION RETIRÉE ICI — même raison que dans Découvrir, et même
752         réversibilité : ces deux lignes remises, l'espace retrouve son chat.
753         CFO Personnel garde la sienne : c'est un espace de conseil où la
754         question EST le contenu, pas un tableau de marchés qu'elle recouvre. -->
755  </main>`
756}
757
758const CFO_TABS = [{ id: 'total', label: 'Total' }, { id: 'investissements', label: 'Investissements' }, { id: 'depenses', label: 'Dépenses' }, { id: 'passifs', label: 'Passifs' }]
759// LES COMPTES RÉELS, OU LA RAISON DE LEUR ABSENCE.
760//
761// L'écran disait « Aucun compte relié » sans jamais proposer d'en relier un, et
762// sans distinguer « pas encore branché chez l'hébergeur » de « aucun compte
763// connecté ». Deux situations très différentes : la première se règle avec une
764// clé, la seconde avec un clic.
765function _cfoComptes(d, tab) {
766  // Les opérations arrivent par un appel séparé, pour ne pas attendre les
767  // comptes : elles sont rangées à côté, pas dedans.
768  const b = { ...(d.banque || {}), operations: d.banqueOps || [] }
769  const cadre = (dedans) => `<div class="rounded-2xl p-8 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)">${dedans}</div>`
770
771  if (b.configure === false) {
772    return cadre(`<p class="mb-2" style="color:var(--t-faint)">${_icon('briefcase', 'w-8 h-8 mx-auto')}</p>
773      <p class="text-[max(0.84375rem,13.5px)] font-semibold mb-1" style="color:var(--t-primary)">Connexion bancaire pas encore activée</p>
774      <p class="text-[max(0.75rem,12px)] max-w-sm mx-auto" style="color:var(--t-dim)">L'agrégateur n'est pas configuré sur ce serveur. Une fois la clé posée, vos comptes se relient en un clic — vos identifiants bancaires restent chez votre banque, Créa ne les voit jamais.</p>`)
775  }
776  if (!(b.comptes || []).length) {
777    return cadre(`<p class="mb-2" style="color:var(--t-faint)">${_icon('briefcase', 'w-8 h-8 mx-auto')}</p>
778      <p class="text-[max(0.84375rem,13.5px)] font-semibold mb-1" style="color:var(--t-primary)">Aucun compte relié pour l'instant</p>
779      <p class="text-[max(0.75rem,12px)] max-w-sm mx-auto mb-3" style="color:var(--t-dim)">Reliez votre banque pour voir ici vos ${tab === 'investissements' ? 'investissements' : tab === 'depenses' ? 'dépenses' : tab === 'passifs' ? 'passifs' : 'finances'}. <strong>Vous vous identifiez chez votre banque</strong>, jamais dans Créa.</p>
780      <button onclick="window.cfoRelierBanque()" class="rounded-full px-4 py-2 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${b.ouverture ? 'Ouverture…' : 'Relier ma banque'}</button>
781      ${b.lien ? `<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Votre navigateur a bloqué la fenêtre. <a href="${escHtml(b.lien)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent);font-weight:600">Ouvrir la page de ma banque</a></p>` : ''}
782      ${d.banqueRelecture ? '<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Lecture de vos comptes…</p>'
783        : window._cfoGuetActif ? `<p class="text-[max(0.75rem,12px)] mt-3" style="color:var(--t-dim)">Terminez la connexion dans l'autre fenêtre. Vos comptes apparaîtront ici dès votre retour — <button onclick="window.cfoRafraichirBanque()" style="background:none;border:none;padding:0;color:var(--accent);font-weight:600;cursor:pointer">actualiser maintenant</button>.</p>` : ''}
784      ${b.raison ? `<p class="text-[max(0.71875rem,11.5px)] mt-3" style="color:#ef4444">${escHtml(b.raison)}</p>` : ''}`)
785  }
786
787  const somme = b.total == null
788    // Plusieurs devises : pas de total. Un nombre qui additionne des euros et
789    // des dollars ne veut rien dire, et l'afficher lui donnerait l'air d'en
790    // vouloir un.
791    ? '<span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">plusieurs devises — pas de total</span>'
792    : `${b.total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(b.devise || 'EUR')}`
793  return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
794      <p class="text-[max(0.71875rem,11.5px)] mb-1" style="color:var(--t-faint)">Total</p>
795      <p class="text-[max(1.625rem,26px)] font-bold leading-none mb-3" style="color:var(--t-primary)">${somme}</p>
796      <div class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">
797        ${b.comptes.map(c => `<div class="rounded-xl p-3" style="background:var(--bg-input);border:1px solid var(--border)">
798          <p class="text-[max(0.78125rem,12.5px)] font-semibold" style="color:var(--t-primary)">${escHtml(c.nom)}</p>
799          <p class="text-[max(0.6875rem,11px)] mb-1.5" style="color:var(--t-faint)">${escHtml(c.banque)}${c.iban ? ' · ••••' + escHtml(c.iban) : ''}</p>
800          <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">
800${(c.solde ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(c.devise)}</p>
801        </div>`).join('')}
802      </div>
803      <button onclick="window.cfoRelierBanque()" class="mt-3 text-[max(0.71875rem,11.5px)] font-medium" style="background:none;border:none;color:var(--accent);cursor:pointer">+ Relier une autre banque</button>
804      ${(d.banqueConnexions || []).length ? `<div class="mt-3 pt-3" style="border-top:1px solid var(--border)">
805        <p class="text-[max(0.6875rem,11px)] font-bold uppercase tracking-wide mb-1.5" style="color:var(--t-faint)">Banques reliées</p>
806        ${d.banqueConnexions.map(c => `<div class="flex items-center justify-between py-1.5">
807          <span class="text-[max(0.75rem,12px)] min-w-0" style="color:var(--t-secondary)">${escHtml(c.banque)}${c.aRenouveler ? ' <span style="color:#d97706">· connexion à renouveler</span>' : ''}</span>
808          <button onclick="window.cfoDelierBanque('${escHtml(c.id)}', '${escHtml(c.banque).replace(/'/g, '')}')" class="shrink-0 ml-3 text-[max(0.71875rem,11.5px)] font-medium" style="background:none;border:none;color:#ef4444;cursor:pointer">Délier</button>
809        </div>`).join('')}
810      </div>` : ''}
811    </div>
812    ${(b.operations || []).length ? `<p class="text-[max(0.78125rem,12.5px)] font-bold mb-2" style="color:var(--t-primary)">Dernières opérations</p>
813    <div class="flex flex-col gap-1">${b.operations.slice(0, 25).map(o => `<div class="flex items-center justify-between rounded-lg px-3 py-2 text-[max(0.78125rem,12.5px)]" style="background:var(--bg-secondary)">
814      <span style="color:var(--t-secondary)">${escHtml(o.libelle)}${o.enAttente ? ' <span style="color:var(--t-faint)">· à venir</span>' : ''}</span>
815      <span class="font-semibold shrink-0 ml-3" style="color:${o.montant < 0 ? 'var(--t-primary)' : '#059669'}">${(o.montant ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${escHtml(o.devise)}</span>
816    </div>`).join('')}</div>` : ''}`
817}
818
819// Ouvre la page de l'agrégateur. C'est LÀ que la personne s'identifie.
820//
821// EN VRAIE FENÊTRE, PAS DANS LE NAVIGATEUR INTERNE DE CRÉA — et c'est la seule
822// exception assumée à la règle « tout reste dans Créa ». Deux raisons, dans cet
823// ordre :
824//
825//   1. Bridge REFUSE d'être affiché dans un cadre. Vérifié sur la vraie page :
826//      `x-frame-options: SAMEORIGIN` et `frame-ancestors 'self'`. Le navigateur
827//      interne de Créa est un cadre : la page y restait blanche, et le bouton
828//      paraissait ne rien faire. C'était le défaut.
829//   2. Et ce refus est SAIN. Une page où l'on saisit ses identifiants bancaires
830//      doit être vue dans une vraie fenêtre, avec son adresse et son cadenas
831//      lisibles. L'afficher dans un cadre est exactement ce que fait une page
832//      d'hameçonnage. On ne contourne pas cette protection, on la respecte.
833window.cfoRelierBanque = async function () {
834  STATE.spaceData ||= {}
835  const maj = (m) => { STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), ...m } }; render() }
836  maj({ ouverture: true, raison: '' })
837  const jeton = (typeof backendToken === 'function' && backendToken()) || ''
838  try {
839    const r = await fetch('/api/banque/session', {
840      method: 'POST', credentials: 'include',
841      headers: { 'Content-Type': 'application/json', ...(jeton ? { Authorization: 'Bearer ' + jeton } : {}) },
842      body: '{}',
843    })
844    // Une session expirée rend 401 avec un corps qui n'a PAS la forme
845    // {ok, raison} : sans ce cas, l'écran affichait « raison inconnue » là où
846    // il suffisait de se reconnecter.
847    if (r.status === 401 || r.status === 403) {
848      maj({ ouverture: false, raison: 'Votre session a expiré. Reconnectez-vous, puis réessayez.' })
849      return
850    }
851    const d = await r.json().catch(() => null)
852    if (!d || !d.ok) { maj({ ouverture: false, raison: _cfoMotif(d && d.raison) }); return }
853
854    // Vraie fenêtre. `noopener` : la page ouverte ne doit pas garder la main
855    // sur celle de Créa.
856    const fenetre = window.open(d.url, '_blank', 'noopener,noreferrer')
857    // Armé AVANT même de savoir si la fenêtre s'est ouverte : que la personne
858    // passe par la fenêtre ou par le lien de repli, son retour doit relire les
859    // comptes.
860    _cfoGuetterRetour()
861    // Bloquée par le navigateur — le cas est fréquent et il était muet. On
862    // garde l'adresse et l'écran propose un vrai lien à cliquer : c'est un
863    // geste délibéré de la personne, que plus aucun bloqueur n'intercepte.
864    maj({ ouverture: false, lien: fenetre ? '' : d.url, raison: '' })
865  } catch (e) {
866    maj({ ouverture: false, raison: 'Connexion impossible : ' + e.message })
867  }
868}
869/**
870 * RELIRE LES COMPTES — le bouton « actualiser maintenant », et le retour.
871 *
872 * Cette fonction était APPELÉE à deux endroits et DÉFINIE nulle part :
873 * `_cfoGuetterRetour` la déclenche au retour de la page de la banque, et
874 * l'écran offre un bouton qui la nomme. Les deux levaient une erreur dans la
875 * console, et le geste ne faisait rien — ce qui est exactement le défaut que
876 * le guet existait pour éviter : « il faudrait recharger la page à la main
877 * pour voir ses comptes, et personne ne devine qu'il faut le faire ».
878 *
879 * On relit TOUT l'espace, avec `force` : les comptes, les opérations et les
880 * connexions viennent de trois routes, et une seule relue laisserait l'écran
881 * mi-frais mi-périmé.
882 */
883window.cfoRafraichirBanque = async function () {
884  STATE.spaceData ||= {}
885  STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banqueRelecture: true }
886  render()
887  try {
888    if (typeof _loadSpaceData === 'function') await _loadSpaceData('espaceCfo', true)
889  } finally {
890    STATE.spaceData.espaceCfo = { ...(STATE.spaceData.espaceCfo || {}), banqueRelecture: false }
891    render()
892  }
893}
894
895/**
896 * DÉLIER UNE BANQUE — « relier sans pouvoir délier, c'est retenir un
897 * consentement qu'on doit pouvoir reprendre ».
898 *
899 * La route existait côté serveur, le bouton existait à l'écran, et la fonction
900 * entre les deux n'a jamais été écrite : cliquer « Délier » ne faisait rien.
901 *
902 * ON DEMANDE CONFIRMATION, et pas par politesse : la déliaison est faite chez
903 * l'agrégateur, elle est irréversible, et il faut repasser par la banque pour
904 * revenir. Un clic malheureux dans une liste coûterait toute une reconnexion.
905 */
906window.cfoDelierBanque = async function (id, nom) {
907  const quelle = String(nom || 'cette banque')
908  if (!confirm(`Délier ${quelle} ?\n\nCréa n'aura plus accès à ces comptes. Pour revenir, il faudra vous identifier à nouveau chez votre banque.`)) return
909  const jeton = (typeof backendToken === 'function' && backendToken()) || ''
910  try {
911    const r = await fetch('/api/banque/connexion?id=' + encodeURIComponent(String(id || '')), {
912      method: 'DELETE',
913      credentials: 'include',
914      headers: jeton ? { Authorization: 'Bearer ' + jeton } : {},
915    })
916    const d = await r.json().catch(() => null)
917    if (!d || !d.ok) {
918      STATE.spaceData ||= {}
919      STATE.spaceData.espaceCfo = {
920        ...(STATE.spaceData.espaceCfo || {}),
921        banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), raison: _cfoMotif(d && d.raison) },
922      }
923      render()
924      return
925    }
926  } catch (e) {
927    STATE.spaceData ||= {}
928    STATE.spaceData.espaceCfo = {
929      ...(STATE.spaceData.espaceCfo || {}),
930      banque: { ...((STATE.spaceData.espaceCfo || {}).banque || {}), raison: 'Déliaison impossible : ' + e.message },
931    }
932    render()
933    return
934  }
935  await window.cfoRafraichirBanque()
936}
937
938function _cfoMotif(code) {
939  if (code === 'agregateur-non-configure') return 'La connexion bancaire n\'est pas encore activée sur ce serveur.'
940  if (code === 'session-sans-url') return 'L\'agrégateur n\'a pas rendu d\'adresse de connexion.'
941  return 'Connexion impossible : ' + String(code || 'raison inconnue')
942}
943
944function renderCfoSpace() {
945  const d = (STATE.spaceData || {}).espaceCfo || {}
946  const tab = STATE.cfoTab || 'total'
947  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
948    ${_spaceMenuBtn()}
949    <div class="flex-1 overflow-y-auto min-h-0">
950      <div class="w-full px-4 md:px-8 pt-3 pb-4 md:flex md:gap-8">
951        <div class="flex-1 min-w-0">
952          <div class="flex items-center justify-between mb-3">
953            ${_spaceTitre('Directeur financier personnel', 'text-[max(0.8125rem,13px)]', 'color:var(--t-dim)', { avant: _icon('eye', 'w-4 h-4') })}
954            <button class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Partager</button>
955          </div>
956          <div class="flex gap-4 mb-5">${CFO_TABS.map(t => `<button data-cfo-tab="${t.id}" class="text-[max(0.78125rem,12.5px)] font-medium pb-1.5" style="${tab === t.id ? 'border-bottom:2px solid var(--accent);color:var(--t-primary)' : 'color:var(--t-dim)'}">${t.label}</button>`).join('')}</div>
957          ${_cfoComptes(d, tab)}
958        </div>
959        <aside class="w-full md:w-[280px] shrink-0 mt-6 md:mt-0">
960          <p class="text-[max(0.78125rem,12.5px)] font-bold mb-2.5" style="color:var(--t-primary)">Explorez vos finances</p>
961          <div class="flex flex-wrap gap-1.5 mb-4">
962            ${[[_icon('wallet'), 'Dépenses'], [_icon('card'), 'Abonnements'], [_icon('chart'), 'Portefeuille']].map(([ic, lb]) => `<button data-cfo-quick="${escHtml(lb)}" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-medium" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${ic}${lb}</button>`).join('')}
963          </div>
964          <button data-cfo-quick="Top spending categories this month" class="flex items-center gap-1.5 mb-2 text-[max(0.71875rem,11.5px)] font-medium" style="color:var(--accent)">${_icon('chevronRight', 'w-3.5 h-3.5')}Catégories de dépenses du mois</button>
965          <button data-cfo-quick="Posez votre propre question" class="flex items-center gap-1.5 mb-4 text-[max(0.71875rem,11.5px)] font-medium" style="color:var(--accent)">${_icon('computer', 'w-3.5 h-3.5')}Posez votre propre question</button>
966          <div class="flex items-center gap-1.5 mb-2">
967            <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide" style="color:var(--t-faint)">Perspectives du marché</p>
968            ${_icon('chevronRight', 'w-3 h-3')}
969          </div>
970          <div class="grid grid-cols-2 gap-2 mb-5">
971            ${_bourseBloc(d.indices, _bourseCarte, d.bourseError, d.loading && !(d.indices || []).length, 'col-span-2')}
972          </div>
973          <p class="text-[max(0.75rem,12px)] font-bold uppercase tracking-wide mb-2" style="color:var(--t-faint)">Actualité économique</p>
974          <div class="space-y-2">${(d.news || []).slice(0, 3).map(a => `<a href="${escHtml(a.url)}" target="_blank" rel="noopener" class="block rounded-xl p-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
975            <p class="text-[max(0.71875rem,11.5px)] font-medium leading-snug" style="color:var(--t-primary)">${escHtml(a.title)}</p>
976          </a>`).join('') || '<p class="text-[max(0.71875rem,11.5px)]" style="color:var(--t-dim)">Indisponible.</p>'}</div>
977        </aside>
978      </div>
979    </div>
980    <!-- ── LA BARRE DE QUESTIONS EST PARTIE, COMME CHEZ DÉCOUVRIR ET FINANCE ──
981         « Dans directeur financier personnel, tu as mis une barre de questions
982          en plein milieu, il faut la supprimer. »
983
984         MESURÉ, sur une hauteur de portable (813 px) : le bloc « suggestions
985         pour vous » + la barre occupaient 300 px — 41 % de l'écran — dans un
986         espace dont le contenu EST le sujet. C'est le même défaut, et la même
987         proportion, que celui qui avait déjà fait retirer la barre de Découvrir
988         et de Finance.
989
990         LES QUATRE AUTRES DÉCLARATIONS RESTENT (suggestions, prompt système,
991         profil d'outils, inscription à la relance) : le rétablissement tient en
992         deux lignes, et boucle-partout-smoke vérifie qu'il n'en reste pas un
993         morceau à moitié câblé — un champ sans bouton prendrait la place sans
994         rien faire. Rétablir = retirer espaceCfo de SANS_BARRE_POUR_L_INSTANT
995         et remettre les deux lignes ci-dessous. -->
996  </main>`
997}
998
999/* CINQ PASTILLES EN ANGLAIS DANS UN PRODUIT FRANÇAIS. Leur libellé n'est pas
1000   qu'un libellé : il PART comme requête de recherche (`_runSpaceSearch(sid,
1001   label + …)`). « Humanities » cherchait donc en anglais, dans un espace dont
1002   tout le reste — titre, invite, sujets tendance — est en français. */
1003const EDUCATION_PILLS = [{ label: 'Santé', icon: _icon('health') }, { label: 'Droit', icon: _icon('law') }, { label: 'Technologie', icon: _icon('computer') }, { label: 'Sciences', icon: _icon('science') }, { label: 'Sciences humaines', icon: _icon('humanities') }]
1004const EDUCATION_TOPICS = [
1005  { c1: '#2563eb', c2: '#7c3aed', q: 'dernières avancées technologie et sciences', label: 'Technologie & sciences', img: 'abstract digital illustration, glowing blue and orange particle network explosion, futuristic technology editorial art', seed: 11 },
1006  { c1: '#f97316', c2: '#dc2626', q: 'actualité sciences politiques et économie', label: 'Sciences politiques & économie', img: 'abstract editorial illustration, two hands reaching toward each other, geometric shapes, orange and dark red color palette', seed: 12 },
1007  { c1: '#eab308', c2: '#f59e0b', q: 'recherche académique finance et marchés', label: 'Finance académique', img: 'abstract editorial illustration, stacked gold coins, geometric triangle shapes, blue and gold color palette', seed: 13 },
1008]
1009function renderEducationSpace() {
1010  const d = (STATE.spaceSearch || {}).espaceEducation || {}
1011  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
1012    ${_spaceMenuBtn()}
1013    <div class="flex-1 overflow-y-auto min-h-0">
1014      <div class="max-w-3xl mx-auto px-4 pt-12 pb-6">
1015        ${_spaceTitre('Éducation', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })}
1016        <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceEducation', sendId: 'btn-space-send-espaceEducation', micId: 'btn-space-mic-espaceEducation', placeholder: 'Explorer des articles académiques, des revues et plus encore', rows: 1 })}</div>
1017        ${_spaceChatArea('espaceEducation', SPACE_SUGGESTIONS.espaceEducation, true)}
1018        <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${EDUCATION_PILLS.map(p => `<button data-space-pill-espaceEducation="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div>
1019        ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Recherche…</p>` :
1020          d.results ? _searchResultsList(d.results, 'Aucun résultat.') : `
1021          <div class="flex items-center justify-between mb-4">
1022            <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Sujets tendance</p>
1023            <button data-space-scroll="edu-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button>
1024          </div>
1025          <div id="edu-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${EDUCATION_TOPICS.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceEducation="${escHtml(t.q)}"`, t.seed)).join('')}</div>
1026        `}
1027      </div>
1028    </div>
1029  </main>`
1030}
1031
1032/* ══════════════════════════════════════════════════════════════════════════
1033 * CRÉA ACADÉMIE — DEUX FAÇONS D'APPRENDRE, ET ELLES NE SE RESSEMBLENT PAS
1034 * ══════════════════════════════════════════════════════════════════════════
1035 *
1036 * « On va créer Créa Académie, et on va la séparer en sections : d'un côté la
1037 *   partie tutoriel, de l'autre la partie éducation. Ça existait déjà, c'était
1038 *   juste mal structuré — c'est le studio éducation. »
1039 *
1040 * ── CE QUI EXISTAIT, ET POURQUOI ON NE LE TROUVAIT PAS ────────────────────
1041 *
1042 * APPRENDRE UNE MATIÈRE existait, et à TROIS endroits qui ne se parlaient pas :
1043 *
1044 *   · `renderEducationSpace()` — une recherche académique, cinq pastilles
1045 *     écrites en anglais dans un produit français, trois sujets tendance.
1046 *     Aucun lien vers la suite ;
1047 *   · le Studio Éducation (le tronc) — quatorze matières, leur
1047s thèmes,
1048 *     quatre modes de séance, un score qui se tient. Joignable seulement par
1049 *     la liste des studios, entre « Marketing » et « Data ». Il est
1050 *     SUPPRIMÉ depuis : le programme est ici, une seule fois ;
1051 *   · `STUDIOS.edu` — le lieu et ses contrats de sortie, qui ne montaient
1052 *     jamais dans la séance : elle écrivait sa propre professeure à la main.
1053 *
1054 * Trois portes pour une chose, et aucune qui mène aux deux autres.
1055 *
1056 * APPRENDRE CRÉA n'existait nulle part. Zéro tutoriel dans le dépôt : on
1057 * découvrait les quatorze studios en cliquant dessus. `grep -rn "tutoriel"
1058 * src/` ne rendait que des expressions régulières qui REPÈRENT le mot dans
1059 * une demande.
1060 *
1061 * ── LA FORME VIENT DES CAPTURES, PAS DE MOI ───────────────────────────────
1062 *
1063 * Quatre rangées, dans cet ordre, et une page par rôle :
1064 *
1065 *     Cours en vedette      des parcours en plusieurs leçons
1066 *     Prise en main         de courts tutoriels, quelques minutes chacun
1067 *     Parcourir par rôle    onze métiers, chacun sa page
1068 *     Planifier un bilan    une séance récurrente, posée dans le chat
1069 *
1070 *     page d'un rôle →      Essayer un prompt · Cas d'usage
1071 *
1072 * ── UN PROMPT S'OUVRE EN BROUILLON, IL NE PART PAS ────────────────────────
1073 *
1074 * Chaque carte de prompt porte `data-studio-template-card` et
1075 * `data-template-exemple` : c'est le geste QUI EXISTE DÉJÀ (voir `bindEvents`,
1076 * autour de `studioLancerModele`). Il ouvre le lieu visé, crée le projet de
1077 * code s'il en manque un, ouvre la matière que vise un modèle Éducation, et
1078 * ÉCRIT le texte dans la barre sans l'envoyer. Poser ici un second
1079 * gestionnaire aurait donné deux comportements pour un seul geste — le canal
1080 * parallèle que ce dépôt passe son temps à défaire.
1081 *
1082 * ── CE QUE CETTE ACADÉMIE EST, ET CE QU'ELLE N'EST PAS ────────────────────
1083 *
1084 * C'est UNE PORTE, pas un quatrième contenu. Elle ne recopie ni les matières
1085 * (elle a son propre programme, `src/crea-ecole.js`, depuis que la table du
1086 * studio est partie avec lui), ni le métier d'un lieu (elle renvoie au lieu,
1087 * qui porte déjà le sien). Et elle ne se donne PAS d'identité
1088 * (règle 1) : son prompt dit d'OÙ elle parle, jamais QUI elle est.
1089 *
1090 * ── LE MODE D'ÉCHEC QU'ELLE POURRAIT AVOIR ────────────────────────────────
1091 *
1092 * Une carte qui n'ouvre rien. `openModeTab()` d'un mode inconnu SORT À SA
1093 * PREMIÈRE LIGNE (`if (!m) return`) — pas d'erreur, pas de message, un clic
1094 * mort ; `data-studio-template-card` a le même silence
1095 * (`if (!mode || !exemple) return`). `tests/academie-smoke.mjs` tient chaque
1096 * cible : tout lieu nommé ici est un mode que `render()` sait dessiner.
1097 * ══════════════════════════════════════════════════════════════════════════ */
1098
1099const ACADEMIE_SECTIONS = [
1100  { id: 'tutoriels', label: 'Tutoriels', quoi: 'Se servir de CREA' },
1101  { id: 'education', label: 'École', quoi: 'Apprendre une matière' },
1102]
1103
1104/* ── LES COURS EN VEDETTE ET LES QUATRE FICHES SONT PARTIS AVEC CE QU'ILS
1105 *    DOUBLAIENT ─────────────────────────────────────────────────────────────
1106 *
1107 * `ACADEMIE_PARCOURS` annonçait deux « cours » de quatre leçons écrites en une
1108 * ligne chacune, et `ACADEMIE_TUTOS` quatre fiches à plat. Les deux vivaient
1109 * du temps où l'Académie n'avait rien d'autre.
1110 *
1111 * Aujourd'hui l'École porte 188 cours et 1 139 leçons, et les tutoriels ont
1112 * leur propre colonne — 8 domaines, 14 parcours, 25 tutoriels, 82 étapes
1113 * (src/crea-tutoriels.js). Garder les six anciennes cartes à côté, c'était
1114 * proposer deux fois la même chose à deux profondeurs différentes, et laisser
1115 * la moins bonne en vitrine.
1116 *
1117 * Le catalogue lit maintenant la vraie table : ses lignes « tutoriels » sont
1118 * les 25 tutoriels, désignés par leur clé `parcours/tutoriel` — celle que
1119 * l'écran du tutoriel relit, pas une seconde forme d'identifiant.
1120 */
1121
1122/* ── PARCOURIR PAR RÔLE ───────────────────────────────────────────────────
1123 * Chaque rôle a SA page : des prompts qui s'ouvrent en brouillon là où ils
1124 * servent, et des cas d'usage qui disent le chemin étape par étape.
1125 * `ou` est un lieu que `render()` sait dessiner — la garde le vérifie. */
1126const ACADEMIE_ROLES = [
1127  { id: 'marketing', label: 'Marketing', icone: 'megaphone',
1128    prompts: [
1129      { ou: 'marketing', titre: 'Écrire un post LinkedIn', quoi: "Un post qui tient en 150 à 300 mots, avec un seul appel à l'action.", exemple: "Rédige un post LinkedIn qui annonce le lancement de notre nouvelle offre, ton direct, un seul appel à l'action." },
1130      { ou: 'marketing', titre: 'Préparer une relance e-mail', quoi: 'Objet sous 50 caractères, une idée par paragraphe.', exemple: "Prépare-moi un e-mail de relance pour des prospects qui n'ont pas répondu depuis trois semaines." },
1131      { ou: 'chat', titre: 'Trouver les angles', quoi: 'Explorer avant de rédiger, dans le chat.', exemple: "Donne-moi cinq angles différents pour parler de notre produit à des indépendants, et dis lequel tu défends." },
1132    ],
1133  },
1134  { id: 'ventes', label: 'Ventes', icone: 'briefcase',
1135    prompts: [
1136      { ou: 'doc', titre: 'Relancer un prospect', quoi: 'Quelques relances écrites pour un prospect devenu silencieux.', exemple: "Rédige trois relances espacées pour un prospect qui ne répond plus depuis un mois, de plus en plus courtes." },
1137      { ou: 'business', titre: 'Qualifier une opportunité', quoi: "Une grille, et une décision au bout — pas un avis.", exemple: "Analyse cette opportunité et décide de la prochaine étape avec une grille de qualification explicite." },
1138      { ou: 'slides', titre: 'Monter un argumentaire', quoi: 'Où on gagne, où on est exposé, quoi dire.', exemple: "Monte-moi un argumentaire de vente en dix diapos face à un concurrent moins cher." },
1139    ],
1140  },
1141  { id: 'produit', label: 'Produit', icone: 'map',
1142    prompts: [
1143      { ou: 'slides', titre: 'Présenter une feuille de route', quoi: 'Une idée par diapo, une demande chiffrée à la fin.', exemple: "Prépare une présentation de feuille de route produit sur deux trimestres, dix diapos." },
1144      { ou: 'business', titre: 'Cadrer une décision', quoi: 'Les options, ce qu’elles coûtent, celle qu’on défend.', exemple: "Cadre-moi la décision « construire ou acheter » pour notre brique de facturation." },
1145      { ou: 'chat', titre: 'Faire parler les retours', quoi: 'Ce que les retours disent vraiment, sans les lisser.', exemple: "Voici des retours utilisateurs : quels s
1145ont les trois problèmes de fond, et lequel est le plus coûteux à ignorer ?" },
1146    ],
1147  },
1148  { id: 'ingenieur', label: 'Ingénieur', icone: 'code',
1149    prompts: [
1150      { ou: 'code', titre: 'Écrire un test qui casse', quoi: "Un test qu'on n'a pas vu échouer ne prouve rien.", exemple: "Écris-moi un test qui échoue sur ce défaut AVANT le correctif, puis corrige." },
1151      { ou: 'code', titre: 'Regarder pourquoi c’est lent', quoi: 'On mesure avant d’optimiser.', exemple: "Cette page est lente : mesure d'abord, dis-moi où part le temps, et ne propose une optimisation qu'après." },
1152      { ou: 'labs', titre: 'Prototyper une idée', quoi: 'Une simulation, un tracé, un mini-jeu qu’on regarde tourner.', exemple: "Fais-moi une simulation interactive de propagation d'une file d'attente, et montre-la moi." },
1153    ],
1154  },
1155  { id: 'conception', label: 'Conception', icone: 'palette',
1156    prompts: [
1157      { ou: 'design', titre: 'Créer un logo', quoi: 'Il se juge à 16 pixels et en noir et blanc.', exemple: "Crée-moi un logo simple pour une brasserie artisanale, lisible en tout petit." },
1158      { ou: 'design', titre: 'Décliner une identité', quoi: 'Couleurs, typographies, usages.', exemple: "Décline une identité visuelle complète à partir de ce logo : couleurs, typographies, exemples d'usage." },
1159      { ou: 'web', titre: 'Maquetter une page', quoi: 'Une page qui tient dans un seul fichier.', exemple: "Maquette une landing page avec un seul objectif et un seul bouton, mobile d'abord." },
1160    ],
1161  },
1162  { id: 'finance', label: 'Finance', icone: 'wallet',
1163    prompts: [
1164      { ou: 'business', titre: 'Bâtir un modèle', quoi: 'Hypothèses nommées, chiffres qui se recalculent.', exemple: "Construis un modèle financier sur trois ans pour un service par abonnement, hypothèses écrites en clair." },
1165      { ou: 'data', titre: 'Lire des chiffres', quoi: 'Le code tourne vraiment, avec pandas.', exemple: "Analyse ce tableau de dépenses et dis-moi ce qui a bougé, avec un graphique." },
1166      { ou: 'espaceCfo', titre: 'Mettre de l’ordre chez soi', quoi: 'Budget, méthode, sans chiffres inventés.', exemple: "Comment établir un budget mensuel qui tienne, avec la règle 50/30/20 ?" },
1167    ],
1168  },
1169  { id: 'juridique', label: 'Juridique', icone: 'law',
1170    prompts: [
1171      { ou: 'doc', titre: 'Un modèle de contrat', quoi: 'Obligations réciproques, clauses numérotées, variables entre crochets.', exemple: "Rédige un modèle de contrat de prestation, clauses numérotées, variables entre crochets." },
1172      { ou: 'espaceJuridique', titre: 'Comprendre une règle', quoi: "Comprendre, pas trancher — ce n'est pas un conseil.", exemple: "Explique-moi le RGPD simplement, et dis-moi ce que je dois vérifier chez moi." },
1173      { ou: 'chat', titre: 'Préparer une question d’avocat', quoi: 'Arriver avec le dossier fait.', exemple: "Aide-moi à préparer les questions précises à poser à un avocat sur ce litige." },
1174    ],
1175  },
1176  { id: 'operations', label: 'Opérations', icone: 'gear',
1177    prompts: [
1178      { ou: 'coworker', titre: 'Prioriser sa semaine', quoi: 'Par conséquence si on ne fait rien.', exemple: "Je suis débordé : aide-moi à prioriser cette liste par conséquence si je ne fais rien." },
1179      { ou: 'dispatch', titre: 'Découper une grosse mission', quoi: 'Un plan, puis des tâches qui tournent en parallèle.', exemple: "Découpe la préparation de notre salon professionnel en tâches, et dis qui fait quoi." },
1180      { ou: 'automate', titre: 'Automatiser un geste répété', quoi: 'Une automatisation par surface, pas deux.', exemple: "Quel geste je répète chaque semaine pourrait être automatisé, et comment on le poserait ?" },
1181    ],
1182  },
1183  { id: 'rh', label: 'Ressources humaines', icone: 'people',
1184    prompts: [
1185      { ou: 'doc', titre: 'Une fiche de poste', quoi: 'Ce que la personne fera, pas ce qu’elle aura lu.', exemple: "Rédige une fiche de poste pour un chargé de projet : missions, résultats attendus, critères d'évaluation." },
1186      { ou: 'doc', titre: 'Un livret d’accueil', quoi: 'Ce qu’on aurait aimé savoir le premier jour.', exemple: "Écris un livret d'accueil pour une nouvelle recrue, une page par semaine du premier mois." },
1187      { ou: 'doc', titre: 'Un module de formation interne', quoi: 'Objectifs observables, évaluation qui les mesure.', exemple: "Construis un module de formation interne sur la sécurité au poste de travail, avec un quiz final." },
1188    ],
1189  },
1190  { id: 'recherche', label: 'Recherche', icone: 'loupe',
1191    prompts: [
1192      { ou: 'espaceDecouvrir', titre: 'Suivre un sujet', quoi: "
1192Le Fil va lire, il ne récite pas de mémoire.", exemple: "Fais le point sur ce qui a bougé cette semaine sur mon sujet, avec les sources." },
1193      { ou: 'espaceEducation', titre: 'Chercher des travaux', quoi: 'Articles, revues, sujets de fond.', exemple: "Trouve-moi des travaux récents sur l'apprentissage par la pratique, et résume-les." },
1194      { ou: 'chat', titre: 'Recouper deux sources', quoi: "Ce qui n'est pas recoupé se dit comme tel.", exemple: "Voici deux articles qui se contredisent : dis-moi où ils divergent et ce qu'on peut affirmer." },
1195    ],
1196  },
1197  { id: 'donnees', label: 'Données', icone: 'chart',
1198    prompts: [
1199      { ou: 'data', titre: 'Nettoyer un jeu de données', quoi: 'Ce qu’on jette se compte et se dit.', exemple: "Nettoie ce jeu de données et dis-moi exactement ce que tu as retiré et pourquoi." },
1200      { ou: 'data', titre: 'Tracer un graphique', quoi: 'Un graphique, pas un tableau de bord — on en demande un.', exemple: "Trace-moi un graphique de l'évolution mensuelle, et explique ce qu'il montre." },
1201      { ou: 'data', titre: 'Monter un tableau de bord', quoi: 'Les indicateurs qui décident, pas tous les chiffres.', exemple: "Monte-moi un tableau de bord avec les quatre indicateurs qui comptent pour cette activité." },
1202    ],
1203  },
1204]
1205
1206/* ── LES CAS D'USAGE — UNE TABLE À PART, ET C'EST VOULU ───────────────────
1207 *
1208 * Ils étaient d'abord imbriqués dans chaque rôle. Ça marchait pour la page
1209 * d'un rôle, et pour RIEN d'autre : « Toutes les ressources » doit pouvoir les
1210 * lister tous, les compter, les filtrer par rôle ET par lieu. Une donnée qu'on
1211 * ne peut lire que par une seule entrée finit toujours par être recopiée à la
1212 * seconde — c'est le défaut que cette Académie existe pour refermer.
1213 *
1214 * `kind` est la clé de `renderTemplateThumb` : l'ESQUISSE du genre produit,
1215 * celle des pages de studio. Elle dit ce que le cas FABRIQUE. Pas de photo :
1216 * aucune banque n'a d'image de « préparer un appel de vente », et une vignette
1217 * qui ne répond pas laisserait un trou gris.
1218 * `ou` est le lieu où le cas se pratique — la garde vérifie qu'il existe. */
1219const ACADEMIE_CAS = [
1220  { id: 'campagne-bout-en-bout', role: 'marketing', ou: 'marketing', minutes: 20, kind: 'email', titre: 'Une campagne de bout en bout',
1221    quoi: 'Du positionnement aux visuels, en passant par les textes.',
1222    etapes: ['Poser le positionnement et la cible dans Marketing.', 'Faire écrire les textes — post, e-mail, page.', 'Passer à Iris pour les visuels, avec le même positionnement.', 'Ranger le tout dans les fichiers du projet.'] },
1223  { id: 'calendrier-editorial', role: 'marketing', ou: 'coworker', minutes: 12, kind: 'list', titre: 'Tenir un calendrier éditorial',
1224    quoi: 'Ce qui part quand, et ce qui reste à écrire.',
1225    etapes: ['Lister les sujets et les dates au Coworker.', 'Faire rédiger au fil de l’eau dans Marketing.', 'Poser les rappels une semaine avant chaque date.'] },
1226  { id: 'compte-rendu-appel', role: 'ventes', ou: 'doc', minutes: 10, kind: 'doc', titre: "D'un compte rendu d'appel au suivi",
1227    quoi: 'Transformer des notes brutes en actions datées.',
1228    etapes: ['Coller le compte rendu dans le Studio Doc.', 'Faire extraire les décisions et les prochaines étapes.', 'Poser les échéances avec le Coworker.'] },
1229  { id: 'argumentaire-concurrent', role: 'ventes', ou: 'slides', minutes: 15, kind: 'slides', titre: 'Se préparer face à un concurrent',
1230    quoi: 'Où on gagne, où on est exposé, quoi dire.',
1231    etapes: ['Rassembler ce qu’on sait du concurrent dans le chat.', 'Monter l’argumentaire en dix diapos.', 'Garder une diapo pour les objections les plus fréquentes.'] },
1232  { id: 'intuition-a-spec', role: 'produit', ou: 'doc', minutes: 18, kind: 'doc', titre: "D'une intuition à une spécification",
1233    quoi: 'Passer d’une idée à quelque chose qu’on peut construire.',
1234    etapes: ['Explorer dans le chat, sans format imposé.', 'Écrire la spécification dans le Studio Doc.', 'En tirer une présentation pour la décision.'] },
1235  { id: 'revue-de-retours', role: 'produit', ou: 'data', minutes: 15, kind: 'chart', titre: 'Faire parler une masse de retours',
1236    quoi: 'Ce que les retours disent vraiment, sans les lisser.',
1237    etapes: ['Charger les retours dans Insight.', 'Faire regrouper par problème, pas par mot-clé.', 'Trancher ce qui coûte le plus cher à ignorer.'] },
1238  { id: 'correctif-qui-tient', role: 'ingenieur', ou: 'code', minutes: 20, kind: 'code', titre: 'Un correctif qui tient',
1239    quoi: 'Reproduire, prouver, corriger, revérifier.',
1240    etapes: ['Reproduire le défaut dans Codao.', 'Écrire le test qui échoue AVANT le correctif.', 'Corriger, puis relancer l’aperçu pour voir la page tourner.'] },
1241  { id: 'lecture-de-depot', role: 'ingenieur', ou: 'code', minutes: 15, kind: 'list', titre: 'Comprendre un dépôt qu’on ne connaît pas',
1242    quoi: 'Par où entrer quand tout est nouveau.',
1243    etapes: ['Faire lister les points d’entrée réels, pas l’arborescence.', 'Faire suivre un chemin de bout en bout.', 'Écrire ce qu’on a compris, pour la prochaine fois.'] },
1244  { id: 'marque-vers-site', role: 'conception', ou: 'web', minutes: 20, kind: 'web', titre: "D'une marque à un site",
1245    quoi: 'Garder la même identité d’un lieu à l’autre.',
1246    etapes: ['Poser l’identité dans Iris.', 'La reprendre dans le Studio No Code pour la page.', 'Regarder la page tourner dans l’aperçu.'] },
1247  { id: 'jeu-de-visuels', role: 'conception', ou: 'design', minutes: 12, kind: 'image', titre: 'Décliner un visuel en série',
1248    quoi: 'Le même message, aux bons formats.',
1249    etapes: ['Fixer le visuel de référence dans Iris.', 'Décliner aux formats voulus.', 'Ranger la série dans la médiathèque.'] },
1250  { id: 'revue-tresorerie', role: 'finance', ou: 'data', minutes: 18, kind: 'chart', titre: 'Une revue de trésorerie',
1251    quoi: 'Des chiffres bruts à une décision.',
1252    etapes: ['Charger les données dans Insight.', 'Faire calculer, pas raconter.', 'Porter la conclusion en première ligne dans le Studio Doc.'] },
1253  { id: 'modele-trois-ans', role: 'finance', ou: 'business', minutes: 25, kind: 'chart', titre: 'Un modèle à trois ans',
1254    quoi: 'Des hypothèses qu’on peut discuter une par une.',
1255    etapes: ['Nommer les hypothèses dans Business.', 'Faire recalculer à chaque changement.', 'Sortir la version investisseurs en diapos.'] },
1256  { id: 'relire-un-contrat', role: 'juridique', ou: 'doc', minutes: 15, kind: 'letter', titre: 'Relire un contrat reçu',
1257    quoi: 'Repérer ce qui engage, avant de signer.',
1258    etapes: ['Joindre le contrat dans le chat.', 'Faire ressortir les obligations et les échéances.', 'Écrire la liste des questions dans le Studio Doc.'] },
1259  { id: 'procedure-ecrite', role: 'operations', ou: 'coworker', minutes: 12, kind: 'list', titre: 'Une procédure écrite une fois',
1260    quoi: 'Ce qui se répète se documente.',
1261    etapes: ['Décrire le geste au Coworker.', 'Faire écrire la procédure dans le Studio Doc.', 'Poser le rappel récurrent.'] },
1262  { id: 'mission-decoupee', role: 'operations', ou: 'dispatch', minutes: 15, kind: 'list', titre: 'Une grosse mission découpée',
1263    quoi: 'Un plan, puis des tâches qui tournent en parallèle.',
1264    etapes: ['Décrire la mission au Dispatch.', 'Relire le découpage avant de lancer.', 'Suivre les tâches de fond pendant qu’on fait autre chose.'] },
1265  { id: 'arrivee-recrue', role: 'rh', ou: 'doc', minutes: 20, kind: 'doc', titre: "L'arrivée d'une nouvelle personne",
1266    quoi: 'Du recrutement au premier mois.',
1267    etapes: ['Écrire la fiche de poste dans le Studio Doc.', 'Préparer le livret d’accueil.', 'Monter la formation dans le Studio Éducation.'] },
1268  { id: 'note-de-synthese', role: 'recherche', ou: 'doc', minutes: 18, kind: 'doc', titre: 'Une note de synthèse',
1269    quoi: 'La conclusion se lit en premier.',
1270    etapes: ['Rassembler les sources depuis Le Fil.', 'Faire recouper dans le chat.', 'Écrire la note dans le Studio Doc, conclusion en tête.'] },
1271  { id: 'veille-hebdo', role: 'recherche', ou: 'espaceDecouvrir', minutes: 10, kind: 'list', titre: 'Une veille qui tient dans la semaine',
1272    quoi: 'Ce qui a bougé, et ce qui n’est pas confirmé.',
1273    etapes: ['Poser les sujets suivis dans Le Fil.', 'Faire distinguer le confirmé du rapporté.', 'Garder la note de la semaine dans les fichiers du projet.'] },
1274  { id: 'export-vers-decision', role: 'donnees', ou: 'data', minutes: 20, kind: 'chart', titre: "D'un export brut à une décision",
1275    quoi: 'Charger, nettoyer, calculer, conclure.',
1276    etapes: ['Charger l’export dans Insight.', 'Nettoyer en comptant ce qui est jeté.', 'Calculer, tracer, puis écrire la conclusion.'] },
1277  { id: 'tableau-de-bord', role: 'donnees', ou: 'data', minutes: 15, kind: 'chart', titre: 'Un tableau de bord qui sert',
1278    quoi: 'Les indicateurs qui décident, pas tous les chiffres.',
1279    etapes: ['Choisir quatre indicateurs, pas quinze.', 'Faire monter le tableau de bord.', 'Le relire dans un mois : ce qui n’a jamais servi s’enlève.'] },
1280]
1281
1282/* ── PLANIFIER UN BILAN D'APPRENTISSAGE ───────────────────────────────────
1283 * Le bouton n'invente pas une machine : il ouvre le chat avec la demande
1284 * ÉCRITE EN BROUILLON, et c'est CREA qui pose le rappel récurrent avec les
1285 * outils qu'elle a. Un bouton qui promettrait une planification que rien
1286 * n'exécute serait pire qu'une absence de bouton. */
1287const ACADEMIE_BILAN = "Je veux faire un bilan d'apprentissage régulier avec toi. Demande-moi ce que je cherche à améliorer et à quelle fréquence je veux faire le point, propose un jour et une heure, et attends ma confirmation avant de créer quoi que ce soit. Une fois que j'aurai confirmé, pose un seul rappel récurrent. À chaque fois, demande-moi ce que j'ai essayé depuis la dernière fois, entraîne-moi sur une nouveauté, et suggère quoi essayer ensuite."
1288
1289/* `ACADEMIE_MODES_SEANCE` EST PARTIE AVEC LA SÉANCE QU'ELLE PROPOSAIT.
1290   Quatre modes — quiz adaptatif, examen blanc, cours, cartes mémoire — que
1291   l'Académie offrait et que le Studio Éducation tenait. Le studio est
1292   supprimé ; plus personne n'appelait `window.academieModeSeance` depuis que
1293   l'École a ses cinq écrans, et la table ne servait plus qu'à valider un
1294   identifiant que rien n'écrivait. */
1295
1296/* ══════════════════════════════════════════════════════════════════════════
1297 * LES CINQ VUES DE L'ACADÉMIE, ET CE QUI LES RELIE
1298 * ══════════════════════════════════════════════════════════════════════════
1299 *
1300 *   Aperçu          cours en vedette · prise en main · rôles · bilan
1301 *   Rôle            essayer un prompt · cas d'usage
1302 *   Lieu            commencer ici · essayer une tâche · tout
1303 *   Ressources      onglets · recherche · filtre par lieu · compteur
1304 *   Éducation       les modes de séance · les matières
1305 *
1306 * ── IL N'Y A QU'UN CATALOGUE ─────────────────────────────────────────────
1307 *
1308 * Les quatre premières vues ne portent AUCUN contenu à elles : elles filtrent
1309 * `_academieCatalogue()`, qui met les cours, les tutoriels et les cas d'usage
1310 * sous une forme commune. Un « Cours » vu depuis un rôle, depuis un lieu ou
1311 * depuis « Toutes les ressources » est le MÊME objet — sinon le compteur d'un
1312 * écran finirait par contredire la liste d'un autre, et c'est le genre de
1313 * désaccord qu'on ne voit jamais parce qu'on ne regarde pas les deux en même
1314 * temps.
1315 *
1316 * ── LA LISTE DES LIEUX EST DÉRIVÉE, PAS ÉCRITE ───────────────────────────
1317 *
1318 * `_academieLieux()` relève les lieux RÉELLEMENT cités par les ressources. Une
1319 * table écrite à la main aurait un jour un lieu sans ressource — une entrée de
1320 * filtre qui ne rend rien — ou une ressource dans un lieu absent du filtre,
1321 * donc introuvable. Les deux sont invisibles à la relecture.
1322 * ══════════════════════════════════════════════════════════════════════════ */
1323
1324/* LA PORTE VERS UN LIEU. `openModeTab` d'un mode inconnu sort à sa première
1325   ligne, en silence : on ne l'appelle donc que sur ce que les tables
1326   déclarent, et la garde vérifie que ce qu'elles déclarent existe. */
1327window.academieOuvrir = function (cible) {
1328  if (!cible) return
1329  if (String(cible).startsWith('espace')) { openSpace(cible); return }
1330  /* LA LISTE DES STUDIOS N'EST PAS UN MODE — c'est une barre. `openModeTab`
1331     d'un mode inconnu sort à sa première ligne : le tutoriel « Un studio est
1332     un LIEU » aurait porté un bouton MUET, ce que rien ne signale.
1333     On OUVRE, on ne bascule pas : `montrerLaBarre` refermerait la barre déjà
1334     posée, et un bouton nommé « Voir les studios » qui les cache une fois sur
1335     deux est pire qu'un bouton mort — on croit avoir raté son clic. */
1336  /* L'ÉCOLE N'EST PAS UN MODE NON PLUS — c'est une SECTION de cet espace-ci.
1337     Elle était atteinte par « Ouvrir le Studio Éducation », qui menait à un
1338     studio maintenant supprimé : le clic aurait été MUET, ce que rien ne
1339     signale. On y va donc par la porte de la section. */
1340  if (cible === 'ecole') {
1341    window.academieSection('education')
1342    return
1343  }
1344  if (cible === 'studios') {
1345    closeAllNavSheets()
1346    STATE.studiosSheetOpen = true
1347    STATE.barreMonde = 'studios'
1348    STATE.barreFermeeParUsager = false
1349    save(); render()
1350    return
1351  }
1352  openModeTab(cible)
1353}
1354
1355/* LA PORTE VERS UNE MATIÈRE EST PARTIE AVEC CE QU'ELLE OUVRAIT.
1356   `window.academieOuvrirMatiere` posait une matière d'`EDU_DOMAINS` et ouvrait
1357   le Studio Éducation — c'était le clic qui, depuis « Code de la route »,
1358   atterrissait sur une barre de chat. L'École a ses cinq écrans, et le studio
1359   n'a plus de catalogue : plus rien n'appelait cette porte.
1360   La laisser aurait laissé un chemin vivant vers l'écran qu'on vient de
1361   retirer, et une garde qui prétend le protéger. */
1362
1363// Une seule fonction pour aller quelque part dans l'Académie : quatre
1364// gestionnaires qui posent chacun leur bout d'état, c'est la garantie qu'un
1365// jour l'un oubliera de nettoyer ce que l'autre avait posé — et on se
1366// retrouverait dans « Toutes les ressources » avec un rôle encore ouvert.
1367window.academieVue = function (vue, cle) {
1368  STATE.academieVue = ['apercu', 'role', 'lieu', 'ressources', 'fiche', 'registre', 'reglages'].includes(vue) ? vue : 'apercu'
1369  /* LE REGISTRE EST UNE VUE D'EXPLOITATION. Le bouton qui y mène ne s'affiche
1370     pas au client — mais un état enregistré, un aperçu client pris depuis un
1371     compte propriétaire, ou un simple `academieVue('registre')` tapé à la main
1372     y entreraient quand même. On refuse ici, à la porte. */
1373  if (STATE.academieVue === 'registre' && isClientView()) STATE.academieVue = 'apercu'
1374  /* LA VUE DU REGISTRE DEMANDE LES DEUX, ET NE COMPTE SUR PERSONNE. Elle
1375     lisait les ressources et attendait que les SOURCES soient chargées par le
1376     filet du rendu — qui existe, mais ailleurs. Mesuré à l'écran : la vue
1377     restait sur « Lecture du registre… », et un chargement qui ne finit jamais
1378     se lit comme une panne. Une vue qui a besoin de quelque chose le demande. */
1379  if (STATE.academieVue === 'registre') {
1380    STATE.academieSourceOuverte = null
1381    window.academieChargerSources()
1382    window.academieChargerRessources(null)
1383  }
1384  STATE.academieCle = cle || null
1385  STATE.academieCherche = ''
1386  save(); render()
1387}
1388window.academieSection = function (id) {
1389  STATE.academieSection = ACADEMIE_SECTIONS.some(s => s.id === id) ? id : 'tutoriels'
1390  window.academieVue('apercu')
1391}
1392window.academieOnglet = function (id) {
1393  STATE.academieOnglet = _academieOngletsDuCatalogue().some(o => o.id === id) ? id : 'tout'
1394  save(); render()
1395}
1396window.academieFiltreLieu = function (id) {
1397  STATE.academieLieu = id || null
1398  save(); render()
1399}
1400
1401/* LA RECHERCHE NE PASSE PAS PAR `render()`, ET C'EST DÉLIBÉRÉ. Un rendu
1402   complet à chaque frappe reconstruit le champ, donc lui reprend le curseur —
1403   on tape « ven », on récupère « v ». On filtre donc le DOM déjà posé, et on
1404   met le compteur à jour au même endroit que lui : deux affichages du même
1405   nombre finiraient par diverger. */
1406window.academieChercher = function (texte) {
1407  const q = String(texte || '').trim().toLowerCase()
1408  let vus = 0
1409  document.querySelectorAll('[data-academie-cherche]').forEach(el => {
1410    const ok = !q || el.getAttribute('data-academie-cherche').includes(q)
1411    el.style.display = ok ? '' : 'none'
1412    if (ok) vus++
1413  })
1414  const compteur = document.getElementById('academie-compteur')
1415  if (compteur) compteur.textContent = _academieCompte(vus)
1416  const vide = document.getElementById('academie-vide')
1417  if (vide) vide.style.display = vus ? 'none' : ''
1418}
1419
1420/* ── LE CATALOGUE — UNE SEULE FORME POUR TROIS FAMILLES ─────────────────── */
1421/* ══════════════════════════════════════════════════════════════════════════
1422 * CE QUI EST IMPORTÉ EST UNE RESSOURCE DE L'ACADÉMIE — PAS UNE LIGNE DE REGISTRE
1423 * ══════════════════════════════════════════════════════════════════════════
1424 *
1425 * Le connecteur Open edX marchait, la route d'import marchait, le registre
1426 * gardait. Et un cours importé ne s'affichait NULLE PART dans l'Académie :
1427 * `_academieCatalogue()` ne connaissait que les trois tables écrites ici. Le
1428 * seul écran qui montrait un cours importé était celui du registre — la
1429 * réserve. On remplissait une réserve sans porte sur la boutique.
1430 *
1431 * Mesuré : catalogue = 26 entrées, types « cours, tutoriels, cas ». Zéro
1432 * importée, quelle que soit la source branchée.
1433 *
1434 * Le catalogue reste UNE fonction et UNE forme : une ressource importée entre
1435 * par la même porte que les autres, avec la même clé `type:id`, et se retrouve
1436 * donc du même coup dans la recherche, dans le compteur, dans le filtre par
1437 * lieu, sur la page du lieu et dans sa propre fiche. C'est tout l'intérêt de
1438 * n'avoir qu'un catalogue.
1439 */
1440const ACADEMIE_TYPES_IMPORTES = {
1441  'cours': { onglet: 'cours', label: 'Cours', icone: 'gradcap' },
1442  'tutoriel': { onglet: 'tutoriels', label: 'Tutoriel', icone: 'bulb' },
1443  'cas': { onglet: 'cas', label: "Cas d'usage", icone: 'bolt' },
1444  'exercice': { onglet: 'exercices', label: 'Exercice', icone: 'science' },
1445  'article': { onglet: 'documents', label: 'Article', icone: 'doc' },
1446  'video': { onglet: 'documents', label: 'Vidéo', icone: 'play' },
1447  'jeu-de-donnees': { onglet: 'documents', label: 'Jeu de données', icone: 'chart' },
1448}
1449
1450/* LE CATALOGUE IMPORTÉ SE DEMANDE SANS FILTRE, ET IL A SON PROPRE CHARGEUR.
1451   `academieChargerRessources` sert l'écran d'administration : il filtre sur la
1452   source ouverte et remet la liste à `undefined` le temps de l'appel. Le
1453   catalogue s'en servirait mal — ouvrir une source dans l'administration
1454   viderait la boutique. Deux questions différentes, deux lectures. */
1455window.academieChargerImportees = async function (force) {
1456  if (STATE.academieImportees !== undefined && !force) return
1457  STATE.academieImportees = null
1458  try {
1459    const r = await fetch('/api/academie/ressources', { headers: internalAuth() })
1460    if (!r.ok) throw new Error('refus ' + r.status)
1461    const d = await r.json()
1462    STATE.academieImportees = Array.isArray(d.ressources) ? d.ressources : []
1463    STATE.academieCredits = d.credits || {}
1464  } catch (e) {
1465    /* NULL, PAS []. Une panne de lecture rendrait sinon un catalogue amputé
1466       sans que rien ne le dise — et on croirait l'import perdu. */
1467    STATE.academieImportees = null
1468  }
1469  render()
1470}
1471
1472function _academieImportees() {
1473  const liste = STATE.academieImportees
1474  if (!Array.isArray(liste)) return []
1475  return liste.map(r => {
1476    const t = ACADEMIE_TYPES_IMPORTES[r.type] || ACADEMIE_TYPES_IMPORTES.article
1477    return {
1478      type: t.onglet, typeLabel: t.label, id: r.id, titre: r.titre, quoi: r.quoi || '',
1479      /* UNE RESSOURCE IMPORTÉE S'ÉTUDIE — son lieu est l'École. C'était le
1480         Studio Éducation, qui n'existe plus : le filtre par lieu aurait gardé
1481         son entrée et le bouton n'aurait rien ouvert. Sans lieu du tout, elle
1482         sortirait du filtre ET de la page de lieu — présente dans le
1483         catalogue, introuvable par les deux chemins qui y mènent. */
1484      ou: 'ecole', minutes: r.minutes || 0, kind: 'list', icone: t.icone, role: null,
1485      meta: [r.langue ? String(r.langue).toUpperCase() : '', r.minutes ? r.minutes + ' min' : '', r.auteur || ''].filter(Boolean).join(' · ') || 'importé',
1486      sourceId: r.sourceId, licence: r.licence, droits: r.droits || {},
1487      url: r.url || '', auteur: r.auteur || '', langue: r.langue || '',
1488    }
1489  })
1490}
1491
1492function _academieCatalogue() {
1493  const tutos = TUTO_PARCOURS.flatMap(p => (p.tutos || []).map(t => ({
1494    type: 'tutoriels', typeLabel: 'Tutoriel', id: p.id + '/' + t.id, titre: t.titre, quoi: t.o, ou: t.ouvre,
1495    minutes: t.m, kind: 'doc', c1: p.c1, c2: p.c2, icone: null,
1496    meta: (t.etapes || []).length + ' étapes · ' + t.m + ' min', role: null,
1497  })))
1498  const cas = ACADEMIE_CAS.map(c => ({
1499    type: 'cas', typeLabel: "Cas d'usage", id: c.id, titre: c.titre, quoi: c.quoi, ou: c.ou,
1500    minutes: c.minutes, kind: c.kind, icone: null,
1501    meta: (ACADEMIE_ROLES.find(r => r.id === c.role) || {}).label + ' · ' + c.minutes + ' min', role: c.role,
1502  }))
1503  return tutos.concat(cas, _academieImportees())
1504}
1505// Les lieux RELEVÉS dans le catalogue — jamais une liste écrite à côté.
1506function _academieLieux() {
1507  const vus = []
1508  for (const r of _academieCatalogue()) if (!vus.includes(r.ou)) vus.push(r.ou)
1509  return vus
1510}
1511function _academieCompte(n) {
1512  const o = STATE.academieOnglet || 'tout'
1513  const mot = o === 'cours' ? 'cours'
1514    : o === 'tutoriels' ? (n > 1 ? 'tutoriels' : 'tutoriel')
1515    : o === 'cas' ? "cas d'usage"
1516    : o === 'exercices' ? (n > 1 ? 'exercices' : 'exercice')
1517    : o === 'documents' ? (n > 1 ? 'documents' : 'document')
1518    : o === 'webinaires' ? (n > 1 ? 'webinaires' : 'webinaire')
1519    : (n > 1 ? 'ressources' : 'ressource')
1520  return n + ' ' + mot
1521}
1522
1523// Le nom lisible d'un lieu, pris dans la table des modes plutôt que réécrit :
1524// un studio renommé demain se renomme ici aussi. Les espaces n'y sont pas —
1525// ils ont leur propre table — d'où le repli explicite.
1526const ACADEMIE_LIEUX_ESPACES = { chat: 'le chat', espaceDecouvrir: 'Le Fil', espaceCfo: 'CFO Personnel', espaceJuridique: 'Juridique', espaceEducation: 'la recherche académique', ecole: 'l’École' }
1527function _academieNomDuLieu(id) {
1528  if (ACADEMIE_LIEUX_ESPACES[id]) return ACADEMIE_LIEUX_ESPACES[id]
1529  const m = (STATE.modes || []).find(x => x.id === id)
1530  return m ? 'le Studio ' + m.name : id
1531}
1532function _academieTitreDuLieu(id) {
1533  const n = _academieNomDuLieu(id)
1534  return n.charAt(0) === 'l' ? n.charAt(0).toUpperCase() + n.slice(1) : n
1535}
1536
1537/* ── LES BRIQUES D'AFFICHAGE ─────────────────────────────────────────────── */
1538function _academieEnTete(fil, sousTitre, sousLeNom, aDroite) {
1539  /* DEUX EN-TÊTES, ET C'EST CE QUE MONTRENT LES CAPTURES. Une page de RÔLE
1540     reste dans l'Académie et le dit — « Créa Académie / Ventes ». Une page de
1541     catalogue, elle, PORTE son nom : « Toutes les ressources », avec le retour
1542     au-dessus. Mettre le fil partout donnait un titre sur deux lignes où le
1543     nom de la page passait après celui du lieu. */
1544  const retour = fil ? `<button onclick="window.academieVue('apercu')" class="inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">${_icon('chevronLeft', 'w-3.5 h-3.5')}Académie</button>` : ''
1545  const titre = (fil && !sousLeNom)
1546    ? `<h1 class="text-[max(2.125rem,34px)] font-bold" style="color:var(--t-primary)">${escHtml(fil)}</h1>`
1547    : `<div class="flex items-baseline gap-2 flex-wrap">
1548        ${_spaceTitre('Créa Académie', 'text-[max(2.125rem,34px)] font-bold', 'color:var(--t-primary)', { chevron: 'w-5 h-5' })}
1549        ${fil ? `<span class="text-[max(2.125rem,34px)] font-bold" style="color:var(--t-faint)">/ ${escHtml(fil)}</span>` : ''}
1550      </div>`
1551  /* CE QUI SE POSE À DROITE, SUR LA LIGNE DU TITRE. Les deux sections étaient
1552     empilées SOUS le sous-titre, collées au bloc du titre — signalé : elles se
1553     mêlaient à lui, et la place à droite restait vide. Une navigation n'est pas
1554     une suite du titre : elle se tient en face, sur la même ligne.
1555     `items-start` et pas `items-center` : le bloc de gauche fait deux lignes,
1556     et centrer ferait descendre l'interrupteur au milieu du sous-titre.
1557     Il repasse SOUS le titre quand la place manque (`flex-wrap`), au lieu
1558     d'écraser le titre sur un écran étroit. */
1559  return `<div class="mb-6 flex items-start justify-between gap-5 flex-wrap">
1560    <div class="min-w-0 flex-1" style="min-width:min(100%,260px)">${retour}${titre}
1561      <p class="text-[max(0.875rem,14px)] mt-1.5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p>
1562    </div>
1563    ${aDroite || ''}
1564  </div>`
1565}
1566
1567function _academieRangee(titre, sousTitre, contenu, lien) {
1568  return `<section class="mb-9">
1569    <div class="flex items-end justify-between gap-3 mb-3">
1570      <div class="min-w-0">
1571        <h2 class="text-[max(1.125rem,18px)] font-bold" style="color:var(--t-primary)">${escHtml(titre)}</h2>
1572        ${sousTitre ? `<p class="text-[max(0.8125rem,13px)] mt-0.5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p>` : ''}
1573      </div>
1574      ${lien || ''}
1575    </div>
1576    ${contenu}
1577  </section>`
1578}
1579function _academieLienTout(vue, cle) {
1580  return `<button onclick="window.academieVue('${vue}'${cle ? `,'${cle}'` : ''})" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.8125rem,13px)] font-semibold" style="color:var(--t-primary)">Tout afficher ${_icon('chevronRight', 'w-3.5 h-3.5')}</button>`
1581}
1582
1583// La vignette : l'aplat pastel des pages de studio (`tpl-swatch-N`) avec, par
1584// dessus, l'ESQUISSE du genre produit (`renderTemplateThumb`) ou le trait du
1585// sujet. Pas de photo : aucune banque n'a d'image de « la boucle d'agent », et
1586// une vignette qui ne répond pas laisserait un trou gris à la place.
1587function _academieVignette(r, hauteur, badge) {
1588  /* `tpl-swatch-N` ne pose que les DEUX VARIABLES `--tpl-bg` et `--tpl-fg` :
1589     c'est `.tpl-thumb` qui les emploie. Poser la classe de teinte sans elle
1590     donne une tuile grise — mesuré à la première capture, et c'est le genre de
1591     détail qui ne se voit qu'à l'écran. On prend donc les deux. */
1592  const teinte = 'tpl-swatch-' + ([...String(r.id)].reduce((a, c) => a + c.charCodeAt(0), 0) % 8)
1593  const dedans = r.icone
1594    ? `<span style="color:var(--tpl-fg,var(--accent));opacity:.85">${_icon(r.icone, 'w-7 h-7')}</span>`
1595    : renderTemplateThumb(r.kind)
1596  return `<span class="tpl-thumb ${teinte} relative" style="${hauteur};flex:none;border-radius:0;${r.icone ? 'align-items:center' : ''}">
1597    ${dedans}
1598    ${badge ? `<span class="absolute top-2 right-2 rounded-md px-1.5 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-primary);color:var(--t-secondary)">${escHtml(badge)}</span>` : ''}
1599  </span>`
1600}
1601
1602// Une ligne du catalogue : vignette à gauche, titre, description, méta — la
1603// forme de « Toutes les ressources ».
1604function _academieLigne(r) {
1605  const cherche = escHtml((r.titre + ' ' + r.quoi + ' ' + r.typeLabel + ' ' + _academieNomDuLieu(r.ou)).toLowerCase())
1606  return `<button data-academie-cherche="${cherche}" onclick="window.academieVue('fiche','${r.type}:${r.id}')" class="w-full text-left flex items-stretch gap-0 rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)">
1607    ${_academieVignette(r, 'width:120px;min-height:92px')}
1608    <span class="flex-1 min-w-0 p-3.5">
1609      <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</span>
1610      <span class="block text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi)}</span>
1611      <span class="block text-[max(0.75rem,12px)] mt-1.5" style="color:var(--t-faint)">${escHtml(r.typeLabel)} · ${escHtml(r.meta)}</span>
1612    </span>
1613  </button>`
1614}
1615
1616// Une carte du catalogue : vignette en haut, la forme de « Commencer ici ».
1617function _academieCarte(r) {
1618  const cherche = escHtml((r.titre + ' ' + r.quoi + ' ' + r.typeLabel).toLowerCase())
1619  return `<button data-academie-cherche="${cherche}" onclick="window.academieVue('fiche','${r.type}:${r.id}')" class="text-left rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)">
1620    ${_academieVignette(r, 'width:100%;height:112px', r.type === 'cours' ? '' : r.minutes + ' min')}
1621    <span class="block p-3.5">
1622      <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</span>
1623      <span class="block text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi)}</span>
1624      <span class="block text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${escHtml(r.typeLabel)} · ${escHtml(r.meta)}</span>
1625    </span>
1626  </button>`
1627}
1628
1629/* UNE CARTE DE PROMPT — le geste qui existe déjà. `data-studio-template-card`
1630   ouvre le lieu et écrit le texte dans sa barre SANS l'envoyer, comme le dit
1631   l'écran : « chaque prompt s'ouvre en brouillon ». */
1632function _academieCartePrompt(p) {
1633  return `<button data-studio-template-card="${escHtml(p.ou)}" data-template-exemple="${escHtml(p.exemple)}" class="text-left rounded-2xl p-3.5 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)" title="${escHtml(p.exemple)}">
1634    <span class="block text-[max(0.6875rem,11px)] mb-1" style="color:var(--t-faint)">Essayer dans ${escHtml(_academieNomDuLieu(p.ou))}</span>
1635    <span class="block text-[max(0.875rem,14px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(p.titre)}</span>
1636    <span class="block text-[max(0.75rem,12px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(p.quoi)}</span>
1637  </button>`
1638}
1639
1640
1641/* ══════════════════════════════════════════════════════════════════════════
1642 * LES WEBINAIRES — LA STRUCTURE EXISTE AVANT LE CONTENU, ET C'EST VOULU
1643 * ══════════════════════════════════════════════════════════════════════════
1644 *
1645 * « Il n'en a pas, mais il va en avoir. Il faut tout créer. »
1646 *
1647 * Cette table est VIDE aujourd'hui, et l'écran le dit en toutes lettres au
1648 * lieu d'afficher une rangée fantôme. La différence entre les deux est tout ce
1649 * qui compte : un onglet qui annonce « aucune séance programmée — proposez-en
1650 * une » est une invitation ; le même onglet avec trois cartes inventées est un
1651 * mensonge qu'on découvre en cliquant.
1652 *
1653 * La forme d'une séance, pour celle qu'on posera demain :
1654 *   { id, titre, quoi, quand (ISO), duree, etat: 'a-venir' | 'rediffusion',
1655 *     par: ['qui anime'], lien }
1656 * `quand` est une date ISO, jamais un texte : « le 12 » ne se trie pas et ne
1657 * se compare pas à maintenant, donc une séance passée resterait « à venir »
1658 * pour toujours.
1659 */
1660/* LA TABLE EST PARTIE AU SERVEUR. Écrite ici, elle était vide POUR TOUJOURS :
1661   aucune route, aucun stockage, aucun moyen d'en créer une. « Aucune séance
1662   programmée » n'était pas un état, c'était une propriété du produit. */
1663window.academieChargerSeances = async function (force) {
1664  if (STATE.academieSeances !== undefined && !force) return
1665  STATE.academieSeances = null
1666  try {
1667    const r = await fetch('/api/academie/seances', { headers: internalAuth() })
1668    if (!r.ok) throw new Error('refus ' + r.status)
1669    const d = await r.json()
1670    STATE.academieSeances = Array.isArray(d.seances) ? d.seances : []
1671    STATE._academieSeancesErreur = ''
1672  } catch (e) {
1673    /* NULL, PAS []. « Je n'ai pas pu regarder » et « il n'y en a pas » sont
1674       deux phrases différentes, et l'une invite quand l'autre inquiète. */
1675    STATE.academieSeances = null
1676    STATE._academieSeancesErreur = e.message === 'refus 401' ? 'connectez-vous pour voir les séances' : e.message
1677  }
1678  render()
1679}
1680
1681window.academieProposerSeance = async function () {
1682  const lu = (id) => (document.getElementById(id) || {}).value || ''
1683  const d = await _academiePoster('/api/academie/seances', {
1684    titre: lu('seance-titre'), quoi: lu('seance-quoi'),
1685    quand: lu('seance-quand'), minutes: Number(lu('seance-minutes')),
1686    matiere: lu('seance-matiere'), lien: lu('seance-lien'),
1687    par: lu('seance-par') ? [lu('seance-par')] : [],
1688  })
1689  if (d) { STATE.academieSeanceFormOuvert = false; window.academieChargerSeances(true) }
1690  else render()
1691}
1692
1693window.academieInscription = async function (id) {
1694  const d = await _academiePoster('/api/academie/seances/' + encodeURIComponent(id) + '/inscription', {})
1695  if (d) window.academieChargerSeances(true)
1696  else render()
1697}
1698
1699window.academieAnnulerSeance = async function (id) {
1700  try {
1701    const r = await fetch('/api/academie/seances/' + encodeURIComponent(id), { method: 'DELETE', headers: internalAuth() })
1702    const d = await r.json().catch(() => ({}))
1703    if (!r.ok || d.ok === false) { STATE._academieRefus = d.detail ? `${d.raison || 'refus'} — ${d.detail}` : `le serveur a répondu ${r.status}`; render(); return }
1704    STATE._academieRefus = ''
1705    window.academieChargerSeances(true)
1706  } catch (e) { STATE._academieRefus = e.message; render() }
1707}
1708
1709window.academieSeanceForm = function (ouvert) { STATE.academieSeanceFormOuvert = !!ouvert; render() }
1710
1711const ACADEMIE_ETAT_SEANCE = {
1712  'en-cours': { label: 'En direct', couleur: 'var(--danger)' },
1713  'a-venir': { label: 'À venir', couleur: 'var(--accent)' },
1714  rediffusion: { label: 'Rediffusion', couleur: 'var(--t-faint)' },
1715}
1716
1717/* ══════════════════════════════════════════════════════════════════════════
1718 * LE REGISTRE DES SOURCES — IL VIT AU SERVEUR, ON LE LIT
1719 * ══════════════════════════════════════════════════════════════════════════
1720 *
1721 * Il a été une table ÉCRITE ICI, et c'était un registre qui n'en était pas un :
1722 * dans le navigateur de chacun, donc nulle part. Une source branchée par
1723 * quelqu'un n'existait pas pour le suivant, et rien ne pouvait vérifier ce
1724 * qu'elle prétendait — le client se croyait sur parole.
1725 *
1726 * Tout ce qui décide est maintenant dans `server/academie-registre.js` : il
1727 * refuse une source branchée par un outil que le serveur ne sait pas
1728 * EXÉCUTER, une ressource sans source connue, une licence hors table, et des
1729 * droits envoyés par l'appelant (ils sont DÉRIVÉS de la licence).
1730 *
1731 * TROIS ÉTATS DE LECTURE, ET ILS NE SE RESSEMBLENT PAS :
1732 *   · `undefined` — pas encore demandé : « Lecture… » ;
1733 *   · `null`      — demandé et refusé : on DIT pourquoi ;
1734 *   · un tableau  — le registre.
1735 * Confondre les deux premiers avec le troisième donnerait « aucune source »
1736 * pendant une panne de réseau, et on croirait avoir tout perdu.
1737 */
1738window.academieChargerSources = async function (force) {
1739  if (STATE.academieSources !== undefined && !force) return
1740  STATE.academieSources = null
1741  try {
1742    const r = await fetch('/api/academie/sources', { headers: internalAuth() })
1743    if (!r.ok) throw new Error('refus ' + r.status)
1744    const d = await r.json()
1745    STATE.academieSources = Array.isArray(d.sources) ? d.sources : []
1746    STATE._academieSourcesErreur = ''
1747    /* CE QUE LE SERVEUR A ÉCARTÉ SE DIT. Une source de départ refusée — un
1748       outil retiré du catalogue, par exemple — rendrait sinon un registre
1749       plus court sans que personne ne sache pourquoi. */
1750    STATE._academieEcartees = Array.isArray(d.ecartees) ? d.ecartees : []
1751  } catch (e) {
1752    STATE.academieSources = null
1753    STATE._academieSourcesErreur = e.message === 'refus 401' ? 'connectez-vous pour voir le registre' : e.message
1754  }
1755  render()
1756}
1757
1758/* LA NEWSLETTER PASSE PAR LA PORTE QUI EXISTE. `/api/account/announcements`
1759   pose `announcementsOptIn` sur le compte, et `/api/admin/announcements/send`
1760   n'écrit qu'aux comptes qui l'ont posé — les deux sont écrits, servis et
1761   déjà appelés depuis les Réglages. Ouvrir ici une seconde collecte d'adresses
1762   aurait donné deux listes d'abonnés dont une ne recevrait jamais rien. */
1763/* LA BASCULE D'UN CANAL. `academieNewsletter` posait un BOOLÉEN de compte
1764   depuis l'Académie, sous le nom « les annonces de l'Académie » : deux noms,
1765   deux promesses, un seul bit — se désabonner « de l'Académie » coupait aussi
1766   les mises à jour du produit, et rien ne le laissait deviner.
1767   Le corps porte `tous` : la liste de ce que « tout » veut dire aujourd'hui.
1768   Sans elle, le serveur ne pourrait pas décocher un canal pour un compte qui
1769   n'a jamais choisi — il partirait d'une liste vide et en couperait quatre.
1770   Et c'est l'écran qui la donne, parce que c'est lui qui tient la table : le
1771   serveur n'a pas de seconde liste à garder d'accord. */
1772window.annoncesBasculer = async function (canal, oui) {
1773  const corps = canal === null
1774    ? { optIn: oui !== false }
1775    : { canal: canal, abonne: oui !== false, tous: (window.ANNONCES_CANAUX || []).map(c => c.id) }
1776  try {
1777    const r = await fetch('/api/account/announcements', {
1778      method: 'POST', headers: { 'Content-Type': 'application/json', ...internalAuth() },
1779      body: JSON.stringify(corps),
1780    })
1781    if (!r.ok) throw new Error('refus ' + r.status)
1782    const d = await r.json().catch(() => ({}))
1783    /* ON RANGE CE QUE LE SERVEUR A DÉCIDÉ, pas ce qu'on a demandé. Les deux
1784       diffèrent dès que la règle a son mot à dire — un canal coché alors que
1785       l'interrupteur général est coupé, par exemple — et afficher son propre
1786       souhait ferait croire à un abonnement qui n'existe pas. */
1787    STATE.backendUser = STATE.backendUser || {}
1788    STATE.backendUser.announcementsOptIn = d.optIn !== false
1789    if (Array.isArray(d.canaux)) STATE.backendUser.announcementsCanaux = d.canaux
1790  } catch (e) {
1791    /* LE REFUS SE DIT. Sans compte la route rend 401 et rien ne bascule — mais
1792       une case qui ne bouge pas se lit comme un clic raté, pas comme un refus.
1793       On nomme la cause. */
1794    logNotice('Connectez-vous pour choisir vos annonces.', 'error', '🔒')
1795  }
1796  render()
1797}
1798
1799
1800function _academieCarteWebinaire(w) {
1801  const e = ACADEMIE_ETAT_SEANCE[w.etat] || ACADEMIE_ETAT_SEANCE.rediffusion
1802  const quand = new Date(w.quand)
1803  const lisible = isNaN(quand) ? '' : quand.toLocaleString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
1804  const cherche = escHtml((w.titre + ' ' + (w.quoi || '') + ' seance directe webinaire ' + (w.matiere || '')).toLowerCase())
1805  /* ON S'INSCRIT À CE QUI N'A PAS ENCORE EU LIEU, ON REGARDE CE QUI EST PASSÉ.
1806     Un bouton « je participe » sur une rediffusion promettrait une place à une
1807     séance qui n'existe plus. */
1808  const passe = w.etat === 'rediffusion'
1809  return `<div data-academie-cherche="${cherche}" class="flex flex-col rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid ${w.etat === 'en-cours' ? 'var(--danger)' : 'var(--border)'}">
1810    <div class="flex items-center gap-2 px-3.5 pt-3.5">
1811      <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-bold" style="background:var(--bg-input);color:${e.couleur};border:1px solid ${e.couleur}">${w.etat === 'en-cours' ? '<span style="width:6px;height:6px;border-radius:999px;background:var(--danger);display:inline-block"></span>' : ''}${escHtml(e.label)}</span>
1812      ${lisible ? `<span class="text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(lisible)}</span>` : ''}
1813      ${w.matiere ? `<span class="ml-auto text-[max(0.6875rem,11px)]" style="color:var(--t-faint)">${escHtml(w.matiere)}</span>` : ''}
1814    </div>
1815    <div class="px-3.5 pt-2 pb-3.5 flex-1">
1816      <p class="text-[max(0.9375rem,15px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(w.titre)}</p>
1817      ${w.quoi ? `<p class="text-[max(0.8125rem,13px)] mt-1 leading-snug" style="color:var(--t-dim)">${escHtml(w.quoi)}</p>` : ''}
1818      <p class="text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${w.minutes} min${(w.par || []).length ? ' · ' + escHtml(w.par.join(', ')) : ''} · ${w.inscrits} inscrit${w.inscrits > 1 ? 's' : ''}</p>
1819    </div>
1820    <div class="flex items-center gap-2 px-3.5 pb-3.5">
1821      ${passe
1822        ? (w.lien ? `<a href="${escHtml(w.lien)}" target="_blank" rel="noopener noreferrer" class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary);text-decoration:none">Voir la rediffusion</a>` : `<span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">Rediffusion à venir</span>`)
1823        : `<button onclick="window.academieInscription('${escHtml(w.id)}')" class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="${w.jeParticipe ? 'background:var(--bg-input);border:1px solid var(--accent);color:var(--accent)' : 'background:var(--accent);color:#fff;border:none'}">${w.jeParticipe ? 'Inscrit ✓' : 'Je participe'}</button>`}
1824      ${w.jAnime ? `<button onclick="window.academieAnnulerSeance('${escHtml(w.id)}')" class="ml-auto text-[max(0.75rem,12px)] font-semibold" style="color:var(--danger);background:none;border:none">Annuler</button>` : ''}
1825    </div>
1826  </div>`
1827}
1828
1829
1830function _academieFormulaireSeance() {
1831  if (!STATE.academieSeanceFormOuvert) {
1832    return `<button onclick="window.academieSeanceForm(true)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Proposer une séance</button>`
1833  }
1834  /* LA DATE EST UN CHAMP `datetime-local`, PAS DU TEXTE LIBRE. « le 12 » ne se
1835     compare pas à maintenant : l'état de la séance — à venir, en direct,
1836     rediffusion — se calcule sur cette date, et il se calculerait faux. */
1837  const champ = (id, lab, type, ph, val, liste) => `<label class="flex flex-col gap-1">
1838    <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(lab)}</span>
1839    <input id="${id}" type="${type}" ${liste ? `list="${liste}"` : ''} ${val ? `value="${escHtml(val)}"` : ''} placeholder="${escHtml(ph || '')}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" />
1840  </label>`
1841  /* LA MATIÈRE SE PROPOSE, ELLE NE SE DEVINE PAS.
1842     C'est ce champ qui décide si la séance apparaîtra sur la page de sa
1843     matière : le rapprochement se fait sur le NOM. Laissé en texte libre nu,
1844     « code route » et « Code de la route » ne se rejoignent jamais, et la
1845     séance reste orpheline sans que personne ne comprenne pourquoi.
1846     Une `datalist` PROPOSE les cinquante-six matières réelles sans INTERDIRE
1847     d'écrire autre chose — une séance sur un sujet que l'École ne couvre pas
1848     encore doit rester possible, et elle garde sa place dans la rangée de
1849     l'accueil. */
1850  const matieres = `<datalist id="seance-matieres">${(window.ECOLE_MATIERES || []).map(m =>
1850 `<option value="${escHtml(m.nom)}"></option>`).join('')}</datalist>`
1851  return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
1852    <p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Proposer une séance en direct</p>
1853    <div class="grid gap-2.5 mb-3" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr))">
1854      ${champ('seance-titre', 'Titre', 'text', 'Ce que la séance apprend')}
1855      ${champ('seance-quand', 'Quand', 'datetime-local', '')}
1856      ${champ('seance-minutes', 'Durée en minutes', 'number', '45', '45')}
1857      ${champ('seance-matiere', 'Matière', 'text', 'Code de la route, Python…', '', 'seance-matieres')}${matieres}
1858      ${champ('seance-par', 'Animée par', 'text', 'Votre nom')}
1859      ${champ('seance-lien', 'Lien de la visio (facultatif)', 'text', 'https://…')}
1860    </div>
1861    ${champ('seance-quoi', 'Ce que les gens sauront faire à la fin', 'text', 'En une phrase')}
1862    <div class="flex gap-2 mt-3">
1863      <button onclick="window.academieProposerSeance()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Poser la séance</button>
1864      <button onclick="window.academieSeanceForm(false)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Annuler</button>
1865    </div>
1866  </div>`
1867}
1868
1869function _academieWebinaires() {
1870  const liste = STATE.academieSeances
1871  const refus = STATE._academieRefus
1872    ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--danger) 10%,transparent);border:1px solid var(--danger);color:var(--danger)">${escHtml(STATE._academieRefus)}</p>`
1873    : ''
1874  /* TROIS ÉTATS DE LECTURE, ET ILS NE SE RESSEMBLENT PAS. `undefined` : pas
1875     encore demandé. `null` : demandé et refusé — on DIT pourquoi. Un tableau :
1876     la liste, même vide. Les confondre ferait passer une panne de réseau pour
1877     une académie sans vie. */
1878  if (liste === undefined) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture des séances…</p>`
1879  if (liste === null) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Séances indisponibles${STATE._academieSeancesErreur ? ' — ' + escHtml(STATE._academieSeancesErreur) : ''}.</p>`
1880
1881  if (!liste.length) {
1882    /* L'ÉTAT VIDE DIT « IL N'Y EN A PAS ENCORE », JAMAIS « IL N'Y EN A PAS ».
1883       Les deux se ressemblent et ne veulent pas dire la même chose : l'un
1884       invite, l'autre ferme. Et maintenant l'invitation MÈNE quelque part. */
1885    return refus + _academieFormulaireSeance() + `<div class="rounded-2xl p-6 text-center mt-3" style="background:var(--bg-secondary);border:1px dashed var(--border)">
1886      <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">Aucune séance programmée pour l'instant</p>
1887      <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Les séances en direct s'afficheront ici avec leur date, et leur rediffusion ensuite. La première peut être la vôtre.</p>
1888    </div>`
1889  }
1890  return refus + _academieFormulaireSeance()
1891    + `<div class="grid gap-2.5 mt-3" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${liste.map(_academieCarteWebinaire).join('')}</div>`
1892}
1893
1894
1895/* ══════════════════════════════════════════════════════════════════════════
1896 * LE BLOC DES ANNONCES — PARTAGÉ, JAMAIS RECOPIÉ
1897 * ══════════════════════════════════════════════════════════════════════════
1898 *
1899 * Il s'appelait `_academieNewsletterBloc` et disait « les annonces de
1900 * l'Académie ». Signalé : « il faut centraliser ça parce que ça ne va pas
1901 * jouer un rôle que pour cet espace-là. »
1902 *
1903 * Il prend maintenant un ESPACE et rend les canaux que la table déclare pour
1904 * lui (src/crea-annonces.js) : un espace qui veut annoncer quelque chose
1905 * ajoute une LIGNE à la table, il n'écrit pas un second bloc. Sans espace, il
1906 * rend TOUS les canaux — c'est la forme qu'emploient les Réglages, l'endroit
1907 * où l'on vient gérer ses emails plutôt que les découvrir.
1908 *
1909 * ET IL DIT OÙ VIVE LE RESTE. Un espace qui montre deux canaux 
1909sur cinq sans
1910 * le dire laisse croire qu'il n'y en a que deux — et le jour où l'on veut se
1911 * désabonner de tout, on cherche là où l'on avait coché.
1912 */
1913function _annoncesBloc(espace) {
1914  const canaux = espace ? window.annoncesCanauxDeLEspace(espace) : (window.ANNONCES_CANAUX || [])
1915  if (!canaux.length) return ''
1916  const general = !STATE.backendUser || STATE.backendUser.announcementsOptIn !== false
1917  const ligne = (c) => {
1918    const coche = general && window.annoncesAbonneA(c.id)
1919    return `<div class="flex items-center gap-3 px-4 py-3" style="border-top:1px solid var(--border-dim)">
1920      <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon(c.icone, 'w-4 h-4')}</span>
1921      <div class="min-w-0 flex-1">
1922        <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">${escHtml(c.label)}</p>
1923        <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">${escHtml(c.quoi)}</p>
1924      </div>
1925      <button onclick="window.annoncesBasculer('${escHtml(c.id)}',${coche ? 'false' : 'true'})" class="shrink-0 rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="${coche ? 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)' : 'background:var(--t-primary);color:var(--bg-primary);border:none'}">${coche ? 'Abonné' : 'S’abonner'}</button>
1926    </div>`
1927  }
1928  /* L'INTERRUPTEUR GÉNÉRAL EST DIT QUAND IL EST COUPÉ, ET SEULEMENT LÀ. Coupé,
1929     il rend les cases inertes : les laisser paraître cochables ferait cliquer
1930     dans le vide, et on chercherait la panne du côté du bouton. */
1931  const coupe = !general ? `<div class="px-4 py-3 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);color:var(--t-dim);border-top:1px solid var(--border-dim)">
1932      Vous avez coupé toutes les annonces de CREA. ${espace ? 'Elles se rallument dans Réglages → Compte.' : ''}
1933      <button onclick="window.annoncesBasculer(null,true)" class="ml-1 font-semibold" style="background:none;border:none;color:var(--accent)">Les rallumer</button>
1934    </div>` : ''
1935  return `<div class="rounded-2xl overflow-hidden mb-9" style="background:var(--bg-secondary);border:1px solid var(--border)">
1936    <div class="px-4 pt-4 pb-1">
1937      <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Les annonces de CREA</p>
1938      <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Envoyées à l’adresse de votre compte, et le désabonnement tient en un clic.${espace ? ` ${canaux.length} ${canaux.length > 1 ? 'canaux concernent' : 'canal concerne'} cet espace sur ${(window.ANNONCES_CANAUX || []).length} — la liste entière est dans Réglages → Compte.` : ''}</p>
1939    </div>
1940    ${coupe}${canaux.map(ligne).join('')}
1941  </div>`
1942}
1943
1944
1945
1946/* ══════════════════════════════════════════════════════════════════════════
1947 * L'ÉCRAN DU REGISTRE — LE TUYAU AVAIT UNE PIÈCE SANS PORTE
1948 * ══════════════════════════════════════════════════════════════════════════
1949 *
1950 * Le registre était branché et AVEUGLE : on pouvait importer un cours Open edX,
1951 * et rien dans l'Académie ne le montrait. Aucun écran pour ajouter une source,
1952 * aucun pour renseigner une licence — donc tout ce qui entrait restait en
1953 * « inconnue », c'est-à-dire lecture et citation, pour toujours.
1954 *
1955 * ── CE QUE CET ÉCRAN PEUT CASSER, ET QUI NE LÈVERAIT RIEN ─────────────────
1956 *
1957 *   · UN REFUS AVALÉ. Le serveur refuse avec un code et une phrase — source
1958 *     branchée sans outil, licence hors table, ressource orpheline. Les taire
1959 *     ferait disparaître la ligne sans un mot, et on la reposerait deux fois
1960 *     en croyant à un raté de réseau. Chaque refus s'affiche, tel quel.
1961 *   · UNE LICENCE CHANGÉE SANS RIEN DIRE. Passer une ressource de « inconnue »
1962 *     à « CC BY » donne le droit de la traduire et de la redistribuer. Ce
1963 *     n'est pas un réglage d'affichage : l'écran montre ce que le choix
1964 *     PERMET, avant de le poser.
1965 *   · UN COMPTE JUSTE AU MAUVAIS ENDROIT. Le nombre de ressources par source
1966 *     vient du serveur AVANT filtrage : compté après, il dirait zéro partout
1967 *     sauf sur la source ouverte.
1968 * ══════════════════════════════════════════════════════════════════════════ */
1969
1970window.academieChargerRessources = async function (source) {
1971  STATE.academieRessources = undefined
1972  render()
1973  try {
1974    const q = source ? '?source=' + encodeURIComponent(source) : ''
1975    const r = await fetch('/api/academie/ressources' + q, { headers: internalAuth() })
1976    if (!r.ok) throw new Error('refus ' + r.status)
1977    const d = await r.json()
1978    STATE.academieRessources = Array.isArray(d.ressources) ? d.ressources : []
1979    STATE.academieParSource = d.parSource || {}
1980    STATE.academieLicences = d.licences || {}
1981    STATE._academieRegistreErreur = ''
1982  } catch (e) {
1983    STATE.academieRessources = null
1984    STATE._academieRegistreErreur = e.message === 'refus 401' ? 'connectez-vous pour voir le registre' : e.message
1985  }
1986  render()
1987}
1988
1989/* UN REFUS DU SERVEUR S'AFFICHE, IL NE SE DEVINE PAS. `raison` est un code
1990   stable, `detail` la phrase écrite pour être lue — on rend les deux. */
1991async function _academiePoster(chemin, corps) {
1992  try {
1993    const r = await fetch(chemin, {
1994      method: 'POST', headers: { 'Content-Type': 'application/json', ...internalAuth() },
1995      body: JSON.stringify(corps),
1996    })
1997    const d = await r.json().catch(() => ({}))
1998    if (!r.ok || d.ok === false) {
1999      STATE._academieRefus = d.detail ? `${d.raison || 'refus'} — ${d.detail}` : `le serveur a répondu ${r.status}`
2000      return null
2001    }
2002    STATE._academieRefus = ''
2003    return d
2004  } catch (e) {
2005    STATE._academieRefus = e.message
2006    return null
2007  }
2008}
2009
2010window.academieAjouterSource = async function () {
2011  const val = (id) => (document.getElementById(id) || {}).value || ''
2012  const nom = val('reg-nom').trim()
2013  if (!nom) { STATE._academieRefus = 'donnez un nom à la source'; render(); return }
2014  const d = await _academiePoster('/api/academie/sources', {
2015    id: val('reg-id').trim() || nom,
2016    nom,
2017    quoi: val('reg-quoi').trim(),
2018    base: val('reg-base').trim(),
2019    licence: val('reg-licence'),
2020    /* UNE SOURCE AJOUTÉE À LA MAIN EST « À BRANCHER », ET L'ÉCRAN NE PROPOSE
2021       PAS AUTRE CHOSE. « Branchée » exige de nommer un outil que le serveur
2022       sait exécuter : ça ne se déclare pas depuis un formulaire, ça s'écrit
2023       dans le code. Offrir le choix ferait poser une promesse que le serveur
2024       refuserait — un aller-retour pour rien. */
2025    etat: 'a-brancher',
2026  })
2027  if (d) { STATE.academieSources = undefined; window.academieChargerSources(true) }
2028  else render()
2029}
2030
2031window.academieLicence = async function (id, licence) {
2032  const r = (STATE.academieRessources || []).find(x => x.id === id)
2033  if (!r) return
2034  /* ON RENVOIE LA RESSOURCE ENTIÈRE, pas seulement la licence : la route
2035     remplace par identifiant, et n'envoyer qu'un champ effacerait les autres.
2036     Les DROITS, eux, ne sont pas envoyés — le serveur les dérive de la
2037     licence, et les poster ne servirait qu'à essayer de se les donner. */
2038  const d = await _academiePoster('/api/academie/ressources', {
2039    id: r.id, titre: r.titre, type: r.type, sourceId: r.sourceId,
2040    quoi: r.quoi, url: r.url, langue: r.langue, auteur: r.auteur, licence,
2041  })
2042  /* LE CATALOGUE SUIT LA LICENCE. Changer une licence dans l'administration
2043     change ce que la FICHE affiche au client — « lecture et citation
2044     seulement » devient « traduire, adapter ». Ne rafraîchir que l'écran
2045     d'administration laisserait la boutique afficher l'ancien droit jusqu'au
2046     prochain rechargement complet, et personne ne verrait le désaccord. */
2047  if (d) {
2048    window.academieChargerRessources(STATE.academieSourceOuverte || null)
2049    window.academieChargerImportees(true)
2050  } else render()
2051}
2052
2053/* ══════════════════════════════════════════════════════════════════════════
2054 * IMPORTER UN COURS — LE GESTE QUI N'EXISTAIT NULLE PART
2055 * ══════════════════════════════════════════════════════════════════════════
2056 *
2057 * `/api/academie/{openedx|moodle}/importer` était écrite, gardée, éprouvée —
2058 * et personne ne l'appelait. Aucun écran, aucun bouton, aucun outil. Brancher
2059 * son instance ne donnait donc RIEN, et c'est indiscernable d'un connecteur en
2060 * panne : on a fait la moitié du chemin et on a laissé la porte sans poignée.
2061 *
2062 * Deux gestes, et ils sont dans cet ordre parce qu'on n'importe pas à
2063 * l'aveugle : on cherche dans le catalogue de l'instance, on voit ce qu'on
2064 * ramène, puis on l'importe. Le refus du fournisseur — jeton invalide,
2065 * instance non configurée — s'affiche tel quel.
2066 */
2067const ACADEMIE_FOURNISSEURS = ['openedx', 'moodle']
2068
2069window.academieChercherCours = async function (fournisseur, q) {
2070  if (!ACADEMIE_FOURNISSEURS.includes(fournisseur)) return
2071  STATE.academieRecherche = { fournisseur, q: q || '', cours: undefined, refus: '' }
2072  render()
2073  try {
2074    const r = await fetch(`/api/academie/${fournisseur}/catalogue?q=` + encodeURIComponent(q || ''), { headers: internalAuth() })
2075    const d = await r.json().catch(() => ({}))
2076    if (!r.ok || d.ok === false) {
2077      STATE.academieRecherche = { fournisseur, q: q || '', cours: null, refus: d.detail ? `${d.raison || 'refus'} — ${d.detail}` : `le serveur a répondu ${r.status}` }
2078    } else {
2079      STATE.academieRecherche = { fournisseur, q: q || '', cours: d.cours || [], refus: '', filtreLocal: d.filtreLocal === true }
2080    }
2081  } catch (e) {
2082    STATE.academieRecherche = { fournisseur, q: q || '', cours: null, refus: e.message }
2083  }
2084  render()
2085}
2086
2087window.academieImporterCours = async function (fournisseur, cours) {
2088  if (!ACADEMIE_FOURNISSEURS.includes(fournisseur) || !cours) return
2089  STATE.academieImportEnCours = String(cours)
2090  render()
2091  const d = await _academiePoster(`/api/academie/${fournisseur}/importer`, { cours: String(cours) })
2092  STATE.academieImportEnCours = null
2093  if (d) {
2094    /* LES DEUX LECTURES SE REFONT. L'écran d'administration montre ce qui
2095       vient d'entrer ; le catalogue de la boutique doit le montrer AUSSI,
2096       sinon on aurait importé un cours qui n'existe que pour le propriétaire —
2097       c'est-à-dire exactement le défaut qu'on est en train de refermer. */
2098    window.academieChargerRessources(STATE.academieSourceOuverte || null)
2099    window.academieChargerImportees(true)
2100    /* LE RETOUR EST À L'ENDROIT DU CLIC, PAS DANS LE JOURNAL. La liste juste
2101       en dessous se remplit — mais un cours dont toutes les sections sont
2102       vides pose ZÉRO ressource, et là rien ne bougerait : le clic aurait
2103       l'air de rater. On dit donc le compte, y compris quand il vaut zéro. */
2104    STATE.academieDernierImport = { cours: d.cours || cours, posees: d.posees || 0, refusees: (d.refusees || []).length }
2105  } else render()
2106}
2107
2108window.academieOuvrirSource = function (id) {
2109  STATE.academieSourceOuverte = id || null
2110  window.academieChargerRessources(id || null)
2111}
2112
2113/* CE QU'UNE LICENCE PERMET, EN UNE LIGNE. C'est la seule information qui
2114   compte au moment de la choisir : « CC BY-ND » ne dit rien à personne,
2115   « traduire : non » si. */
2116function _academieDroitsLisibles(d) {
2117  if (!d) return 'droits inconnus'
2118  const permis = [d.traduire && 'traduire', d.adapter && 'adapter', d.redistribuer && 'redistribuer', d.commercial && 'usage commercial'].filter(Boolean)
2119  return (permis.length ? 'Permis : ' + permis.join(', ') : 'Lecture et citation seulement') + (d.attribution ? ' · attribution obligatoire' : '')
2120}
2121
2122function _academieFormulaireSource() {
2123  const licences = STATE.academieLicences || {}
2124  const options = Object.keys(licences).length
2125    ? Object.entries(licences).map(([id, l]) => `<option value="${escHtml(id)}"${id === 'inconnue' ? ' selected' : ''}>${escHtml(l.nom || id)}</option>`).join('')
2126    : '<option value="inconnue">Inconnue</option>'
2127  const champ = (id, invite, large) => `<input id="${id}" placeholder="${escHtml(invite)}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);${large ? 'grid-column:1/-1' : ''}" />`
2128  return `<details class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
2129    <summary class="text-[max(0.875rem,14px)] font-bold cursor-pointer" style="color:var(--t-primary)">Ajouter une source</summary>
2130    <p class="text-[max(0.75rem,12px)] mt-1 mb-3" style="color:var(--t-dim)">Elle entre « à brancher » : dire qu'une source est branchée demande de nommer un outil que le serveur sait exécuter, et ça s'écrit dans le code, pas dans un formulaire.</p>
2131    <div class="grid gap-2 mb-3" style="grid-template-columns:1fr 1fr">
2132      ${champ('reg-nom', 'Nom — ex. Manuels de mon école')}
2133      ${champ('reg-id', 'Identifiant (facultatif)')}
2134      ${champ('reg-quoi', 'Ce qu’on y trouve', true)}
2135      ${champ('reg-base', 'Adresse (facultatif)', true)}
2136      <select id="reg-licence" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);grid-column:1/-1">${options}</select>
2137    </div>
2138    <button onclick="window.academieAjouterSource()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ajouter</button>
2139  </details>`
2140}
2141
2142function _academieLigneRessource(r) {
2143  const licences = STATE.academieLicences || {}
2144  const options = Object.keys(licences).length
2145    ? Object.entries(licences).map(([id, l]) => `<option value="${escHtml(id)}"${id === r.licence ? ' selected' : ''}>${escHtml(l.nom || id)}</option>`).join('')
2146    : `<option>${escHtml(r.licence || 'inconnue')}</option>`
2147  return `<div class="rounded-2xl p-3.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2148    <div class="flex items-start gap-3">
2149      <div class="min-w-0 flex-1">
2150        <p class="text-[max(0.875rem,14px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(r.titre)}</p>
2151        <p class="text-[max(0.75rem,12px)] mt-0.5 leading-snug" style="color:var(--t-dim)">${escHtml(r.quoi || '')}</p>
2152        <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(r.type)} · ${escHtml(r.sourceId)} · ${escHtml(r.langue || '')}${r.auteur ? ' · ' + escHtml(r.auteur) : ''}</p>
2153        ${/* CE QUE LA LICENCE PERMET, SOUS LE CHOIX. Changer « inconnue » en
2154             « CC BY » donne le droit de traduire et de redistribuer : ce n'est
2155             pas un réglage d'affichage, et l'écran le dit avant le clic. */''}
2156        <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(_academieDroitsLisibles(r.droits))}</p>
2157      </div>
2158      <div class="shrink-0 flex flex-col items-end gap-1.5">
2159        <select onchange="window.academieLicence('${escHtml(r.id)}', this.value)" class="rounded-lg px-2 py-1 text-[max(0.75rem,12px)] outline-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)">${options}</select>
2160        ${r.url ? `<a href="${escHtml(r.url)}" target="_blank" rel="noopener" class="text-[max(0.6875rem,11px)]" style="color:var(--accent)">Ouvrir ↗</a>` : ''}
2161      </div>
2162    </div>
2163  </div>`
2164}
2165
2166/* L'IMPORTATEUR — VISIBLE SUR LA SOURCE OUVERTE, ET SEULEMENT SI ELLE SERT.
2167 *
2168 * Trois états, trois écrans, et ils ne se ressemblent pas : une source qui
2169 * n'est pas un fournisseur de cours n'a rien à importer ; une source à
2170 * configurer dit ce qui manque, NOMMÉMENT ; une source branchée cherche. Un
2171 * champ de recherche affiché sur une instance non configurée rendrait un
2172 * catalogue vide, et « vide » se lit comme « il n'y a rien là-bas ».
2173 */
2174function _academieImportateur(sources, ouverte) {
2175  const src = (sources || []).find(x => x && x.id === ouverte)
2176  if (!src || !ACADEMIE_FOURNISSEURS.includes(src.id)) return ''
2177  if (src.etat !== 'connectee') {
2178    return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px dashed var(--warn)">
2179      <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">${escHtml(src.nom)} n'est pas encore joignable</p>
2180      <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Le connecteur est écrit et éprouvé — il manque l'adresse de l'instance${src.id === 'moodle' ? ' et son jeton de service web' : ''}. Renseignez ${escHtml(src.id === 'moodle' ? 'CREA_MOODLE_BASE et CREA_MOODLE_TOKEN' : 'CREA_OPENEDX_BASE')} sur le serveur, puis rechargez.</p>
2181    </div>`
2182  }
2183  const rech = STATE.academieRecherche && STATE.academieRecherche.fournisseur === src.id ? STATE.academieRecherche : null
2184  const resultats = !rech ? ''
2185    : rech.cours === undefined ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--t-dim)">Lecture du catalogue…</p>`
2186    : rech.cours === null ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--danger)">${escHtml(rech.refus || 'catalogue indisponible')}</p>`
2187    : !rech.cours.length ? `<p class="text-[max(0.8125rem,13px)] py-2" style="color:var(--t-faint)">Aucun cours ne correspond dans cette instance.</p>`
2188    : `<div class="space-y-1.5 mt-2">${rech.cours.slice(0, 25).map(c => {
2189        const enCours = STATE.academieImportEnCours === String(c.id)
2190        return `<div class="flex items-center gap-3 rounded-xl px-3 py-2" style="background:var(--bg-primary);border:1px solid var(--border)">
2191          <div class="min-w-0 flex-1">
2192            <p class="text-[max(0.8125rem,13px)] font-semibold truncate" style="color:var(--t-primary)">${escHtml(c.titre || c.id)}</p>
2193            <p class="text-[max(0.6875rem,11px)] truncate" style="color:var(--t-faint)">
2193${escHtml([c.id, c.langue, c.categorie].filter(Boolean).join(' · '))}</p>
2194          </div>
2195          <button onclick="window.academieImporterCours('${escHtml(src.id)}','${escHtml(String(c.id))}')" ${enCours ? 'disabled' : ''} class="shrink-0 rounded-full px-3 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="${enCours ? 'background:var(--bg-input);color:var(--t-faint);border:1px solid var(--border)' : 'background:var(--accent);color:#fff;border:none'}">${enCours ? 'Import…' : 'Importer'}</button>
2196        </div>`
2197      }).join('')}</div>`
2198  return `<div class="rounded-2xl p-4 mb-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
2199    <p class="text-[max(0.875rem,14px)] font-bold mb-1" style="color:var(--t-primary)">Importer un cours depuis ${escHtml(src.nom)}</p>
2200    <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-dim)">Le cours entre découpé en ressources — une par section — avec sa licence à renseigner ci-dessous.</p>
2201    <div class="flex gap-2">
2202      <input id="academie-cours-q" value="${escHtml(rech ? rech.q : '')}" onkeydown="if(event.key==='Enter')window.academieChercherCours('${escHtml(src.id)}',this.value)" placeholder="Chercher un cours — vide = tout le catalogue" class="flex-1 min-w-0 rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" />
2203      <button onclick="window.academieChercherCours('${escHtml(src.id)}',document.getElementById('academie-cours-q').value)" class="shrink-0 rounded-lg px-3.5 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Chercher</button>
2204    </div>
2205    ${rech && rech.filtreLocal && rech.q ? `<p class="text-[max(0.6875rem,11px)] mt-1.5" style="color:var(--t-faint)">Le filtre est appliqué ici, pas par l'instance : elle rend tout son catalogue visible.</p>` : ''}
2206    ${STATE.academieDernierImport ? `<p class="text-[max(0.75rem,12px)] mt-2" style="color:${STATE.academieDernierImport.posees ? 'var(--success)' : 'var(--warn)'}">« ${escHtml(STATE.academieDernierImport.cours)} » : ${STATE.academieDernierImport.posees} ressource${STATE.academieDernierImport.posees > 1 ? 's' : ''} dans l'Académie${STATE.academieDernierImport.posees ? '' : ' — ce cours n’a aucune section remplie'}${STATE.academieDernierImport.refusees ? ` · ${STATE.academieDernierImport.refusees} refusée(s) par le registre` : ''}.</p>` : ''}
2207    ${resultats}
2208  </div>`
2209}
2210
2211function _academieRegistre() {
2212  const sources = STATE.academieSources
2213  const ouverte = STATE.academieSourceOuverte || null
2214  const parSource = STATE.academieParSource || {}
2215  const refus = STATE._academieRefus
2216    ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--danger) 10%,transparent);border:1px solid var(--danger);color:var(--danger)">${escHtml(STATE._academieRefus)}</p>`
2217    : ''
2218  if (sources === undefined) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture du registre…</p>`
2219  if (sources === null) return refus + `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Registre indisponible${STATE._academieSourcesErreur ? ' — ' + escHtml(STATE._academieSourcesErreur) : ''}.</p>`
2220
2221  const cartes = `<div class="grid gap-2 mb-6" style="grid-template-columns:repeat(auto-fill,minmax(220px,1fr))">
2222    <button onclick="window.academieOuvrirSource('')" class="rounded-2xl p-3 text-left" style="background:var(--bg-secondary);border:1px solid ${ouverte ? 'var(--border)' : 'var(--accent)'}">
2223      <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Toutes les sources</p>
2224      <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${Object.values(parSource).reduce((a, b) => a + b, 0)} ressources</p>
2225    </button>
2226    ${sources.map(s => {
2227      const n = parSource[s.id] || 0
2228      const on = s.etat === 'connectee'
2229      return `<button onclick="window.academieOuvrirSource('${escHtml(s.id)}')" class="rounded-2xl p-3 text-left" style="background:var(--bg-secondary);border:1px solid ${ouverte === s.id ? 'var(--accent)' : 'var(--border)'}">
2230        <p class="text-[max(0.8125rem,13px)] font-bold flex items-center gap-1.5" style="color:var(--t-primary)"><span class="shrink-0 rounded-full" style="width:7px;height:7px;background:${on ? 'var(--success)' : s.etat === 'a-configurer' ? 'var(--warn)' : 'var(--t-faint)'}"></span>${escHtml(s.nom)}</p>
2231        <p class="text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${n} ressource${n > 1 ? 's' : ''} · ${escHtml(s.licence || 'inconnue')}</p>
2232      </button>`
2233    }).join('')}
2234  </div>`
2235
2236  const liste = STATE.academieRessources
2237  const corps = liste === undefined
2238    ? `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--t-dim)">Lecture des ressources…</p>`
2239    : liste === null
2240      ? `<p class="py-3 text-[max(0.8125rem,13px)]" style="color:var(--danger)">Ressources indisponibles${STATE._academieRegistreErreur ? ' — ' + escHtml(STATE._academieRegistreErreur) : ''}.</p>`
2241      : liste.length
2242        ? `<div class="space-y-2">${liste.map(_academieLigneRessource).join('')}</div>`
2243        /* « AUCUNE RESSOURCE » ET « RIEN N'A ENCORE ÉTÉ IMPORTÉ » NE SE
2244           RESSEMBLENT PAS. Le registre neuf est vide — le dire, et dire par où
2245           on le remplit, vaut mieux qu'un cadre blanc. */
2246        : `<div class="rounded-2xl p-5 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)">
2247            <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Rien d'importé pour l'instant</p>
2248            <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Demandez à CREA de chercher un cours dans une source branchée — elle lit le catalogue et son plan, et vous décidez de ce qui entre ici.</p>
2249          </div>`
2250
2251  /* CE QUE LE SERVEUR A ÉCARTÉ SE DIT — ET SE DIT ICI DÉSORMAIS.
2252     Cette phrase vivait dans la rangée de l'aperçu, qui a quitté la vue
2253     client. Elle ne s'adresse qu'au propriétaire : une source de départ
2254     refusée (outil retiré du catalogue, par exemple) rendrait sinon un
2255     registre plus court sans que personne ne sache pourquoi. */
2256  const ecartees = (STATE._academieEcartees || []).length
2257    ? `<p class="text-[max(0.6875rem,11px)] mt-3" style="color:var(--warn)">${(STATE._academieEcartees || []).length} source(s) de départ écartée(s) par le serveur : ${escHtml((STATE._academieEcartees || []).map(x => x.id + ' (' + x.raison + ')').join(', '))}.</p>`
2258    : ''
2259  return refus + _academieFormulaireSource() + cartes + _academieImportateur(sources, ouverte) + corps + ecartees
2260}
2261
2262/* ── VUE 1 : L'APERÇU ───────────────────────────────────────────────────── */
2263/* ══════════════════════════════════════════════════════════════════════════
2264 * LES RÉGLAGES DE L'ACADÉMIE — UNE PAGE, PAS UN FOND DE PAGE
2265 * ══════════════════════════════════════════════════════════════════════════
2266 *
2267 * « Administration, le registre, et cetera. Ça, il faut venir créer une forme
2268 *   de page pour ces réalités-là aussi, donc de réglages. Ce n'est pas juste
2269 *   des boutons en gros “déposer mon cours”. »
2270 *
2271 * C'étaient quatre blocs empilés SOUS le catalogue : le dépôt, la porte du
2272 * registre, la newsletter, le bilan. Il fallait faire défiler toute la page
2273 * pour les voir, et une fois vus ils renvoyaient en haut. Un réglage qui se
2274 * trouve en descendant n'est pas rangé : il traîne.
2275 *
2276 * Ils ont maintenant leur page, atteinte par un bouton d'en-tête, et la
2277 * frontière du registre est celle qui existait déjà (`isClientView`) — la
2278 * chaîne d'approvisionnement ne se montre pas au client.
2279 */
2280/* ══════════════════════════════════════════════════════════════════════════
2281 * LE BILAN D'APPRENTISSAGE — CE QU'ON A FAIT, ET CE QU'ON A CROISÉ
2282 * ══════════════════════════════════════════════════════════════════════════
2283 *
2284 * « Publier un bilan d'apprentissage, ça aussi, c'est un autre système encore
2285 *   à venir imaginer. »
2286 *
2287 * C'était un bouton « Configurer » qui écrivait une demande en brouillon dans
2288 * le chat : on lui demandait de poser un rappel, et il n'y avait aucun bilan
2289 * derrière. Un rappel de relire quelque chose qui n'existe pas.
2290 *
2291 * Maintenant, une page, et RIEN N'Y EST INVENTÉ. Tout ce qu'elle montre est
2292 * déjà compté ailleurs — les leçons faites, les étapes cochées, et ce que le
2293 * flux a proposé sans qu'on s'y arrête. Le poste (src/crea-apprendre.js) les
2294 * rassemble et ne juge pas ; l'écran les met côte à côte et ne félicite pas.
2295 * Un bilan qui dirait « bon rythme » sur un compteur à zéro serait la première
2296 * chose qu'on cesserait de lire.
2297 *
2298 * LA TROISIÈME COLONNE EST LA PLUS UTILE, et elle n'existait nulle part : ce
2299 * qu'on a CROISÉ en travaillant sans s'arrêter. C'est exactement ce qu'un
2300 * bilan doit rappeler — pas ce qu'on a fait, ce qu'on a remis à plus tard.
2301 */
2302function _academieBilan() {
2303  const b = window.apprendreBilan()
2304  if (b.vide) {
2305    return `<div class="rounded-2xl p-6 text-center" style="background:var(--bg-secondary);border:1px dashed var(--border)">
2306      <p class="text-[max(0.9375rem,15px)] font-bold" style="color:var(--t-primary)">Rien à rapporter pour l’instant</p>
2307      <p class="text-[max(0.8125rem,13px)] mt-1" style="color:var(--t-dim)">Ce bilan se remplit tout seul : les leçons que vous faites, les étapes que vous cochez, et ce que CREA vous propose pendant que vous travaillez. Il ne devine rien — tant qu’il n’y a rien, il le dit.</p>
2308    </div>`
2309  }
2310  const carte = (o, sousTitre, clic) => _academieCarteCouchee({
2311    clic, o, cote: 96, titre: o.nom, quoi: sousTitre,
2312    meta: o.faites + ' sur ' + o.total, part: o.part,
2313  })
2314  const chiffres = `<div class="grid gap-2.5 mb-4" style="grid-template-columns:repeat(auto-fit,minmax(180px,1fr))">
2315    ${[['Leçons faites', b.lecons], ['Étapes cochées', b.etapes], ['Croisé sans s’arrêter', b.croises.length]].map(([l, n]) => `
2316      <div class="rounded-2xl px-4 py-3.5" style="background:var(--bg-input);border:1px solid var(--border)">
2317        <p class="text-[max(1.625rem,26px)] font-bold leading-none" style="color:var(--accent)">${n}</p>
2318        <p class="text-[max(0.75rem,12px)] mt-1.5" style="color:var(--t-faint)">${escHtml(l)}</p>
2319      </div>`).join('')}
2320  </div>`
2321
2322  const matieres = b.matieres.length ? _academieRangee('À l’École', 'Les matières que vous avez ouvertes.',
2323    `<div style="${ACADEMIE_GRILLE}">${b.matieres.map(m => carte(m, '', `window.academieSection('education');window.ecoleVue('matiere','${escHtml(m.id)}')`)).join('')}</div>`) : ''
2324
2325  const parcours = b.parcours.length ? _academieRangee('Dans les tutoriels', 'Les parcours que vous avez commencés.',
2326    `<div style="${ACADEMIE_GRILLE}">${b.parcours.map(p => carte(p, '', `window.academieSection('tutoriels');window.tutoVue('parcours','${escHtml(p.id)}')`)).join('')}</div>`) : ''
2327
2328  const croises = b.croises.length ? _academieRangee('Croisé en travaillant', 'CREA vous l’a proposé pendant que vous faisiez autre chose. Ce n’est pas fait.',
2329    `<div class="rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid var(--border)">${b.croises.map((c, i) => `
2330      <button onclick="window.apprendreOuvrir('${escHtml(c.id)}')" class="w-full text-left flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}">
2331        <span class="shrink-0" style="color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span>
2332        <span class="flex-1 min-w-0">
2333          <span class="block text-[max(0.90625rem,14.5px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(c.titre)}</span>
2334          <span class="block text-[max(0.78125rem,12.5px)] leading-snug mt-0.5" style="color:var(--t-dim)">${escHtml(c.quoi)}</span>
2335        </span>
2336        <span class="shrink-0 text-[max(0.71875rem,11.5px)] font-semibold" style="color:var(--t-faint)">${c.minutes} min</span>
2337        ${_icon('chevronRight', 'w-4 h-4 shrink-0')}
2338      </button>`).join('')}</div>`) : ''
2339
2340  return chiffres + croises + matieres + parcours
2341}
2342
2343function _academieReglages() {
2344  const bilan = `<button data-studio-template-card="chat" data-template-exemple="${escHtml(ACADEMIE_BILAN)}" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2345    <div class="flex items-center gap-3">
2346      <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('calendar', 'w-4 h-4')}</span>
2347      <div class="min-w-0 flex-1">
2348        <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Planifier un bilan d'apprentissage</p>
2349        <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Choisissez quoi travailler et à quelle fréquence. La demande s'ouvre en brouillon dans le chat : c'est CREA qui pose le rappel, après votre confirmation.</p>
2350      </div>
2351      <span class="shrink-0 inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Poser le rappel ${_icon('chevronRight', 'w-3.5 h-3.5')}</span>
2352    </div>
2353  </button>`
2354  /* LE BILAN PASSE DEVANT SON RAPPEL. On venait proposer de POSER UN RAPPEL de
2355     relire un bilan qui n'existait pas : le bouton écrivait une demande en
2356     brouillon dans le chat, et c'était tout le système. Le bilan est
2357     maintenant une page, et le rappel vient après — c'est l'ordre des
2358     choses. */
2359  return _academieRangee('Votre bilan d’apprentissage', 'Ce que vous avez fait, et ce que vous avez croisé sans vous arrêter.', _academieBilan())
2360    + _academieRangee('Se poser un rendez-vous', 'Un rappel régulier pour revenir dessus. La demande s’ouvre en brouillon dans le chat : c’est CREA qui pose le rappel, après votre confirmation.', bilan)
2361    + _annoncesBloc('espaceAcademie')
2362    + _academieBlocDepot()
2363    + _academieAdministration()
2364}
2365
2366/* LE FOURNISSEUR NE SE MONTRE PAS AU CLIENT.
2367 *
2368 * L'aperçu portait une rangée « Où l'Académie va chercher » : sept sources,
2369 * leur état de connexion, l'aveu qu'Open edX est « à brancher », un formulaire
2370 * pour en ajouter une et un sélecteur de licence par ressource. C'est la
2371 * chaîne d'approvisionnement, affichée en vitrine. Aucune maison ne montre à
2372 * ses clients chez qui elle achète, ni lesquels de ses contrats ne sont pas
2373 * signés.
2374 *
2375 * Ce qui est dû au client reste : le CRÉDIT d'une œuvre — source, auteur,
2376 * licence — vit sur la fiche de la ressource, où il sert. Ce qui relève de
2377 * l'exploitation part derrière cette porte.
2378 *
2379 * `isClientView()` est le partage qui existe déjà dans le tronc (il vaut aussi
2380 * pour l'aperçu client d'un propriétaire) : on ne s'en écrit pas un second.
2381 */
2382function _academieAdministration() {
2383  if (isClientView()) return ''
2384  return _academieRangee('Administration · le registre des sources',
2385    "Visible du propriétaire seulement. Les sources branchées, ce qui en a été importé, et sous quelle licence.",
2386    `<button onclick="window.academieVue('registre')" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px dashed var(--border)">
2387      <div class="flex items-center gap-3">
2388        <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('plug', 'w-4 h-4')}</span>
2389        <div class="min-w-0 flex-1">
2390          <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Ouvrir le registre</p>
2391          <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Brancher une source, importer un cours, renseigner une licence.</p>
2392        </div>
2393        ${_icon('chevronRight', 'w-4 h-4 shrink-0')}
2394      </div>
2395    </button>`)
2396}
2397
2398/* ── VUE 2 : UN RÔLE ────────────────────────────────────────────────────── */
2399function _academiePageDeRole(r) {
2400  const prompts = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${r.prompts.map(_academieCartePrompt).join('')}</div>`
2401  const cas = _academieCatalogue().filter(x => x.role === r.id)
2402  const grille = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${cas.map(_academieCarte).join('')}</div>`
2403  return _academieRangee('Essayer un prompt', "Chaque prompt s'ouvre en brouillon dans son lieu : vous le modifiez avant de l'envoyer.", prompts)
2404    + _academieRangee("Cas d'usage", 'Un vrai enchaînement, étape par étape.', grille, _academieLienTout('ressources'))
2405}
2406
2407/* ── VUE 3 : UN LIEU ────────────────────────────────────────────────────── */
2408function _academiePageDeLieu(id) {
2409  const cat = _academieCatalogue().filter(r => r.ou === id)
2410  const commencer = cat.filter(r => r.type !== 'cas')
2411  const taches = cat.filter(r => r.type === 'cas')
2412  const parType = ['cours', 'tutoriels', 'cas'].map(t => ({ t, n: cat.filter(r => r.type === t).length }))
2413  const tout = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${parType.map(x => `
2414    <button onclick="window.academieVue('ressources');window.academieOnglet('${x.t}');window.academieFiltreLieu('${id}')" class="rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border);${x.n ? '' : 'opacity:.5'}">
2415      <span class="block text-[max(0.875rem,14px)] font-semibold" style="color:var(--t-primary)">${x.t === 'cours' ? 'Cours' : x.t === 'tutoriels' ? 'Tutoriels' : "Cas d'usage"}</span>
2416      <span class="block text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-faint)">${x.n === 0 ? 'aucun pour l’instant' : x.n + (x.t === 'cours' ? ' cours' : x.t === 'tutoriels' ? (x.n > 1 ? ' tutoriels' : ' tutoriel') : " cas d'usage")}</span>
2417    </button>`).join('')}</div>`
2418  const ouvrir = `<button onclick="window.academieOuvrir('${id}')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir ${escHtml(_academieNomDuLieu(id))}</button>`
2419  return (commencer.length ? _academieRangee('Commencer ici', '', `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(240px,1fr))">${commencer.map(_academieCarte).join('')}</div>`) : '')
2420    + (taches.length ? _academieRangee('Essayer une tâche', 'Un enchaînement complet, étape par étape.', `<div class="space-y-2.5">${taches.map(_academieLigne).join('')}</div>`, _academieLienTout('ressources')) : '')
2421    + _academieRangee('Tout', '', tout)
2422    + `<div class="mt-2">${ouvrir}</div>`
2423}
2424
2425/* ── VUE 4 : TOUTES LES RESSOURCES ──────────────────────────────────────── */
2426/* LES ONGLETS SONT DÉRIVÉS, COMME LES LIEUX — ET POUR LA MÊME RAISON.
2427   Une liste écrite à la main a deux façons de mentir, toutes deux invisibles à
2428   la relecture : un onglet qui ne rend rien, ou un type de ressource qu'AUCUN
2429   onglet ne montre. Le second est arrivé dès que l'import a existé : le
2430   registre connaît « exercice », « article », « vidéo », « jeu de données »,
2431   et les quatre n'avaient nulle part où aller. Ils seraient entrés dans le
2432   catalogue pour rester introuvables.
2433   « Tout » et « Webinaires » sont là quoi qu'il arrive : le premier ne filtre
2434   rien, le second ne vient pas du catalogue et dit lui-même qu'il est vide. */
2435const ACADEMIE_ONGLETS_LABELS = {
2436  tout: 'Tout', cours: 'Cours', tutoriels: 'Tutoriels', cas: "Cas d'usage",
2437  exercices: 'Exercices', documents: 'Documents', webinaires: 'Webinaires',
2438}
2439const ACADEMIE_ONGLETS_ORDRE = ['tout', 'cours', 'tutoriels', 'cas', 'exercices', 'documents', 'webinaires']
2440function _academieOngletsDuCatalogue() {
2441  const presents = new Set(_academieCatalogue().map(r => r.type))
2442  return ACADEMIE_ONGLETS_ORDRE
2443    .filter(id => id === 'tout' || id === 'webinaires' || presents.has(id))
2444    .map(id => ({ id, label: ACADEMIE_ONGLETS_LABELS[id] }))
2445}
2446function _academieRessources() {
2447  const onglets_ = _academieOngletsDuCatalogue()
2448  /* UN ONGLET QUI N'EXISTE PLUS RAMÈNE À « TOUT ». Il suffit d'avoir retiré la
2449     dernière ressource importée d'un genre en gardant l'onglet ouvert : le
2450     filtre porterait sur un type que plus rien ne porte, et la page dirait
2451     « Rien ne correspond » sans qu'aucun onglet n'ait l'air sélectionné. */
2452  const onglet = onglets_.some(o => o.id === (STATE.academieOnglet || 'tout')) ? (STATE.academieOnglet || 'tout') : 'tout'
2453  const lieu = STATE.academieLieu || null
2454  let liste = _academieCatalogue()
2455  if (onglet !== 'tout') liste = liste.filter(r => r.type === onglet)
2456  if (lieu) liste = liste.filter(r => r.ou === lieu)
2457  const onglets = `<div class="flex flex-wrap gap-1.5 mb-3">${onglets_.map(o => `
2458    <button onclick="window.academieOnglet('${o.id}')" class="rounded-lg px-3 py-1.5 text-[max(0.8125rem,13px)] font-medium" style="${onglet === o.id ? 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)' : 'background:transparent;border:1px solid transparent;color:var(--t-dim)'}">${escHtml(o.label)}</button>`).join('')}</div>`
2459  const filtres = `<div class="flex flex-wrap items-center gap-2 mb-5">
2460    <span class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2461      ${_icon('loupe', 'w-3.5 h-3.5')}
2462      <input id="academie-recherche" oninput="window.academieChercher(this.value)" placeholder="Chercher" class="bg-transparent outline-none text-[max(0.8125rem,13px)]" style="color:var(--t-primary);width:130px" />
2463    </span>
2464    ${/* QUATORZE PASTILLES DE LIEU DÉBORDAIENT LA LIGNE — mesuré à l'écran : le
2465         compteur était rejeté sous elles, et les derniers lieux se coupaient en
2466         trois mots. Une liste déroulante native tient sur une ligne, se déroule
2467         au clavier, et ne relance pas le rendu tant qu'on ne choisit rien. */''}
2468    <span class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2469      <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">Lieu</span>
2470      <select onchange="window.academieFiltreLieu(this.value || null)" class="bg-transparent outline-none text-[max(0.8125rem,13px)] font-medium" style="color:var(--t-primary);border:none">
2471        <option value=""${lieu ? '' : ' selected'}>Tout</option>
2472        ${_academieLieux().map(id => `<option value="${escHtml(id)}"${lieu === id ? ' selected' : ''}>${escHtml(_academieTitreDuLieu(id))}</option>`).join('')}
2473      </select>
2474    </span>
2475    <span id="academie-compteur" class="ml-auto text-[max(0.8125rem,13px)]" style="color:var(--t-faint)">${escHtml(_academieCompte(onglet === 'webinaires' ? (STATE.academieSeances || []).length : liste.length))}</span>
2476  </div>`
2477  const vide = `<p id="academie-vide" class="text-center py-10 text-[max(0.8125rem,13px)]" style="color:var(--t-faint);${liste.length ? 'display:none' : ''}">Rien ne correspond. Essayez un autre mot, ou remettez le lieu sur « Tout ».</p>`
2478  /* LES WEBINAIRES NE SONT PAS DES LIGNES DE CATALOGUE. Une séance a une DATE
2479     et un état — à venir, rediffusion — que la ligne « titre · durée » ne sait
2480     pas porter, et un onglet vide se dit au lieu de rendre une liste vide. */
2481  if (onglet === 'webinaires') return onglets + filtres + _academieWebinaires()
2482  /* UN CATALOGUE AMPUTÉ SE DIT. Si la lecture des ressources importées a été
2483     refusée, `_academieImportees()` rend un tableau vide et la page est
2484     simplement PLUS COURTE — sans une erreur, sans un mot. On aurait cherché
2485     un cours importé, ne pas l'avoir trouvé, et conclu que l'import n'avait
2486     rien donné. C'est la différence entre « il n'y en a pas » et « je n'ai pas
2487     pu regarder », et elle ne se devine pas. */
2488  const amputee = STATE.academieImportees === null
2489    ? `<p class="rounded-xl px-3 py-2 mb-3 text-[max(0.8125rem,13px)]" style="background:color-mix(in srgb,var(--warn) 12%,transparent);border:1px solid var(--warn);color:var(--t-secondary)">Les ressources importées n'ont pas pu être lues — cette liste ne montre que le contenu propre à CREA.</p>`
2490    : ''
2491  return onglets + filtres + amputee + `<div class="space-y-2.5">${liste.map(_academieLigne).join('')}</div>` + vide
2492}
2493
2494/* ══════════════════════════════════════════════════════════════════════════
2495 * LE MOTEUR DE TRANSFORMATION — CÔTÉ ÉCRAN
2496 * ══════════════════════════════════════════════════════════════════════════
2497 *
2498 * La table des droits s'affichait et ne servait à rien. On lisait « traduire :
2499 * oui » sur une fiche, et aucun geste ne s'en servait. Ici, le droit décide :
2500 * une transformation que la licence refuse n'est pas cachée — elle est
2501 * MONTRÉE, éteinte, avec la phrase qui dit pourquoi. Cacher un refus le ferait
2502 * passer pour une fonction absente.
2503 *
2504 * LE SERVEUR DÉCIDE DEUX FOIS, ET ON NE S'EN PLAINT PAS : ce qui repart d'ici
2505 * a voyagé, et la première autorisation avec.
2506 */
2507window.academieChargerTransformations = async function (force) {
2508  if (STATE.academieTransformations !== undefined && !force) return
2509  STATE.academieTransformations = null
2510  try {
2511    const r = await fetch('/api/academie/transformations', { headers: internalAuth() })
2512    if (!r.ok) throw new Error('refus ' + r.status)
2513    const d = await r.json()
2514    STATE.academieTransformations = d.transformations || []
2515    STATE.academieLicencesDeposables = d.licencesDeposables || []
2516  } catch (e) {
2517    STATE.academieTransformations = null
2518  }
2519  render()
2520}
2521
2522window.academieViserTransformation = function (ressourceId, transformation) {
2523  STATE.academieTransfo = transformation ? { ressource: ressourceId, transformation, etape: 'choix' } : null
2524  STATE._academieRefus = ''
2525  render()
2526}
2527
2528window.academieLancerTransformation = async function () {
2529  const t = STATE.academieTransfo
2530  if (!t) return
2531  const def = (STATE.academieTransformations || []).find(x => x.id === t.transformation)
2532  const options = {}
2533  if (def && def.parametre) {
2534    const champ = document.getElementById('academie-transfo-param')
2535    options[def.parametre] = (champ && champ.value) || ''
2536  }
2537  t.options = options
2538  t.etape = 'verification'
2539  render()
2540
2541  const v = await _academiePoster('/api/academie/transformer/verifier', { ressource: t.ressource, transformation: t.transformation, options })
2542  if (!v) { t.etape = 'choix'; render(); return }
2543
2544  /* LE CORPS SE DEMANDE À L'UNITÉ — la liste ne le porte pas. Une ressource
2545     sans corps stocké (un cours Open edX dont on n'a que le plan) se
2546     transforme quand même : on le DIT à la consigne, au lieu de laisser le
2547     modèle combler le trou sans prévenir. */
2548  let corps = ''
2549  try {
2550    const r = await fetch('/api/academie/ressources/' + encodeURIComponent(t.ressource), { headers: internalAuth() })
2551    if (r.ok) { const d = await r.json(); corps = (d.ressource && d.ressource.contenu) || '' }
2552  } catch (e) { corps = '' }
2553
2554  t.etape = 'travail'
2555  t.licenceResultat = v.licenceResultat
2556  t.licenceImposee = v.licenceImposee
2557  render()
2558
2559  let texte = ''
2560  try {
2561    texte = await parlerAuModele({
2562      messages: [{ role: 'user', content: corps || "Cette ressource n'a pas de corps stocké dans le registre : travaille à partir de son titre et de sa description, et écris explicitement ce qui manque au lieu de le combler." }],
2563      systemPrompt: v.consigne,
2564      ...internalAuth(),
2565    }, { intention: 'utilitaire', surface: 'espaceAcademie', memoire: 'espaceAcademie', timeoutMs: 120000 })
2566  } catch (e) {
2567    STATE._academieRefus = 'la transformation n’a pas abouti — ' + (e.message || 'erreur inconnue')
2568    t.etape = 'choix'; render(); return
2569  }
2570
2571  const d = await _academiePoster('/api/academie/transformer/poser', {
2572    ressource: t.ressource, transformation: t.transformation, options, contenu: texte,
2573  })
2574  if (!d) { t.etape = 'choix'; render(); return }
2575  STATE.academieTransfo = null
2576  window.academieChargerImportees(true)
2577  window.academieChargerRessources(STATE.academieSourceOuverte || null)
2578  render()
2579}
2580
2581/* CE QUE LA LICENCE PERMET, ET CE QU'ELLE REFUSE — LES DEUX S'AFFICHENT. */
2582function _academieBlocTransformer(r) {
2583  const defs = STATE.academieTransformations
2584  if (!Array.isArray(defs) || !defs.length) return ''
2585  const d = r.droits || {}
2586  const enCours = STATE.academieTransfo && STATE.academieTransfo.ressource === r.id ? STATE.academieTransfo : null
2587  const def = enCours ? defs.find(x => x.id === enCours.transformation) : null
2588
2589  const puces = defs.map(t => {
2590    const permis = d[t.droit] === true
2591    const vise = enCours && enCours.transformation === t.id
2592    return `<button ${permis ? `onclick="window.academieViserTransformation('${escHtml(r.id)}','${vise ? '' : escHtml(t.id)}')"` : 'disabled'}
2593      title="${escHtml(permis ? t.quoi : 'La licence « ' + (r.licence || 'inconnue') + ' » ne le permet pas.')}"
2594      class="rounded-full px-3.5 py-1.5 text-[max(0.75rem,12px)] font-semibold"
2595      style="${permis ? (vise ? 'background:var(--accent);color:#fff;border:1px solid var(--accent)' : 'background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)') : 'background:var(--bg-secondary);border:1px dashed var(--border);color:var(--t-faint);cursor:not-allowed'}">${escHtml(t.label)}${permis ? '' : ' 🞫'}</button>`
2596  }).join('')
2597
2598  const refuses = defs.filter(t => d[t.droit] !== true)
2599  const pourquoi = refuses.length
2600    ? `<p class="text-[max(0.6875rem,11px)] mt-2" style="color:var(--t-faint)">${escHtml(refuses.map(t => t.label.toLowerCase()).join(', '))} — la licence « ${escHtml(r.licence || 'inconnue')} » ne le permet pas. Renseignez la licence si vous la connaissez.</p>`
2601    : ''
2602
2603  let travail = ''
2604  if (enCours && def) {
2605    if (enCours.etape === 'travail' || enCours.etape === 'verification') {
2606      travail = `<p class="text-[max(0.8125rem,13px)] mt-3" style="color:var(--t-dim)">${enCours.etape === 'verification' ? 'Vérification des droits…' : 'CREA travaille — le résultat entrera au registre sous « ' + escHtml(enCours.licenceResultat || '') + ' »' + (enCours.licenceImposee ? ', et cette licence est imposée par celle de la source.' : '.')}</p>`
2607    } else {
2608      travail = `<div class="flex flex-wrap items-end gap-2 mt-3">
2609        ${def.parametre ? `<label class="flex flex-col gap-1">
2610          <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(def.demandeParametre)}</span>
2611          <input id="academie-transfo-param" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border);width:220px" />
2612        </label>` : ''}
2613        <button onclick="window.academieLancerTransformation()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${escHtml(def.label)}</button>
2614      </div>`
2615    }
2616  }
2617
2618  return `<div class="rounded-2xl p-4 mt-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
2619    <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Transformer cette ressource</p>
2620    <p class="text-[max(0.75rem,12px)] mt-0.5 mb-2.5" style="color:var(--t-dim)">Ce que vous avez le droit de faire vient de la licence, pas d'un réglage. Le résultat entre au registre comme une œuvre dérivée, créditée.</p>
2621    <div class="flex flex-wrap gap-2">${puces}</div>
2622    ${pourquoi}
2623    ${travail}
2624  </div>`
2625}
2626
2627/* ══════════════════════════════════════════════════════════════════════════
2628 * LE DÉPÔT — LE SEUL ENDROIT OÙ C'EST L'AUTEUR QUI CHOISIT LA LICENCE
2629 * ══════════════════════════════════════════════════════════════════════════ */
2630window.academieDepotForm = function (ouvert) { STATE.academieDepotOuvert = !!ouvert; STATE._academieRefus = ''; render() }
2631
2632window.academieDeposer = async function () {
2633  const lu = (id) => (document.getElementById(id) || {}).value || ''
2634  const d = await _academiePoster('/api/academie/deposer', {
2635    titre: lu('depot-titre'), quoi: lu('depot-quoi'), auteur: lu('depot-auteur'),
2636    langue: lu('depot-langue'), minutes: Number(lu('depot-minutes')),
2637    licence: lu('depot-licence'), contenu: lu('depot-contenu'),
2638  })
2639  if (d) {
2640    STATE.academieDepotOuvert = false
2641    window.academieChargerImportees(true)
2642    window.academieChargerRessources(STATE.academieSourceOuverte || null)
2643  } else render()
2644}
2645
2646function _academieBlocDepot() {
2647  const licences = STATE.academieLicencesDeposables || []
2648  if (!STATE.academieDepotOuvert) {
2649    return _academieRangee('Déposer un cours', "Votre cours entre dans l'Académie sous la licence que VOUS choisissez — c'est la seule chose du registre que l'auteur décide, parce que c'est son œuvre.",
2650      `<button onclick="window.academieDepotForm(true)" class="w-full text-left rounded-2xl p-4 transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px dashed var(--border)">
2651        <div class="flex items-center gap-3">
2652          <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon('plus', 'w-4 h-4')}</span>
2653          <div class="min-w-0 flex-1">
2654            <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Déposer mon cours</p>
2655            <p class="text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-dim)">Titre, contenu, licence. Vous pouvez le retirer à tout moment — personne d'autre ne le peut.</p>
2656          </div>
2657          ${_icon('chevronRight', 'w-4 h-4 shrink-0')}
2658        </div>
2659      </button>`)
2660  }
2661  const champ = (id, lab, ph, type) => `<label class="flex flex-col gap-1">
2662    <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">${escHtml(lab)}</span>
2663    <input id="${id}" type="${type || 'text'}" placeholder="${escHtml(ph || '')}" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)" />
2664  </label>`
2665  return _academieRangee('Déposer un cours', "La licence que vous choisissez dit aux autres ce qu'ils pourront en faire — la traduire, l'adapter, la republier. Elle ne se change pas dans leur dos.",
2666    `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
2667      <div class="grid gap-2.5 mb-2.5" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr))">
2668        ${champ('depot-titre', 'Titre du cours', 'Ce qu’il apprend')}
2669        ${champ('depot-auteur', 'Votre nom d’auteur', 'Affiché dans le crédit')}
2670        ${champ('depot-langue', 'Langue', 'fr')}
2671        ${champ('depot-minutes', 'Durée en minutes', '30', 'number')}
2672      </div>
2673      ${champ('depot-quoi', 'En une phrase', 'Ce que la personne saura faire à la fin')}
2674      <label class="flex flex-col gap-1 mt-2.5">
2675        <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">Le cours</span>
2676        <textarea id="depot-contenu" rows="6" placeholder="Le contenu de votre cours." class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border);resize:vertical"></textarea>
2677      </label>
2678      <label class="flex flex-col gap-1 mt-2.5">
2679        <span class="text-[max(0.6875rem,11px)] font-semibold" style="color:var(--t-dim)">Sous quelle licence le donnez-vous ?</span>
2680        <select id="depot-licence" class="rounded-lg px-3 py-2 text-[max(0.8125rem,13px)] bg-transparent outline-none" style="color:var(--t-primary);border:1px solid var(--border)">
2681          <option value="">— choisissez —</option>
2682          ${licences.map(l => `<option value="${escHtml(l.id)}">${escHtml(l.nom)}</option>`).join('')}
2683        </select>
2684      </label>
2685      <div class="flex gap-2 mt-3">
2686        <button onclick="window.academieDeposer()" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Déposer</button>
2687        <button onclick="window.academieDepotForm(false)" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">Annuler</button>
2688      </div>
2689    </div>`)
2690}
2691
2692/* ── VUE 5 : LA FICHE D'UNE RESSOURCE ───────────────────────────────────── */
2693
2694/* L'ATTRIBUTION N'EST PAS DE LA PLOMBERIE — ELLE RESTE.
2695 *
2696 * Le CATALOGUE DES FOURNISSEURS ne regarde pas le client : savoir que CREA
2697 * parle à telle instance, que telle autre est « à configurer », c'est une
2698 * affaire interne. Mais le CRÉDIT d'une œuvre, lui, est dû : une ressource en
2699 * CC BY doit dire d'où elle vient et sous quelle licence, sans quoi la
2700 * republier est une violation. La fiche porte donc la source, la licence,
2701 * l'auteur et le lien vers l'original — et l'écran d'administration porte le
2702 * reste.
2703 *
2704 * Ce que la fiche NE fait pas : traduire, adapter, recomposer. Les droits sont
2705 * affichés parce qu'ils se lisent AVANT d'agir ; le moteur qui agira n'existe
2706 * pas encore, et une fiche qui promettrait « traduire » mentirait.
2707 */
2708function _academieFicheImportee(r) {
2709  const d = r.droits || {}
2710  const permis = [d.traduire && 'traduire', d.adapter && 'adapter', d.redistribuer && 'redistribuer', d.commercial && 'usage commercial'].filter(
2710Boolean)
2711  /* LE NOM VIENT DES CRÉDITS, PAS DE LA LISTE DES SOURCES. Cette liste n'est
2712     plus lue en vue client — la fiche aurait affiché « moodle » au lieu de
2713     « Moodle », et le crédit dû n'aurait pas été rendu. */
2714  const nomSource = (STATE.academieCredits || {})[r.sourceId] || r.sourceId
2715  const ligne = (t, v) => v ? `<div class="flex gap-2 text-[max(0.8125rem,13px)]"><span style="color:var(--t-faint);min-width:92px">${escHtml(t)}</span><span style="color:var(--t-secondary)">${escHtml(v)}</span></div>` : ''
2716  return `<div class="rounded-2xl p-5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2717    <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">${escHtml(r.typeLabel)}${r.minutes ? ' · ' + r.minutes + ' min' : ''}</p>
2718    ${r.quoi ? `<p class="text-[max(0.875rem,14px)] mb-4" style="color:var(--t-dim)">${escHtml(r.quoi)}</p>` : ''}
2719    <div class="space-y-1.5 mb-4">
2720      ${ligne('Source', nomSource)}
2721      ${ligne('Auteur', r.auteur)}
2722      ${ligne('Langue', r.langue ? String(r.langue).toUpperCase() : '')}
2723      ${ligne('Licence', r.licence)}
2724      ${ligne('Vous pouvez', permis.length ? permis.join(', ') : 'lire et citer')}
2725      ${d.attribution ? ligne('Obligation', 'citer la source et l’auteur') : ''}
2726    </div>
2727    ${_academieBlocTransformer(r)}
2728    <div class="flex flex-wrap gap-2 mt-4">
2729      <button onclick="window.academieOuvrir('ecole')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir l’École</button>
2730      ${r.url ? `<a href="${escHtml(r.url)}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary);text-decoration:none">Voir l’original ${_icon('externe', 'w-3.5 h-3.5')}</a>` : ''}
2731    </div>
2732  </div>`
2733}
2734
2735function _academieFiche(cle) {
2736  const [type, id] = String(cle || '').split(':')
2737  /* UNE RESSOURCE IMPORTÉE A SA FICHE, ET C'EST LE PREMIER TEST.
2738     Elle porte le type « cours » sans être dans aucune table écrite : la
2739     recherche par table échouait et rendait l'accueil. Cliquer un cours
2740     importé ramenait donc en arrière — le MÊME défaut, muet, que la porte des
2741     matières. C'est aussi le SEUL « cours » qui reste : les deux cours en
2742     vedette écrits à la main sont partis avec ce qu'ils doublaient. */
2743  const imp = _academieImportees().find(r => r.type === type && r.id === id)
2744  if (imp) return _academieFicheImportee(imp)
2745  /* UN TUTORIEL N'A PAS DE FICHE : IL A SON ÉCRAN. Il en avait une — un pavé
2746     qui recopiait les étapes en liste morte, sans progression et sans case à
2747     cocher, pendant que l'écran du tutoriel savait déjà tout faire. Deux vues
2748     du même contenu, dont une qui ne retenait rien de ce qu'on y faisait.
2749     La clé du catalogue EST celle de l'écran (`parcours/tutoriel`) : on la lui
2750     passe telle quelle. */
2751  if (type === 'tutoriels') return _tutoTutoriel(id)
2752  /* LE TYPE « cours » N'EXISTE PLUS EN PROPRE. Il ne reste que les cours
2753     IMPORTÉS, traités plus haut par leur fiche : celle-ci porte le crédit dû à
2754     l'œuvre, ce que la fiche écrite à la main ne faisait pas. */
2755  const c = ACADEMIE_CAS.find(x => x.id === id); if (!c) return _academieTutoriels()
2756  const role = ACADEMIE_ROLES.find(r => r.id === c.role)
2757  return `<div class="rounded-2xl p-5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2758    <p class="text-[max(0.75rem,12px)] mb-3" style="color:var(--t-faint)">Cas d'usage · ${escHtml(role ? role.label : '')} · ${c.minutes} min</p>
2759    <p class="text-[max(0.875rem,14px)] mb-4" style="color:var(--t-dim)">${escHtml(c.quoi)}</p>
2760    <ol class="space-y-2.5 mb-5">${c.etapes.map((e, i) => `<li class="flex gap-3">
2761      <span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[max(0.625rem,10px)] font-bold" style="background:var(--accent);color:#fff">${i + 1}</span>
2762      <span class="text-[max(0.8125rem,13px)] leading-snug" style="color:var(--t-secondary)">${escHtml(e)}</span>
2763    </li>`).join('')}</ol>
2764    <button onclick="window.academieOuvrir('${c.ou}')" class="rounded-full px-4 py-2 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir ${escHtml(_academieNomDuLieu(c.ou))}</button>
2765  </div>`
2766}
2767
2768/* ══════════════════════════════════════════════════════════════════════════
2769 * LES CARTES ILLUSTRÉES DE L'ACADÉMIE — UN SEUL JEU, DEUX SECTIONS
2770 * ══════════════════════════════════════════════════════════════════════════
2771 *
2772 * Elles s'appelaient `_ecole*` et vivaient dans le bloc de l'École, parce que
2773 * l'École était seule à en avoir. Les tutoriels reçoivent la même colonne et
2774 * les mêmes cartes : les laisser sous un nom d'École aurait donné, au premier
2775 * ajustement, DEUX jeux de cartes qui se ressemblent — celui qu'on retouche et
2776 * celui qu'on oublie. C'est le canal parallèle habituel, en version visuelle.
2777 */
2778/* LA CARTE ILLUSTRÉE — le geste des espaces, pas un second.
2779   `/api/image` sert déjà les cartes de Découvrir, Santé et Brevets. Le dégradé
2780   n'est pas un ornement : c'est ce qu'on voit pendant que la photo arrive, et
2781   ce qui reste si elle n'arrive jamais. Sans lui, un rectangle blanc. */
2782/* LA PHOTO EST UN CARRÉ, ET ELLE EST DEMANDÉE CARRÉE.
2783   Elle était large et basse (520 × 320), posée EN HAUT d'une carte, le titre
2784   dessous. Signalé, et juste : « ça ne permet pas de voir bien les photos ». Un
2785   bandeau de 116 px de haut recadre `object-cover` sur une bande étroite au
2786   milieu — on perd le sujet, et il ne reste qu'une texture.
2787   Un carré demandé carré n'est pas recadré du tout : on voit ce qui a été
2788   généré. Et la même image sert de 96 à 160 px sans repasser au fournisseur,
2789   puisque la clé du cache est le prompt et les DIMENSIONS DEMANDÉES. */
2790function _academieVignetteCarree(o, cote, badge) {
2791  const src = '/api/image?prompt=' + encodeURIComponent(o.img) + '&width=640&height=640&seed=' + (o.seed || 1)
2792  return `<span class="block relative overflow-hidden shrink-0" style="width:${cote}px;height:${cote}px;border-radius:12px;background:linear-gradient(135deg,${o.c1},${o.c2})">
2793    <img src="${escHtml(src)}" loading="lazy" alt="" class="absolute inset-0 w-full h-full object-cover" onerror="this.style.display='none'">
2794    ${badge ? `<span class="absolute bottom-1.5 left-1.5 right-1.5 rounded-md px-1.5 py-0.5 text-[max(0.625rem,10px)] font-bold text-center truncate" style="background:rgba(255,255,255,.94);color:#111">${escHtml(badge)}</span>` : ''}
2795  </span>`
2796}
2797
2798/* LA CARTE COUCHÉE — LA PHOTO À GAUCHE, TOUT LE RESTE À CÔTÉ.
2799   « L'idéal, ça aurait été la photo dans un carré et son titre à côté. Comme
2800   ça, ça laisse la pleine ampleur à la photo, et tout ce qu'il y a à écrire à
2801   côté. » C'est exactement ce que fait cette carte, et c'est aussi ce qui
2802   remplit la largeur : une carte debout ne peut pas dépasser la largeur de sa
2803   photo, donc trois colonnes étroites et beaucoup de blanc de chaque côté.
2804   Couchée, elle prend 480 px et le texte respire. */
2805function _academieCarteCouchee({ clic, o, cote, titre, quoi, meta, part, badge, bordure }) {
2806  return `<button onclick="${clic}" class="text-left flex items-center gap-3.5 p-3 rounded-2xl transition-colors hover:bg-black/[.035]" style="background:var(--bg-input);border:1px solid ${bordure || 'var(--border)'}">
2807    ${_academieVignetteCarree(o, cote || 104, badge)}
2808    <span class="flex-1 min-w-0 flex flex-col gap-1">
2809      <span class="block text-[max(0.9375rem,15px)] font-bold leading-snug truncate" style="color:var(--t-primary)">${escHtml(titre)}</span>
2810      ${quoi ? `<span class="block text-[max(0.78125rem,12.5px)] leading-snug" style="color:var(--t-dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${escHtml(quoi)}</span>` : ''}
2811      ${meta ? `<span class="block text-[max(0.6875rem,11px)] mt-0.5" style="color:var(--t-faint)">${escHtml(meta)}</span>` : ''}
2812      ${part === undefined ? '' : `<span class="block mt-1">${_academieBarre(part)}</span>`}
2813    </span>
2814  </button>`
2815}
2816
2817/* LA GRILLE DE L'ÉCOLE. `minmax(420px, 1fr)` et pas trois colonnes figées :
2818   sur la largeur du plan de travail ça donne deux à trois cartes couchées
2819   lisibles, et une seule colonne sur un téléphone — sans écrire un seul point
2820   de rupture. */
2821const ACADEMIE_GRILLE = 'display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(420px,1fr))'
2822
2823
2824function _academieBarre(part, epaisseur) {
2825  return `<span class="block rounded-full overflow-hidden" style="height:${epaisseur || 4}px;background:var(--bg-tertiary)">
2826    <span class="block h-full rounded-full" style="width:${Math.max(0, Math.min(100, part))}%;background:var(--accent)"></span>
2827  </span>`
2828}
2829
2830/* ══════════════════════════════════════════════════════════════════════════
2831 * LES TUTORIELS — QUATRE ÉCRANS, LA MÊME COLONNE QUE L'ÉCOLE
2832 * ══════════════════════════════════════════════════════════════════════════
2833 *
2834 * « La partie école commence à être propre toute la page, mais la partie
2835 *   tutoriel elle n'est pas encore à la même portée que la partie école. »
2836 *
2837 * Avant : quatre fiches à plat, sans progression, sans image, sans ordre — et
2838 * dessous, empilés, un dépôt, une administration, une newsletter et un bilan.
2839 * La page se lisait comme un fond de tiroir.
2840 *
2841 * Maintenant, dans l'ordre où on les traverse :
2842 *
2843 *   ACCUEIL   les séances EN HAUT, la reprise, les huit domaines
2844 *   DOMAINE   ses parcours, chacun avec sa progression
2845 *   PARCOURS  ses tutoriels, dans l'ordre
2846 *   TUTORIEL  ses étapes, qu'on coche — et le lieu où les faire
2847 *
2848 * ── POURQUOI LES ÉTAPES SE COCHENT ICI, ET N'ONT PAS D'ÉCRAN ─────────────
2849 *
2850 * Une leçon de l'École est une page : on l'ouvre, on la lit, elle dure. Une
2851 * étape de tutoriel est une ligne — « le trombone joint un fichier ». Lui
2852 * donner un écran serait un clic pour une phrase. On la coche là où on la lit,
2853 * et la progression est du même grain que le geste.
2854 *
2855 * ── LES SÉANCES SONT EN HAUT, ET C'EST UNE CORRECTION ────────────────────
2856 *
2857 * « Séance en direct, ça, c'est à mettre en tête, ce n'est pas à mettre en
2858 *   bas. » Une séance a une DATE : passé l'heure, la montrer ne sert plus à
2859 *   rien. Un bloc daté qu'il faut faire défiler pour voir est un bloc qu'on
2860 *   découvre le lendemain.
2861 */
2862const TUTO_VUES = ['accueil', 'domaine', 'parcours', 'tutoriel']
2863
2864window.tutoVue = function (vue, cle) {
2865  STATE.tutoVue = TUTO_VUES.includes(vue) ? vue : 'accueil'
2866  STATE.tutoCle = cle || null
2867  save(); render()
2868}
2869
2870/* COCHER UNE ÉTAPE NE FAIT PAS QUITTER LE TUTORIEL. C'est la différence avec
2871   la leçon, qui enchaîne : ici les étapes se lisent ensemble, et sauter à la
2872   suivante ferait perdre celle qu'on vient de faire des yeux. */
2873window.tutoEtape = function (cle) {
2874  window.tutoMarquer(cle)
2875  save(); render()
2876}
2877
2878/* LE FIL D'ARIANE — il dit où l'on est, et chaque cran ramène. Le même que
2879   celui de l'École, et pour la même raison : sans lui, on descend trois étages
2880   et le seul retour est le bouton du navigateur. */
2881function _tutoFil() {
2882  const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil'
2883  if (vue === 'accueil') return ''
2884  const cle = String(STATE.tutoCle || '')
2885  const [pid, tid] = cle.split('/')
2886  const p = vue === 'domaine' ? null : window.tutoParcours(pid)
2887  const d = vue === 'domaine' ? window.tutoDomaine(cle) : (p ? window.tutoDomaine(p.d) : null)
2888  const t = (p && tid) ? window.tutoUn(pid, tid) : null
2889  const crans = [{ l: 'Tutoriels', v: 'accueil', c: null }]
2890  if (d) crans.push({ l: d.nom, v: 'domaine', c: d.id })
2891  if (p) crans.push({ l: p.nom, v: 'parcours', c: p.id })
2892  if (t) crans.push({ l: t.titre, v: 'tutoriel', c: pid + '/' + tid })
2893  return `<div class="flex flex-wrap items-center gap-1.5 mb-4 text-[max(0.78125rem,12.5px)]">${crans.map((x, i) => `
2894    <button onclick="window.tutoVue('${x.v}'${x.c ? `,'${escHtml(x.c)}'` : ''})" style="color:${i === crans.length - 1 ? 'var(--t-secondary)' : 'var(--t-faint)'};background:none;border:none;font-weight:${i === crans.length - 1 ? '600' : '500'}">${escHtml(x.l)}</button>
2895    ${i < crans.length - 1 ? `<span style="color:var(--t-faint)">›</span>` : ''}`).join('')}</div>`
2896}
2897
2898/* ── ÉCRAN 1 : L'ACCUEIL DES TUTORIELS ──────────────────────────────────── */
2899function _tutoAccueil() {
2900  const reprise = window.tutoReprendre()
2901  /* « REPRENDRE » NE PROPOSE JAMAIS UN TUTORIEL JAMAIS OUVERT — même règle
2902     qu'à l'École, et pour la même raison : reprendre ce qu'on n'a pas commencé
2903     est le mensonge qui décrédibilise un écran d'accueil. */
2904  const bandeau = reprise ? _academieRangee('Reprendre', 'là où vou
2904s vous êtes arrêté',
2905    `<button onclick="window.tutoVue('tutoriel','${escHtml(reprise.parcours.id + '/' + reprise.tutoriel.id)}')" class="w-full text-left flex items-stretch rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-input);border:1px solid var(--border)">
2906      <span class="shrink-0 p-3">${_academieVignetteCarree(reprise.parcours, 104, reprise.tutoriel.minutes + ' min')}</span>
2907      <span class="flex-1 min-w-0 py-4 pr-2 flex flex-col gap-2.5 justify-center">
2908        <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(reprise.domaine.nom)} › ${escHtml(reprise.parcours.nom)}</span>
2909        <span class="block text-[max(1.0625rem,17px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(reprise.tutoriel.titre)}</span>
2910        <span class="flex items-center gap-3">
2911          <span class="flex-1">${_academieBarre(reprise.progression.part, 5)}</span>
2912          <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${reprise.progression.faites} / ${reprise.progression.total} étapes</span>
2913        </span>
2914      </span>
2915      <span class="shrink-0 flex items-center pr-5 pl-2">
2916        <span class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff">Continuer ${_icon('chevronRight', 'w-4 h-4')}</span>
2917      </span>
2918    </button>`) : ''
2919
2920  const domaines = `<div style="${ACADEMIE_GRILLE}">${TUTO_DOMAINES.map(d => {
2921    const p = window.tutoProgressionDomaine(d.id)
2922    const commence = p.faites > 0 && p.faites < p.total
2923    return _academieCarteCouchee({
2924      clic: `window.tutoVue('domaine','${escHtml(d.id)}')`, o: d, cote: 112,
2925      titre: d.nom, quoi: d.quoi,
2926      meta: p.parcours + ' parcours · ' + p.total + ' étapes' + (p.faites ? ' · ' + p.faites + ' faites' : ''),
2927      part: p.faites ? p.part : undefined,
2928      badge: commence ? 'EN COURS' : p.total && p.faites >= p.total ? 'TERMINÉ' : '',
2929      bordure: commence ? 'var(--accent)' : undefined,
2930    })
2931  }).join('')}</div>`
2932
2933  /* LES DEUX ANCIENNES RANGÉES QUI SERVAIENT ENCORE. « Par rôle » et « par
2934     lieu » ne doublaient rien : l'une range les prompts par métier, l'autre
2935     dit ce qui se pratique à chaque endroit. Elles descendent sous la colonne,
2936     là où l'on va quand on ne cherche pas un tutoriel précis. */
2937  const roles = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${ACADEMIE_ROLES.map(r => `
2938    <button onclick="window.academieVue('role','${r.id}')" class="flex items-center gap-3 rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2939      <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style="background:var(--bg-input);color:var(--t-secondary)">${_icon(r.icone, 'w-4 h-4')}</span>
2940      <span class="text-[max(0.875rem,14px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(r.label)}</span>
2941    </button>`).join('')}</div>`
2942  const lieux = `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">${_academieLieux().map(id => {
2943    const n = _academieCatalogue().filter(r => r.ou === id).length
2944    return `<button onclick="window.academieVue('lieu','${id}')" class="rounded-2xl p-3.5 text-left transition-colors hover:bg-black/5" style="background:var(--bg-secondary);border:1px solid var(--border)">
2945      <span class="block text-[max(0.875rem,14px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(_academieTitreDuLieu(id))}</span>
2946      <span class="block text-[max(0.75rem,12px)] mt-0.5" style="color:var(--t-faint)">${n} ressource${n > 1 ? 's' : ''}</span>
2947    </button>`
2948  }).join('')}</div>`
2949
2950  const v = window.tutoVerifier()
2951  /* LES SÉANCES PASSENT DEVANT TOUT — voir l'en-tête de ce bloc. */
2952  return _academieRangee('Séances en direct', 'Ce qui se passe en ce moment, et les rediffusions.', _academieWebinaires())
2953    + bandeau
2954    + _academieRangee('Apprendre CREA',
2955      `${v.domaines} domaines · ${v.parcours} parcours · ${v.tutoriels} tutoriels · ${v.etapes} étapes`, domaines)
2956    + _academieRangee('Parcourir par rôle', "Prompts et cas d'usage adaptés au travail que vous faites.", roles)
2957    + _academieRangee('Parcourir par lieu', 'Ce qui se pratique dans chaque endroit de CREA.', lieux, _academieLienTout('ressources'))
2958}
2959
2960/* ── ÉCRAN 2 : UN DOMAINE ───────────────────────────────────────────────── */
2961function _tutoDomaineEcran(did) {
2962  const d = window.tutoDomaine(did)
2963  if (!d) return _tutoAccueil()
2964  const pd = window.tutoProgressionDomaine(did)
2965  const parcours = `<div style="${ACADEMIE_GRILLE}">${window.tutoParcoursDuDomaine(did).map(p => {
2966    const pp = window.tutoProgressionParcours(p.id)
2967    const commence = pp.faites > 0 && pp.faites < pp.total
2968    return _academieCarteCouchee({
2969      clic: `window.tutoVue('parcours','${escHtml(p.id)}')`, o: p, cote: 112,
2970      titre: p.nom, quoi: p.quoi,
2971      meta: pp.tutos + (pp.tutos > 1 ? ' tutoriels · ' : ' tutoriel · ') + p.minutes + ' min · ' + pp.total + ' étapes',
2972      part: pp.faites ? pp.part : undefined,
2973      badge: commence ? 'EN COURS' : pp.total && pp.faites >= pp.total ? 'TERMINÉ' : '',
2974      bordure: commence ? 'var(--accent)' : undefined,
2975    })
2976  }).join('')}</div>`
2977  return _academieRangee(d.nom, `${d.quoi} — ${pd.parcours} parcours, ${pd.total} étapes, ${pd.part} % parcouru`, parcours)
2978}
2979
2980/* ── ÉCRAN 3 : UN PARCOURS ──────────────────────────────────────────────── */
2981function _tutoParcoursEcran(pid) {
2982  const p = window.tutoParcours(pid)
2983  if (!p) return _tutoAccueil()
2984  const pp = window.tutoProgressionParcours(pid)
2985  const lignes = p.tutos.map((t, i) => {
2986    const pt = window.tutoProgressionTuto(pid, t.id)
2987    const fini = pt.total && pt.faites >= pt.total
2988    return `<button onclick="window.tutoVue('tutoriel','${escHtml(pid + '/' + t.id)}')" class="w-full text-left flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}">
2989      <span class="shrink-0 inline-flex items-center justify-center rounded-full text-[max(0.75rem,12px)] font-bold" style="width:26px;height:26px;background:${fini ? 'var(--accent)' : 'var(--bg-tertiary)'};color:${fini ? '#fff' : 'var(--t-dim)'}">${fini ? '✓' : i + 1}</span>
2990      <span class="flex-1 min-w-0">
2991        <span class="block text-[max(0.90625rem,14.5px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(t.titre)}</span>
2992        <span class="block text-[max(0.78125rem,12.5px)] leading-snug mt-0.5" style="color:var(--t-dim)">${escHtml(t.objectif)}</span>
2993      </span>
2994      <span class="shrink-0 text-[max(0.71875rem,11.5px)] font-semibold" style="color:var(--t-faint)">${pt.faites}/${pt.total} · ${t.minutes} min</span>
2995      ${_icon('chevronRight', 'w-4 h-4 shrink-0')}
2996    </button>`
2997  }).join('')
2998  const entete = `<div class="flex items-center gap-4 p-4 rounded-2xl mb-4" style="background:var(--bg-input);border:1px solid var(--border)">
2999    ${_academieVignetteCarree(p, 128)}
3000    <div class="flex-1 min-w-0">
3001      <p class="text-[max(1.25rem,20px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(p.nom)}</p>
3002      <p class="text-[max(0.875rem,14px)] mt-1" style="color:var(--t-dim)">${escHtml(p.quoi)}</p>
3003      <p class="text-[max(0.75rem,12px)] mt-2" style="color:var(--t-faint)">${pp.tutos} ${pp.tutos > 1 ? 'tutoriels' : 'tutoriel'} · ${p.minutes} min · ${pp.faites} / ${pp.total} étapes faites</p>
3004      <span class="block mt-2">${_academieBarre(pp.part, 5)}</span>
3005    </div>
3006  </div>`
3007  return entete + `<div class="rounded-2xl overflow-hidden" style="background:var(--bg-secondary);border:1px solid var(--border)">${lignes}</div>`
3008}
3009
3010/* ── ÉCRAN 4 : UN TUTORIEL ──────────────────────────────────────────────── */
3011/* LE GESTE DES MODÈLES, PAS UN SECOND. `data-studio-template-card` +
3012   `data-template-exemple` ouvre le lieu, crée le projet de code s'il en manque
3013   un, et ÉCRIT la demande dans la barre sans l'envoyer. C'est le geste qui
3014   existe déjà (voir bindEvents) : un gestionnaire de plus ici aurait donné
3015   deux comportements pour un seul clic. */
3016function _tutoTutoriel(cle) {
3017  const [pid, tid] = String(cle || '').split('/')
3018  const p = window.tutoParcours(pid)
3019  const t = p && window.tutoUn(pid, tid)
3020  if (!t) return _tutoAccueil()
3021  const pt = window.tutoProgressionTuto(pid, tid)
3022  const suivant = p.tutos[p.tutos.findIndex(x => x.id === tid) + 1]
3023
3024  const jauge = `<div class="flex items-center gap-4 px-5 py-3.5 mb-5 rounded-2xl" style="background:var(--bg-input);border:1px solid var(--border)">
3025    <button onclick="window.tutoVue('parcours','${escHtml(pid)}')" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] font-medium" style="color:var(--t-dim);background:none;border:none">${_icon('chevronLeft', 'w-3.5 h-3.5')}${escHtml(p.nom)}</button>
3026    <span class="flex-1">${_academieBarre(pt.part, 5)}</span>
3027    <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${pt.faites} / ${pt.total} étapes</span>
3028  </div>`
3029
3030  /* CHAQUE ÉTAPE EST UN INTERRUPTEUR, ET LE DIT. Une puce numérotée qui ne se
3031     coche pas se lit comme une liste ; une case qui se coche se lit comme un
3032     geste à faire. C'est toute la différence entre lire un tutoriel et le
3033     suivre. */
3034  const etapes = `<ol class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)">${t.etapes.map((e, i) => {
3035    const fait = window.tutoEtapeFaite(e.cle)
3036    return `<li><button onclick="window.tutoEtape('${escHtml(e.cle)}')" class="w-full text-left flex items-start gap-3.5 px-5 py-3.5 transition-colors hover:bg-black/[.035]" style="background:none;border:none;border-top:${i ? '1px solid var(--border-dim)' : 'none'}">
3037      <span class="shrink-0 inline-flex items-center justify-center mt-0.5" style="width:22px;height:22px;border-radius:6px;background:${fait ? 'var(--accent)' : 'var(--bg-secondary)'};border:1px solid ${fait ? 'var(--accent)' : 'var(--border)'};color:#fff">${fait ? _icon('check', 'w-3.5 h-3.5') : ''}</span>
3038      <span class="flex-1 text-[max(0.90625rem,14.5px)] leading-relaxed" style="color:${fait ? 'var(--t-faint)' : 'var(--t-secondary)'};text-decoration:${fait ? 'line-through' : 'none'}">${escHtml(e.texte)}</span>
3039    </button></li>`
3040  }).join('')}</ol>`
3041
3042  const essai = `<div class="rounded-2xl p-4 mb-5" style="background:var(--bg-secondary);border:1px solid var(--border)">
3043    <p class="text-[max(0.8125rem,13px)] font-semibold mb-1" style="color:var(--t-secondary)">Essayer tout de suite</p>
3044    <p class="text-[max(0.84375rem,13.5px)] mb-3" style="color:var(--t-dim)">« ${escHtml(t.exemple)} »</p>
3045    <div class="flex flex-wrap gap-2">
3046      <button data-studio-template-card="${escHtml(t.ouvre)}" data-template-exemple="${escHtml(t.exemple)}" class="rounded-full px-4 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ouvrir avec cette demande</button>
3047      <button onclick="window.academieOuvrir('${escHtml(t.ouvre)}')" class="rounded-full px-4 py-2 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(t.ouvreLabel)}</button>
3048    </div>
3049  </div>`
3050
3051  const nav = `<div class="flex items-center justify-between gap-4">
3052    <button onclick="window.tutoVue('parcours','${escHtml(pid)}')" class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${_icon('chevronLeft', 'w-4 h-4')}Le parcours</button>
3053    ${suivant ? `<button onclick="window.tutoVue('tutoriel','${escHtml(pid + '/' + suivant.id)}')" class="inline-flex items-center gap-2 rounded-full px-6 py-3 text-[max(0.90625rem,14.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${escHtml(suivant.titre)} ${_icon('chevronRight', 'w-4 h-4')}</button>` : '<span></span>'}
3054  </div>`
3055
3056  return jauge + `<div class="flex items-center gap-2 mb-2">
3057      <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.71875rem,11.5px)] font-bold" style="background:var(--accent-bg);color:var(--accent)">TUTORIEL</span>
3058      <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${t.minutes} min · ${escHtml(p.nom)}</span>
3059    </div>
3060    <p class="text-[max(1.6875rem,27px)] font-bold leading-tight mb-1.5" style="color:var(--t-primary)">${escHtml(t.titre)}</p>
3061    <p class="text-[max(0.9375rem,15px)] mb-5" style="color:var(--t-dim)">${escHtml(t.objectif)}</p>`
3062    + etapes + essai + nav
3063}
3064
3065function _academieTutoriels() {
3066  const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil'
3067  const cle = STATE.tutoCle
3068  const corps = vue === 'domaine' ? _tutoDomaineEcran(cle)
3069    : vue === 'parcours' ? _tutoParcoursEcran(cle)
3070    : vue === 'tutoriel' ? _tutoTutoriel(cle)
3071    : _tutoAccueil()
3072  return _tutoFil() + corps
3073}
3074
3075/* ── VUE 6 : L'ÉDUCATION ────────────────────────────────────────────────── */
3076/* ══════════════════════════════════════════════════════════════════════════
3077 * L'ÉCOLE — CINQ ÉCRANS, ET AUCUN NE TOMBE SUR UN CHAT
3078 * ══════════════════════════════════════════════════════════════════════════
3079 *
3080 * Avant : une grille de quatorze matières, et cliquer l'une d'elles sautait
3081 * par-dessus tout le programme pour atterrir sur la séance du Studio
3082 * Éducation — une barre de chat. Les cent quatorze sous-domaines de la table ne s'affichaient
3083 * jamais. « Apprendre une matière » voulait dire « poser une question ».
3084 *
3085 * Maintenant, dans l'ordre où on les traverse :
3086 *
3087 *   ACCUEIL  reprendre là où on s'est arrêté, puis les huit filières
3088 *   FILIÈRE  ses matières, chacune avec sa progression
3089 *   MATIÈRE  ses cours, dans l'ordre, avec ce qui est fait
3090 *   COURS    son plan de leçons, et le contrôle verrouillé au bout
3091 *   LEÇON    la leçon elle-même — et le professeur en SECOND RÔLE
3092 *
3093 * LE PROFESSEUR PARTICULIER N'EST PLUS UNE DESTINATION. Il est une bande DANS
3094 * la leçon : « explique autrement », « interroge-moi ». C'est tout le
3095 * correctif — on arrive sur un contenu, pas sur une barre de saisie.
3096 */
3097const ECOLE_VUES = ['accueil', 'filiere', 'matiere', 'cours', 'lecon']
3098
3099window.ecoleVue = function (vue, cle) {
3100  STATE.ecoleVue = ECOLE_VUES.includes(vue) ? vue : 'accueil'
3101  STATE.ecoleCle = cle || null
3102  save(); render()
3103}
3104
3105window.ecoleFaite = function (cle, suivante) {
3106  window.ecoleMarquer(cle, true)
3107  /* ON ENCHAÎNE. Marquer une leçon faite et rester dessus oblige à revenir au
3108     plan pour cliquer la suivante — trois gestes pour un. */
3109  if (suivante) STATE.ecoleCle = suivante
3110  else STATE.ecoleVue = 'cours'
3111  save(); render()
3112}
3113
3114/* ══════════════════════════════════════════════════════════════════════════
3115 * LES SÉANCES EN DIRECT ENTRENT DANS L'ÉCOLE
3116 * ══════════════════════════════════════════════════════════════════════════
3117 *
3118 * « Séance en direct, ça, c'est à mettre en tête, ce n'est pas à mettre en
3119 *   bas. Et d'ailleurs, il faut qu'ils interviennent aussi dans les écoles. »
3120 *
3121 * Elles n'existaient que dans une rangée des tutoriels. Or une séance porte
3122 * une MATIÈRE : c'est sur la page de cette matière qu'elle sert — au moment
3123 * où quelqu'un décide s'il apprend seul ou s'il attend jeudi.
3124 *
3125 * ── POURQUOI LE RAPPROCHEMENT SE FAIT SUR LE NOM, ET CE QUE ÇA COÛTE ─────
3126 *
3127 * Le champ « matière » du formulaire est du texte libre côté serveur : rien
3128 * ne garantit qu'il tombe sur une des cinquante-six. On compare donc des noms
3129 * NORMALISÉS — sans accents, sans casse, sans ponctuation — et le formulaire
3130 * PROPOSE désormais les matières réelles (voir sa `datalist`), pour que le
3131 * rapprochement soit la règle et non la chance.
3132 *
3133 * CE QUI NE CORRESPOND À RIEN N'EST PAS PERDU : la séance reste dans la rangée
3134 * de l'accueil. Une séance qu'on ne retrouve nulle part serait pire qu'une
3135 * séance mal rangée.
3136 */
3137function _seanceNormaliser(x) {
3138  return String(x == null ? '' : x).normalize('NFD').replace(/[\u0300-\u036f]/g, '')
3139    .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
3140}
3141
3142/* LES SÉANCES QUI VISENT CETTE MATIÈRE. `STATE.academieSeances` a trois états
3143   — pas encore demandé, refusé, la liste — et les confondre ferait passer une
3144   panne de réseau pour une matière sans séance. Ici on ne rend RIEN dans les
3145   deux premiers cas : sur la page d'une matière, une ligne « lecture… » qui
3146   ne finit jamais est un bruit, pas une information. */
3147function _ecoleSeancesDeLaMatiere(mid) {
3148  const liste = STATE.academieSeances
3149  if (!Array.isArray(liste)) return []
3150  const m = window.ecoleMatiere(mid)
3151  if (!m) return []
3152  const cible = _seanceNormaliser(m.nom)
3153  return liste.filter(w => {
3154    const q = _seanceNormaliser(w.matiere)
3155    return q && (q === cible || q === _seanceNormaliser(m.id))
3156  })
3157}
3158
3159/* LES SÉANCES À VENIR, TOUTES MATIÈRES. Ce qui est PASSÉ ne monte pas en tête
3160   d'accueil : une rediffusion n'a pas d'heure, elle attendra la page de sa
3161   matière. En tête, on ne met que ce qui a une échéance. */
3162function _ecoleSeancesAVenir() {
3163  const liste = STATE.academieSeances
3164  if (!Array.isArray(liste)) return []
3165  return liste.filter(w => w.etat !== 'rediffusion')
3166}
3167
3168function _ecoleRangeeDeSeances(seances, titre, sousTitre) {
3169  if (!seances.length) return ''
3170  return _academieRangee(titre, sousTitre,
3171    `<div class="grid gap-2.5" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">${seances.map(_academieCarteWebinaire).join('')}</div>`)
3172}
3173
3174/* ── ÉCRAN 1 : L'ACCUEIL DE L'ÉCOLE ─────────────────────────────────────── */
3175function _ecoleAccueil() {
3176  const reprise = window.ecoleReprendre()
3177  /* « REPRENDRE » NE PROPOSE JAMAIS UN COURS JAMAIS OUVERT. Reprendre ce qu'on
3178     n'a pas commencé est un mensonge, et c'est celui qui décrédibilise un
3179     écran d'accueil pour de bon. Rien de commencé : on n'affiche pas la
3180     rangée, on invite à choisir. */
3181  const bandeau = reprise ? _academieRangee('Reprendre', 'là où vou
3181s vous êtes arrêté',
3182    `<button onclick="window.ecoleVue('lecon','${escHtml(reprise.lecon.cle)}')" class="w-full text-left flex items-stretch rounded-2xl overflow-hidden transition-colors hover:bg-black/5" style="background:var(--bg-input);border:1px solid var(--border)">
3183      <span class="shrink-0 p-3">${_academieVignetteCarree(reprise.matiere, 104, reprise.lecon.nature.label + ' · ' + reprise.lecon.m + ' min')}</span>
3184      <span class="flex-1 min-w-0 py-4 pr-2 flex flex-col gap-2.5 justify-center">
3185        <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(reprise.matiere.nom)} › ${escHtml(reprise.cours.titre)} › leçon ${reprise.lecon.rang + 1} sur ${reprise.cours.lecons.length}</span>
3186        <span class="block text-[max(1.0625rem,17px)] font-bold leading-snug" style="color:var(--t-primary)">${escHtml(reprise.lecon.t)}</span>
3187        <span class="flex items-center gap-3">
3188          <span class="flex-1">${_academieBarre(reprise.progression.part, 5)}</span>
3189          <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">${reprise.progression.faites} / ${reprise.progression.total}</span>
3190        </span>
3191      </span>
3192      <span class="shrink-0 flex items-center pr-5 pl-2">
3193        <span class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff">Continuer ${_icon('chevronRight', 'w-4 h-4')}</span>
3194      </span>
3195    </button>`) : ''
3196
3197  const filieres = `<div style="${ACADEMIE_GRILLE}">${ECOLE_FILIERES.map(f => {
3198    const p = window.ecoleProgressionFiliere(f.id)
3199    return _academieCarteCouchee({
3200      clic: `window.ecoleVue('filiere','${escHtml(f.id)}')`, o: f, cote: 112,
3201      titre: f.nom, quoi: f.quoi,
3202      meta: p.matieres + ' matières · ' + p.total + ' leçons' + (p.faites ? ' · ' + p.faites + ' faites' : ''),
3203      part: p.faites ? p.part : undefined,
3204      bordure: p.faites > 0 && p.faites < p.total ? 'var(--accent)' : undefined,
3205    })
3206  }).join('')}</div>`
3207
3208  const v = window.ecoleVerifier()
3209  /* LES SÉANCES PASSENT DEVANT, COMME AUX TUTORIELS — et pour la même raison :
3210     elles ont une heure. Passé l'heure, les montrer ne sert plus à rien. */
3211  return _ecoleRangeeDeSeances(_ecoleSeancesAVenir(), 'Séances en direct',
3212    'Ce qui se passe en ce moment, et ce qui arrive — toutes matières.')
3213    + bandeau + _academieRangee('Les filières',
3214    `${v.filieres} filières · ${v.matieres} matières · ${v.cours} cours · ${v.lecons} leçons`, filieres)
3215}
3216
3217/* ── ÉCRAN 2 : UNE FILIÈRE ──────────────────────────────────────────────── */
3218function _ecoleFiliere(fid) {
3219  const f = window.ecoleFiliere(fid)
3220  if (!f) return _ecoleAccueil()
3221  const p = window.ecoleProgressionFiliere(fid)
3222  const matieres = `<div style="${ACADEMIE_GRILLE}">${window.ecoleMatieresDeLaFiliere(fid).map(m => {
3223    const pm = window.ecoleProgressionMatiere(m.id)
3224    const commencee = pm.faites > 0 && pm.faites < pm.total
3225    return _academieCarteCouchee({
3226      clic: `window.ecoleVue('matiere','${escHtml(m.id)}')`, o: m, cote: 112,
3227      titre: m.nom, quoi: m.quoi,
3228      meta: pm.cours + ' cours · ' + pm.total + ' leçons · ' + (ECOLE_NIVEAUX[m.niv] || ''),
3229      part: pm.faites ? pm.part : undefined,
3230      badge: commencee ? 'EN COURS' : pm.faites >= pm.total && pm.total ? 'TERMINÉE' : '',
3231      bordure: commencee ? 'var(--accent)' : undefined,
3232    })
3233  }).join('')}</div>`
3234  return _academieRangee(f.nom, `${f.quoi} — ${p.matieres} matières, ${p.total} leçons, ${p.part} % parcouru`, matieres)
3235}
3236
3237/* ── ÉCRAN 3 : UNE MATIÈRE ──────────────────────────────────────────────── */
3238function _ecoleMatiere(mid) {
3239  const m = window.ecoleMatiere(mid)
3240  if (!m) return _ecoleAccueil()
3241  const p = window.ecoleProgressionMatiere(mid)
3242
3243  /* UNE LIGNE DE COURS, sortie de la boucle des thèmes : elle était écrite une
3244     fois pour une liste plate, et un thème par-dessus l'aurait fait recopier. */
3245  const ligne = (c, i) => {
3246    const pc = window.ecoleProgressionCours(mid, c.id)
3247    const fini = pc.faites >= pc.total
3248    const commence = pc.faites > 0 && !fini
3249    return `<button onclick="window.ecoleVue('cours','${escHtml(mid + '/' + c.id)}')" class="w-full text-left flex items-center gap-4 px-5 py-4" style="border-bottom:1px solid var(--border-dim);${commence ? 'background:var(--accent-bg)' : ''}">
3250      <span class="shrink-0 inline-flex items-center justify-center rounded-full" style="width:30px;height:30px;${fini ? 'background:var(--accent);color:#fff' : commence ? 'border:2px solid var(--accent);color:var(--accent)' : 'border:1px solid var(--border);color:var(--t-faint)'};font-size:12px;font-weight:700">${fini ? _icon('check', 'w-4 h-4') : (i + 1)}</span>
3251      <span class="flex-1 min-w-0 flex flex-col gap-1.5">
3252        <span class="block text-[max(0.9375rem,15px)] font-${commence ? 'bold' : 'semibold'} leading-snug" style="color:${fini ? 'var(--t-faint)' : 'var(--t-primary)'}">${escHtml(c.titre)}</span>
3253        <span class="block text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${c.lecons.length} leçons · ${c.minutes} min · ${escHtml(c.niveauLisible)}</span>
3254        ${commence ? `<span class="flex items-center gap-2.5 mt-0.5"><span style="width:190px">${_academieBarre(pc.part)}</span><span class="text-[max(0.6875rem,11px)]" style="color:var(--t-dim)">${pc.faites} sur ${pc.total}</span></span>` : ''}
3255      </span>
3256      <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:${commence ? 'var(--accent)' : 'var(--t-dim)'}">${fini ? 'Revoir' : commence ? 'Reprendre' : 'Commencer'} ${_icon('chevronRight', 'w-3.5 h-3.5')}</span>
3257    </button>`
3258  }
3259
3260  /* LES THÈMES SONT LE SUJET DE CET ÉCRAN. C'est ce qui manquait : « il y a des
3261     domaines qui sont exposés, quand on les ouvre, on ne voit pas leur
3262     sous-domaine ». Chaque thème porte son nom, ce qu'il couvre, et sa propre
3263     progression — un bloc qu'on peut finir, au lieu d'une liste de vingt cours
3264     où l'on ne sait plus où l'on en est.
3265     ILS NE SONT PAS UN ÉCRAN DE PLUS : un étage de clic supplémentaire pour
3266     arriver au même cours n'aurait rien ajouté. */
3267  const themes = window.ecoleThemes(mid).map((t, k) => {
3268    const total = t.cours.reduce((n, c) => n + c.lecons.length, 0)
3269    const faites = t.cours.reduce((n, c) => n + window.ecoleProgressionCours(mid, c.id).faites, 0)
3270    const part = total ? Math.round((faites / total) * 100) : 0
3271    return `<section class="mb-5">
3272      <div class="flex items-end justify-between gap-3 mb-2 px-0.5">
3273        <div class="min-w-0">
3274          <p class="text-[max(1rem,16px)] font-bold leading-snug" style="color:var(--t-primary)">
3275            <span class="mr-1.5" style="color:var(--t-faint)">${k + 1}.</span>${escHtml(t.nom)}
3276          </p>
3277          ${t.quoi ? `<p class="text-[max(0.78125rem,12.5px)] mt-0.5" style="color:var(--t-dim)">${escHtml(t.quoi)}</p>` : ''}
3278        </div>
3279        <div class="shrink-0 flex items-center gap-2.5" style="width:200px">
3280          <span class="flex-1">${_academieBarre(part)}</span>
3281          <span class="text-[max(0.6875rem,11px)] font-semibold whitespace-nowrap" style="color:${faites ? 'var(--accent)' : 'var(--t-faint)'}">${faites} / ${total}</span>
3282        </div>
3283      </div>
3284      <div class="rounded-2xl overflow-hidden" style="background:var(--bg-input);border:1px solid var(--border)">${t.cours.map(ligne).join('')}</div>
3285    </section>`
3286  }).join('')
3287
3288  /* L'EN-TÊTE EST COUCHÉE ELLE AUSSI. La photo faisait un bandeau de 168 px
3289     par toute la largeur : `object-cover` y recadrait une bande étroite d'un
3290     carré, et on ne reconnaissait plus rien. Carrée à gauche, elle est
3291     entière ; le titre, la description et la progression tiennent à côté,
3292     dans la largeur qu'on n'utilisait pas. */
3293  const entete = `<div class="flex items-center gap-5 p-4 rounded-2xl mb-6" style="background:var(--bg-input);border:1px solid var(--border)">
3294    ${_academieVignetteCarree(m, 148, ECOLE_NIVEAUX[m.niv] || '')}
3295    <div class="flex-1 min-w-0">
3296      <p class="text-[max(1.375rem,22px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(m.nom)}</p>
3297      <p class="text-[max(0.875rem,14px)] mt-1.5 leading-snug" style="color:var(--t-dim)">${escHtml(m.quoi)}</p>
3298      <p class="text-[max(0.75rem,12px)] mt-2.5" style="color:var(--t-faint)">${(m.themes || []).length} thèmes · ${p.cours} cours · ${p.total} leçons</p>
3299    </div>
3300    <div class="shrink-0 text-right" style="width:140px">
3301      <p class="text-[max(1.625rem,26px)] font-bold leading-none" style="color:var(--accent)">${p.part}&thinsp;%</p>
3302      <p class="text-[max(0.6875rem,11px)] mt-1.5 mb-2" style="color:var(--t-faint)">${p.faites} leçons sur ${p.total}</p>
3303      ${_academieBarre(p.part, 5)}
3304    </div>
3305  </div>`
3306
3307  /* ET LES SÉANCES DE CETTE MATIÈRE, ENTRE L'EN-TÊTE ET LE PROGRAMME. C'est
3308     l'endroit exact où la question se pose : on vient de voir ce que la
3309     matière contient, et on décide si on l'apprend seul ou si on attend
3310     jeudi. Plus bas, sous cent quatre-vingts leçons, personne ne la verrait ;
3311     plus haut, elle passerait devant ce qu'on est venu chercher. */
3312  return entete
3313    + _ecoleRangeeDeSeances(_ecoleSeancesDeLaMatiere(mid), 'Séances en direct',
3314      'Sur cette matière — en direct, et les rediffusions.')
3315    + themes
3316}
3317
3318/* ── ÉCRAN 4 : UN COURS ET SON PLAN ─────────────────────────────────────── */
3319function _ecoleCours(cle) {
3320  const [mid, cid] = String(cle || '').split('/')
3321  const c = window.ecoleCours(mid, cid)
3322  if (!c) return _ecoleAccueil()
3323  const m = window.ecoleMatiere(mid)
3324  const p = window.ecoleProgressionCours(mid, cid)
3325  const reprise = window.ecoleReprendreDansLeCours(mid, cid)
3326  /* LE CONTRÔLE SE DÉBLOQUE QUAND LES LEÇONS SONT FAITES. L'ouvrir d'emblée en
3327     ferait un raccourci vers l'échec — et un contrôle raté avant d'avoir appris
3328     ne mesure rien. */
3329  const restantes = c.lecons.filter(l => l.k !== 'quiz' && !window.ecoleLeconFaite(l.cle)).length
3330
3331  const plan = c.lecons.map((l, i) => {
3332    const fait = window.ecoleLeconFaite(l.cle)
3333    const courante = reprise && reprise.cle === l.cle
3334    const verrou = l.k === 'quiz' && restantes > 0
3335    const clic = verrou ? '' : `onclick="window.ecoleVue('lecon','${escHtml(l.cle)}')"`
3336    return `<button ${clic} ${verrou ? 'disabled' : ''} class="w-full text-left flex items-center gap-3.5 px-5 py-3.5" style="border-bottom:1px solid var(--border-dim);${courante ? 'background:var(--accent-bg);border-left:3px solid var(--accent)' : verrou ? 'background:var(--bg-secondary);cursor:not-allowed' : ''}">
3337      <span class="shrink-0 inline-flex items-center justify-center rounded-full" style="width:26px;height:26px;${fait ? 'background:var(--accent);color:#fff' : courante ? 'border:2px solid var(--accent);color:var(--accent)' : 'border:1px solid var(--border);color:var(--t-faint)'};font-size:11.5px;font-weight:700">${fait ? _icon('check', 'w-3.5 h-3.5') : verrou ? _icon('lock', 'w-3 h-3') : (i + 1)}</span>
3338      <span class="shrink-0 inline-flex items-center justify-center rounded-lg" style="width:26px;height:26px;background:${courante ? 'var(--accent)' : 'var(--bg-secondary)'};color:${courante ? '#fff' : 'var(--t-dim)'}">${_icon(l.nature.icone, 'w-3.5 h-3.5')}</span>
3339      <span class="flex-1 min-w-0 text-[max(0.90625rem,14.5px)] ${courante ? 'font-bold' : 'font-medium'}" style="color:${fait ? 'var(--t-faint)' : verrou ? 'var(--t-dim)' : 'var(--t-primary)'};${fait ? 'text-decoration:line-through' : ''}">${escHtml(l.t)}</span>
3340      <span class="shrink-0 text-[max(0.75rem,12px)]" style="color:${courante ? 'var(--accent)' : 'var(--t-faint)'}">${escHtml(l.nature.label)} · ${l.m} min${verrou ? ' · après la leçon ' + (c.lecons.length - 1) : ''}</span>
3341    </button>`
3342  }).join('')
3343
3344  return `<div class="flex items-stretch gap-5 mb-5">
3345      <div class="flex-1 min-w-0">
3346        <div class="flex items-center gap-2 mb-2.5">
3347          <span class="inline-flex items-center rounded-full px-2.5 py-1 text-[max(0.6875rem,11px)] font-bold" style="background:var(--bg-secondary);color:var(--t-dim)">COURS</span>
3348          <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${escHtml(c.niveauLisible)} · ${escHtml(m.nom)}</span>
3349        </div>
3350        <p class="text-[max(1.75rem,28px)] font-bold leading-tight" style="color:var(--t-primary)">${escHtml(c.titre)}</p>
3351        <p class="text-[max(0.9375rem,15px)] mt-3 leading-relaxed" style="color:var(--t-muted)"><strong style="color:var(--t-secondary)">À la fin de ce cours, vous saurez</strong> ${escHtml(c.objectif.charAt(0).toLowerCase() + c.objectif.slice(1))}</p>
3352        <p class="text-[max(0.75rem,12px)] mt-3.5" style="color:var(--t-faint)">${c.lecons.length} leçons · ${c.minutes} min · ${Object.entries(c.parNature).map(([k, n]) => n + ' ' + (ECOLE_NATURES[k] ? ECOLE_NATURES[k].label.toLowerCase() + (n > 1 ? 's' : '') : k)).join(', ')}</p>
3353      </div>
3354      <div class="shrink-0 flex flex-col gap-3 p-5 rounded-2xl" style="width:250px;background:var(--bg-input);border:1px solid var(--border)">
3355        <div class="flex items-baseline gap-2">
3356          <span class="text-[max(1.75rem,28px)] font-bold leading-none" style="color:var(--accent)">${p.part}&thinsp;%</span>
3357          <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${p.faites} sur ${p.total}</span>
3358        </div>
3359        ${_academieBarre(p.part, 5)}
3360        ${reprise
3361          ? `<button onclick="window.ecoleVue('lecon','${escHtml(reprise.cle)}')" class="rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff;border:none">${p.faites ? 'Reprendre à la leçon ' + (reprise.rang + 1) : 'Commencer'}</button>`
3362          : `<p class="text-[max(0.8125rem,13px)] text-center" style="color:var(--success)">Cours terminé</p>`}
3363        <p class="text-[max(0.6875rem,11px)] leading-snug text-center" style="color:var(--t-faint)">Le contrôle se débloque quand les ${c.lecons.length - 1} leçons sont faites.</p>
3364      </div>
3365    </div>
3366    <p class="text-[max(1.0625rem,17px)] font-bold mb-2.5" style="color:var(--t-primary)">Le plan du cours</p>
3367    <div class="rounded-2xl overflow-hidden" style="background:var(--bg-input);border:1px solid var(--border)">${plan}</div>`
3368}
3369
3370/* ══════════════════════════════════════════════════════════════════════════
3371 * ÉCRIRE UNE LEÇON — PAR LA GORGE, ET UNE SEULE FOIS
3372 * ══════════════════════════════════════════════════════════════════════════
3373 *
3374 * DEUX ENDROITS OÙ UNE LEÇON PEUT ÊTRE ÉCRITE, et ils ne servent pas la même
3375 * personne :
3376 *   · le STOCKAGE PARTAGÉ du serveur — éditorial, écrit depuis le compte qui
3377 *     administre. C'est celui que tout le monde lit ;
3378 *   · l'état LOCAL de qui la demande — quand le partagé ne l'a pas et qu'on
3379 *     n'a pas le droit d'y écrire. Sa leçon existe quand même, pour lui.
3380 * Sans le second, une personne sans droits verrait pour toujours « pas encore
3381 * écrite » sur un bouton qui ne peut rien faire — un bouton mort.
3382 *
3383 * LE PROMPT PASSE PAR `buildSystemPrompt`, comme la séance du studio, et le
3384 * cadre vient du poste (`leconEcole`, src/app-domaine-surface.js). Écrire ici
3385 * « Tu es un professeur » aurait été une seconde CREA — CLAUDE.md, règle 1.
3386 */
3387function ecoleLeconsLocales() {
3388  const x = (typeof STATE !== 'undefined' && STATE.ecoleLecons) || {}
3389  return (x && typeof x === 'object' && !Array.isArray(x)) ? x : {}
3390}
3391
3392/* CE QUI EST ÉCRIT, D'OÙ QUE ÇA VIENNE. Le partagé PRIME sur le local : le
3393   jour où l'éditorial écrit une leçon, elle remplace la version que quelqu'un
3394   s'était faite — sinon deux personnes ne verraient pas la même École. */
3395window.ecoleLeconEcrite = function (cle) {
3396  const partagees = (typeof STATE !== 'undefined' && STATE.ecoleLeconsPartagees) || {}
3397  const p = partagees[cle]
3398  if (p && p.contenu) return p.contenu
3399  return ecoleLeconsLocales()[cle] || ''
3400}
3401
3402/* LA LECTURE DU STOCKAGE PARTAGÉ. Elle demande les clés DU COURS OUVERT, pas
3403   tout : sans ça, l'écran d'une leçon télécharge toutes les leçons écrites
3404   pour en lire une, et ça ne se voit pas avant qu'il y en ait beaucoup. */
3405window.ecoleChargerLecons = async function (cles) {
3406  const demandees = (cles || []).filter(Boolean).slice(0, 40)
3407  if (!demandees.length) return
3408  try {
3409    const r = await fetch('/api/ecole/lecons?cles=' + encodeURIComponent(demandees.join(',')), { headers: internalAuth() })
3410    if (!r.ok) return
3411    const d = await r.json()
3412    if (!d || !d.lecons) return
3413    STATE.ecoleLeconsPartagees = Object.assign({}, STATE.ecoleLeconsPartagees || {}, d.lecons)
3414    render()
3415  } catch (e) { /* hors ligne, ou route absente : on garde ce qu'on a en local */ }
3416}
3417
3418window.ecoleEcrireLecon = async function (cle) {
3419  const [mid, cid, rang] = String(cle || '').split('/')
3420  const c = window.ecoleCours(mid, cid)
3421  const m = window.ecoleMatiere(mid)
3422  const l = c && c.lecons[Number(rang)]
3423  if (!c || !m || !l) return
3424  /* LE CONTRÔLE NE S'ÉCRIT PAS : il INTERROGE. Un texte figé ne saurait pas
3425     poser une question à quelqu'un ni juger sa réponse — ce sont les
3426     `QUESTION:{…}` de chaque leçon, relues par `_ecoleQuestions`, qui le
3427     font, et le contrôle de fin de cours les rassemble. */
3428  if (l.k === 'quiz') return
3429
3430  const f = window.ecoleFiliere(m.f)
3431  const theme = (m.themes || []).find(t => (t.cours || []).some(x => x.id === cid))
3432  const cadreEtDemande = leconEcole({
3433    filiere: f && f.nom, matiere: m.nom, theme: theme && theme.nom,
3434    cours: c.titre, objectif: c.objectif, titre: l.t, nature: l.k, minutes: l.m,
3435  })
3436
3437  STATE.ecoleLeconEnCours = cle
3438  STATE.ecoleLeconTexte = ''
3439  STATE._ecoleLeconErreur = ''
3440  render()
3441  try {
3442    let dernierRendu = 0
3443    const texte = await parlerAuModele({
3444      messages: [{ role: 'user', content: cadreEtDemande.demande }],
3445      model: STATE.selectedModel?.id || STATE.models[0]?.id,
3446      systemPrompt: buildSystemPrompt(false, cadreEtDemande.demande, 'edu') + '\n\n' + cadreEtDemande.cadre,
3447      ...internalAuth(),
3448      rappels: {
3449        surTexte: (t) => {
3450          STATE.ecoleLeconTexte = t
3451          const now = Date.now()
3452          if (now - dernierRendu > 250) { dernierRendu = now; render() }
3453        },
3454      },
3455    }, { intention: 'agent', surface: 'edu' })
3456
3457    STATE.ecoleLeconEnCours = null
3458    STATE.ecoleLeconTexte = ''
3459    if (!texte || texte.trim().length < 200) {
3460      /* ON NE GARDE PAS UN ÉCHEC. Une leçon de trois lignes rangée dans l'état
3461         se relirait comme une leçon écrite, et le bouton ne reviendrait plus. */
3462      /* L'ÉCHEC SE DIT DANS L'ÉCRAN, PAS DANS UNE BULLE QUI PASSE. C'est la
3463         convention des espaces : l'erreur vit dans l'état, à côté de ce qu'elle
3464         concerne, et reste lisible tant qu'on ne l'a pas résolue. */
3465      STATE._ecoleLeconErreur = 'la leçon est revenue trop courte — réessayez'
3466      render(); return
3467    }
3468    STATE.ecoleLecons = Object.assign({}, ecoleLeconsLocales(), { [cle]: texte })
3469    save(); render()
3470
3471    /* ET ON LA PROPOSE AU STOCKAGE PARTAGÉ. Un refus 403 est le cas NORMAL
3472       pour qui n'administre pas : la leçon reste locale, et on ne le signale
3473       pas — ce n'est pas une panne, c'est la règle. */
3474    try {
3475      await fetch('/api/ecole/lecons', {
3476        method: 'POST',
3477        /* `internalAuth()`, comme tous les envois de l'Académie. Un `authHeaders`
3478           inventé ici ne levait même pas à l'écriture : un identifiant jamais
3479           déclaré fait un ReferenceError DÈS le test `authHeaders ?`, et la
3480           leçon ne serait jamais proposée au stockage partagé. Trouvé par le
3481           cliquet des noms inconnus, pas par la lecture. */
3482        headers: Object.assign({ 'Content-Type': 'application/json' }, internalAuth()),
3483        body: JSON.stringify({ cle, contenu: texte, titre: l.t, nature: l.k }),
3484      })
3485    } catch (e) { /* pas de réseau : la leçon reste dans ce navigateur */ }
3486  } catch (e) {
3487    STATE.ecoleLeconEnCours = null
3488    STATE.ecoleLeconTexte = ''
3489    STATE._ecoleLeconErreur = String(e && e.message || e).slice(0, 120)
3490    render()
3491  }
3492}
3493
3494/* ══════════════════════════════════════════════════════════════════════════
3495 * LE PANNEAU QUI SAIT D'OÙ L'ON PARLE
3496 * ══════════════════════════════════════════════════════════════════════════
3497 *
3498 * « Depuis une leçon, on part vers le Studio Éducation et on perd le
3499 *   contexte. » On rouvrait une conversation vierge, il fallait réexpliquer où
3500 *   l'on était, et la moitié du temps on renonçait.
3501 *
3502 * Le panneau s'ouvre PAR-DESSUS l'écran, sans le quitter : la leçon reste
3503 * derrière, on revient d'un clic, et la conversation garde son fil. Il lit
3504 * `STATE.ecoleVue` et `STATE.ecoleCle` au moment d'envoyer — donc il suit
3505 * toujours l'endroit où l'on est, sans qu'on ait à le lui dire.
3506 *
3507 * ── IL PASSE PAR LA GORGE, PAS PAR LE CHAT DES ESPACES ───────────────────
3508 *
3509 * Les espaces écrivaient leurs personas à la main (« Tu es CREA, l'assistant
3510 * IA de la plateforme CREA… ») — une dette `a-relier` au registre des bouches,
3511 * refermée depuis : chaque espace est un lieu de STUDIOS.
3512 * Ce panneau passe donc par `buildSystemPrompt(false, question, 'edu')`, comme
3513 * la séance et comme la leçon, et son cadre vit au poste
3514 * (`conversationEcole`, src/app-domaine-surface.js).
3515 *
3516 * ── LE FIL EST GARDÉ, MAIS PAS ÉTERNEL ───────────────────────────────────
3517 *
3518 * Douze échanges au plus partent au modèle : au-delà, on paie une conversation
3519 * entière pour une question de trois mots. Ce qui est à l'écran, lui, reste
3520 * entier — couper l'affichage ferait disparaître une réponse qu'on relisait.
3521 */
3522const ACADEMIE_TOURS_ENVOYES = 12
3523
3524window.academiePanneau = function (ouvrir) {
3525  STATE.academiePanneauOuvert = ouvrir === undefined ? !STATE.academiePanneauOuvert : Boolean(ouvrir)
3526  save(); render()
3527  if (STATE.academiePanneauOuvert) setTimeout(() => document.getElementById('academie-demande')?.focus(), 40)
3528}
3529
3530/* OÙ L'ON EST, AU MOMENT D'ENVOYER. Calculé ici et pas gardé dans l'état : un
3531   endroit rangé au moment d'ouvrir le panneau serait FAUX dès qu'on change de
3532   leçon derrière — et c'est précisément ce qu'on vient corriger. */
3533function _ecoleOuJeSuis() {
3534  const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil'
3535  const [mid, cid, rang] = String(STATE.ecoleCle || '').split('/')
3536  const m = mid ? window.ecoleMatiere(mid) : null
3537  const f = m ? window.ecoleFiliere(m.f) : (vue === 'filiere' ? window.ecoleFiliere(STATE.ecoleCle) : null)
3538  const c = (m && cid) ? window.ecoleCours(mid, cid) : null
3539  const theme = (m && cid) ? (m.themes || []).find(t => (t.cours || []).some(x => x.id === cid)) : null
3540  const l = (c && rang !== undefined) ? c.lecons[Number(rang)] : null
3541  return {
3542    filiere: f && f.nom, matiere: m && m.nom, theme: theme && theme.nom,
3543    cours: c && c.titre, objectif: c && c.objectif,
3544    lecon: l && l.t, nature: l && l.nature && l.nature.label,
3545  }
3546}
3547
3548/* OÙ L'ON EST DANS LES TUTORIELS. Le frère de `_ecoleOuJeSuis`, et calculé au
3549   même moment — à l'ENVOI, jamais à l'ouverture du panneau : un endroit rangé
3550   quand on ouvre serait faux dès qu'on change de page derrière. */
3551function _tutoOuJeSuis() {
3552  const vue = TUTO_VUES.includes(STATE.tutoVue) ? STATE.tutoVue : 'accueil'
3553  const [pid, tid] = String(STATE.tutoCle || '').split('/')
3554  const p = vue === 'domaine' ? null : window.tutoParcours(pid)
3555  const d = vue === 'domaine' ? window.tutoDomaine(STATE.tutoCle) : (p ? window.tutoDomaine(p.d) : null)
3556  const t = (p && tid) ? window.tutoUn(pid, tid) : null
3557  return {
3558    domaine: d && d.nom, parcours: p && p.nom,
3559    tutoriel: t && t.titre, objectif: t && t.objectif, lieu: t && t.ouvreLabel,
3560  }
3561}
3562
3563/* LA SITUATION DU PANNEAU — UNE SEULE PORTE, DEUX ENDROITS.
3564 *
3565 * Le panneau suit la personne dans TOUTE l'Académie ; il doit donc dire d'où
3566 * elle parle des deux côtés. Deux appels au modèle, un par section, auraient
3567 * donné deux façons de composer le même prompt — et le jour où l'une apprend
3568 * quelque chose, l'autre l'ignore. C'est le canal parallèle que ce dépôt passe
3569 * son temps à défaire.
3570 *
3571 * LA SURFACE CHANGE AVEC LA SECTION, ET CE N'EST PAS UN DÉTAIL. L'École parle
3572 * depuis `edu`, qui porte les outils de son domaine. Les tutoriels parlent de
3573 * CREA elle-même : leur lieu est le CHAT — `null`, « pas de lieu », le plancher
3574 * du chat général. Leur donner `edu` aurait fait monter le métier d'un studio
3575 * d'éducation par-dessus une question sur le trombone.
3576 */
3577function _academieSituation() {
3578  const section = ACADEMIE_SECTIONS.some(s => s.id === STATE.academieSection) ? STATE.academieSection : 'tutoriels'
3579  return section === 'education'
3580    ? { ...conversationEcole(_ecoleOuJeSuis()), surface: 'edu', chemin: _ecoleCheminLisible() }
3581    : { ...conversationTutoriel(_tutoOuJeSuis()), surface: null, chemin: _tutoCheminLisible() }
3582}
3583
3584/* CE QUI S'ÉCRIT DANS L'EN-TÊTE DU PANNEAU. C'est la moitié visible du
3585   contexte : sans elle on ne peut pas SAVOIR que CREA sait d'où l'on parle,
3586   donc on le redit, et le panneau n'a servi à rien. */
3587function _ecoleCheminLisible() {
3588  const ou = _ecoleOuJeSuis()
3589  return [ou.matiere, ou.cours, ou.lecon].filter(Boolean).join(' › ')
3590}
3591function _tutoCheminLisible() {
3592  const ou = _tutoOuJeSuis()
3593  return [ou.domaine, ou.parcours, ou.tutoriel].filter(Boolean).join(' › ')
3594}
3595
3596window.academieDemander = async function (texteDonne) {
3597  const champ = document.getElementById('academie-demande')
3598  const question = String(texteDonne || (champ && champ.value) || '').trim()
3599  if (!question || STATE.academieEnvoiEnCours) return
3600  if (champ && !texteDonne) champ.value = ''
3601
3602  STATE.academieFil = Array.isArray(STATE.academieFil) ? STATE.academieFil : []
3603  STATE.academieFil.push({ role: 'user', content: question })
3604  STATE.academieFil.push({ role: 'assistant', content: '', pending: true })
3605  STATE.academieEnvoiEnCours = true
3606  STATE._academieFilErreur = ''
3607  render()
3608
3609  const idx = STATE.academieFil.length - 1
3610  try {
3611    const situation = _academieSituation()
3612    let dernierRendu = 0
3613    const texte = await parlerAuModele({
3614      messages: STATE.academieFil.slice(0, -1).slice(-ACADEMIE_TOURS_ENVOYES).map(m => ({ role: m.role, content: m.content || '' })),
3615      model: STATE.selectedModel?.id || STATE.models[0]?.id,
3616      systemPrompt: buildSystemPrompt(false, question, situation.surface) + '\n\n' + situation.cadre,
3617      ...internalAuth(),
3618      rappels: {
3619        surTexte: (t) => {
3620          if (STATE.academieFil[idx]) STATE.academieFil[idx] = { role: 'assistant', content: t, pending: true }
3621          const now = Date.now()
3622          if (now - dernierRendu > 250) { dernierRendu = now; render() }
3623        },
3624      },
3625    }, { intention: 'agent', surface: situation.surface })
3626    STATE.academieFil[idx] = { role: 'assistant', content: texte || '_(pas de réponse)_' }
3627  } catch (e) {
3628    /* ON RETIRE LA RÉPONSE EN ATTENTE. La laisser vide et « pending » ferait
3629       tourner l'indicateur pour toujours sur une réponse qui ne viendra pas. */
3630    STATE.academieFil.splice(idx, 1)
3631    STATE._academieFilErreur = String(e && e.message || e).slice(0, 140)
3632  }
3633  STATE.academieEnvoiEnCours = false
3634  save(); render()
3635}
3636
3637/* LES TROIS DEMANDES QUI REVIENNENT. Ce sont celles de la bande du professeur,
3638   déplacées ici : elles servent partout dans l'École, pas seulement dans une
3639   leçon — et depuis un cours ou une matière, « interroge-moi » a un sens. */
3640const ECOLE_DEMANDES = [
3641  'Explique-moi ça autrement',
3642  'Donne-moi un exemple concret',
3643  'Interroge-moi là-dessus',
3644]
3645
3646/* ET LES TROIS AUTRES, POUR L'AUTRE SECTION. « Interroge-moi là-dessus » n'a
3647   aucun sens devant un tutoriel : on n'y révise pas, on essaie. Trois amorces
3648   qui ne collent pas à ce qu'on regarde se lisent comme un décor, et on tape
3649   sa question à côté — donc le panneau a échoué là où il devait servir. */
3650const TUTO_DEMANDES = [
3651  'Montre-moi ça pas à pas',
3652  'À quoi ça sert concrètement ?',
3653  'Fais-le avec moi sur mon cas',
3654]
3655
3656function _academiePanneauChat() {
3657  if (!STATE.academiePanneauOuvert) return ''
3658  const fil = Array.isArray(STATE.academieFil) ? STATE.academieFil : []
3659  const dansLEcole = STATE.academieSection === 'education'
3660  const chemin = dansLEcole ? _ecoleCheminLisible() : _tutoCheminLisible()
3661  const amorces = dansLEcole ? ECOLE_DEMANDES : TUTO_DEMANDES
3662  return `<div class="fixed inset-0 z-40 flex justify-end" style="background:rgba(0,0,0,.28)" onclick="if(event.target===this)window.academiePanneau(false)">
3663    <aside class="flex flex-col h-full" style="width:min(520px,100%);background:var(--bg-primary);border-left:1px solid var(--border);box-shadow:-8px 0 32px rgba(0,0,0,.12)">
3664      <header class="shrink-0 flex items-start gap-3 px-5 py-4" style="border-bottom:1px solid var(--border)">
3665        <span class="shrink-0 inline-flex items-center justify-center rounded-xl" style="width:34px;height:34px;background:var(--bg-secondary);border:1px solid var(--border);color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span>
3666        <div class="flex-1 min-w-0">
3667          <p class="text-[max(0.9375rem,15px)] font-bold leading-tight" style="color:var(--t-primary)">Demander à CREA</p>
3668          ${/* L'ENDROIT EST ÉCRIT, ET C'EST TOUT L'OBJET DU PANNEAU : on doit
3669               VOIR qu'elle sait d'où l'on parle, sinon on réexplique quand
3670               même. */''}
3671          <p class="text-[max(0.75rem,12px)] mt-0.5 truncate" style="color:var(--t-dim)">${chemin ? escHtml(chemin) : (dansLEcole ? 'depuis l’École' : 'depuis les tutoriels')}</p>
3672        </div>
3673        <button onclick="window.academiePanneau(false)" class="shrink-0 rounded-lg p-1.5" style="background:none;border:none;color:var(--t-faint)">${_icon('close', 'w-4 h-4')}</button>
3674      </header>
3675
3676      <div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-3.5">
3677        ${fil.length === 0 ? `<div class="flex flex-col gap-2 pt-2">
3678          <p class="text-[max(0.84375rem,13.5px)] mb-1" style="color:var(--t-dim)">
3678Posez votre question — elle sait où vous en êtes.</p>
3679          ${amorces.map(d => `<button onclick="window.academieDemander('${escHtml(d)}')" class="text-left rounded-xl px-3.5 py-2.5 text-[max(0.84375rem,13.5px)]" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(d)}</button>`).join('')}
3680        </div>` : fil.map(m => m.role === 'user'
3681          ? `<div class="self-end max-w-[85%] rounded-2xl rounded-br-sm px-3.5 py-2 text-[max(0.84375rem,13.5px)]" style="background:var(--accent);color:#fff">${escHtml(m.content)}</div>`
3682          : `<div class="self-start max-w-[92%] msg-content text-[max(0.84375rem,13.5px)]" style="color:var(--t-secondary)">${m.pending && !m.content ? '<span class="opacity-50">…</span>' : renderMarkdown(m.content || '')}</div>`
3683        ).join('')}
3684        ${STATE._academieFilErreur ? `<p class="text-[max(0.8125rem,13px)]" style="color:var(--danger)">${escHtml(STATE._academieFilErreur)}</p>` : ''}
3685      </div>
3686
3687      <div class="shrink-0 px-4 py-3.5" style="border-top:1px solid var(--border)">
3688        <div class="flex items-end gap-2">
3689          <textarea id="academie-demande" rows="1" placeholder="Votre question…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.academieDemander()}" class="flex-1 rounded-xl px-3.5 py-2.5 text-[max(0.875rem,14px)] outline-none resize-none" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary);max-height:120px"></textarea>
3690          <button onclick="window.academieDemander()" class="shrink-0 rounded-xl px-4 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:${STATE.academieEnvoiEnCours ? 'var(--bg-tertiary)' : 'var(--accent)'};color:${STATE.academieEnvoiEnCours ? 'var(--t-faint)' : '#fff'};border:none">${STATE.academieEnvoiEnCours ? '…' : 'Envoyer'}</button>
3691        </div>
3692      </div>
3693    </aside>
3694  </div>`
3695}
3696
3697/* LE BOUTON QUI L'OUVRE — présent sur TOUS les écrans de l'École, flottant en
3698   bas à droite. Pas une barre en bas de page : c'est ce qu'on vient de retirer,
3699   parce qu'il fallait défiler pour la voir. */
3700function _academieBoutonDemander() {
3701  if (STATE.academiePanneauOuvert) return ''
3702  const n = (STATE.academieFil || []).length
3703  return `<button onclick="window.academiePanneau(true)" class="fixed z-30 inline-flex items-center gap-2 rounded-full px-5 py-3 text-[max(0.875rem,14px)] font-semibold" style="right:24px;bottom:24px;background:var(--accent);color:#fff;border:none;box-shadow:0 4px 16px rgba(0,0,0,.18)">
3704    ${_icon('gradcap', 'w-4 h-4')}Demander à CREA${n ? `<span class="rounded-full px-1.5 text-[max(0.6875rem,11px)]" style="background:rgba(255,255,255,.24)">${Math.ceil(n / 2)}</span>` : ''}
3705  </button>`
3706}
3707
3708/* ══════════════════════════════════════════════════════════════════════════
3709 * LES QUESTIONS D'UNE LEÇON — LE MÊME LECTEUR QUE LE SERVEUR
3710 * ══════════════════════════════════════════════════════════════════════════
3711 *
3712 * Le serveur refuse à l'entrée une question dont « bonne » sort des choix
3713 * (server/ecole-lecon.js). L'écran doit lire EXACTEMENT la même chose, sinon
3714 * les deux divergent : une leçon écrite localement — donc jamais passée par le
3715 * serveur — afficherait des boutons dont aucun n'est juste.
3716 *
3717 * On ne peut pas importer le module du serveur ici : ce fichier est un script
3718 * de navigateur. On réécrit donc le lecteur, et une garde EXÉCUTE les deux sur
3719 * les mêmes entrées pour vérifier qu'ils décident pareil. C'est la seule forme
3720 * de copie que ce dépôt accepte : celle qu'une garde tient accordée.
3721 */
3722function _ecoleQuestions(contenu) {
3723  const out = []
3724  const re = /^QUESTION:(\{.*\})\s*$/gm
3725  let m
3726  while ((m = re.exec(String(contenu || '')))) {
3727    let q = null
3728    try { q = JSON.parse(m[1]) } catch (e) { continue }
3729    const choix = Array.isArray(q.choix) ? q.choix.map(c =>
3729 String(c == null ? '' : c).trim()).filter(Boolean) : []
3730    const bonne = Number(q.bonne)
3731    if (!q.q || typeof q.q !== 'string' || !q.q.trim()) continue
3732    if (choix.length < 2) continue
3733    if (!Number.isInteger(bonne) || bonne < 0 || bonne >= choix.length) continue
3734    out.push({ q: String(q.q).trim(), choix, bonne, pourquoi: String(q.pourquoi || '').trim() })
3735  }
3736  return out
3737}
3738
3739/* LE CORPS SANS LES QUESTIONS. Les laisser dans le Markdown les afficherait
3740   DEUX fois : une fois en JSON brut au fil du texte, une fois en boutons. */
3741function _ecoleCorpsSansQuestions(contenu) {
3742  return String(contenu || '').replace(/^QUESTION:\{.*\}\s*$/gm, '').replace(/\n{3,}/g, '\n\n').trim()
3743}
3744
3745/* RÉPONDRE. L'état n'est PAS persisté : c'est une aide à la lecture, pas une
3746   note. Le persister ferait qu'on retrouve ses réponses six mois plus tard
3747   sans se rappeler la question — et surtout, ça figerait une leçon qui peut
3748   être réécrite entre-temps. */
3749window.ecoleRepondre = function (cle, i, choisi) {
3750  STATE._ecoleReponses = STATE._ecoleReponses || {}
3751  const k = cle + '#' + i
3752  if (STATE._ecoleReponses[k] !== undefined) return  // on ne répond qu'une fois
3753  STATE._ecoleReponses[k] = Number(choisi)
3754  render()
3755}
3756
3757function _ecoleBlocQuestions(cle, questions) {
3758  if (!questions.length) return ''
3759  const rep = STATE._ecoleReponses || {}
3760  return `<section class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)">
3761    <p class="px-5 pt-4 pb-1 text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-secondary)">Vérifier que c'est compris</p>
3762    ${questions.map((q, i) => {
3763      const donnee = rep[cle + '#' + i]
3764      const repondu = donnee !== undefined
3765      return `<div class="px-5 py-4" style="border-top:1px solid var(--border-dim)">
3766        <p class="text-[max(0.90625rem,14.5px)] font-semibold mb-2.5" style="color:var(--t-primary)">${i + 1}. ${escHtml(q.q)}</p>
3767        <div class="flex flex-col gap-1.5">
3768          ${q.choix.map((c, j) => {
3769            const juste = j === q.bonne
3770            const choisi = donnee === j
3771            const fond = !repondu ? 'var(--bg-secondary)'
3772              : juste ? 'color-mix(in srgb,var(--success) 12%,transparent)'
3773              : choisi ? 'color-mix(in srgb,var(--danger) 12%,transparent)' : 'var(--bg-secondary)'
3774            const bord = !repondu ? 'var(--border)' : juste ? 'var(--success)' : choisi ? 'var(--danger)' : 'var(--border)'
3775            return `<button ${repondu ? '' : `onclick="window.ecoleRepondre('${escHtml(cle)}',${i},${j})"`} class="text-left rounded-xl px-3.5 py-2 text-[max(0.84375rem,13.5px)] flex items-center gap-2.5" style="background:${fond};border:1px solid ${bord};color:var(--t-secondary)">
3776              <span class="shrink-0 inline-flex items-center justify-center rounded-full text-[max(0.6875rem,11px)] font-bold" style="width:20px;height:20px;background:var(--bg-input);border:1px solid ${bord};color:var(--t-dim)">${String.fromCharCode(65 + j)}</span>
3777              <span class="flex-1">${escHtml(c)}</span>
3778              ${repondu && juste ? _icon('check', 'w-4 h-4') : ''}
3779            </button>`
3780          }).join('')}
3781        </div>
3782        ${repondu && q.pourquoi ? `<p class="text-[max(0.8125rem,13px)] mt-2.5 leading-snug" style="color:var(--t-dim)">${escHtml(q.pourquoi)}</p>` : ''}
3783      </div>`
3784    }).join('')}
3785  </section>`
3786}
3787
3788/* ── ÉCRAN 5 : LA LEÇON ─────────────────────────────────────────────────── */
3789/* `ECOLE_AIDES` EST PARTIE DANS LE PANNEAU. Ces trois demandes ne servaient
3790   que dans une leçon et ouvraient le studio ; elles sont maintenant
3791   `ECOLE_DEMANDES`, et elles valent partout dans l'École — depuis un cours ou
3792   une matière, « interroge-moi » a un sens lui aussi. */
3793
3794function _ecoleLecon(cle) {
3795  const [mid, cid, rang] = String(cle || '').split('/')
3796  const c = window.ecoleCours(mid, cid)
3797  if (!c) return _ecoleAccueil()
3798  const l = c.lecons[Number(rang)]
3799  if (!l) return _ecoleCours(mid + '/' + cid)
3800  const m = window.ecoleMatiere(mid)
3801  const suivante = c.lecons[Number(rang) + 1]
3802  const precedente = c.lecons[Number(rang) - 1]
3803  const fait = window.ecoleLeconFaite(l.cle)
3804
3805  const jauge = `<div class="flex items-center gap-4 px-5 py-3.5 mb-5 rounded-2xl" style="background:var(--bg-input);border:1px solid var(--border)">
3806    <button onclick="window.ecoleVue('cours','${escHtml(mid + '/' + cid)}')" class="shrink-0 inline-flex items-center gap-1.5 text-[max(0.75rem,12px)] font-medium" style="color:var(--t-dim);background:none;border:none">${_icon('chevronLeft', 'w-3.5 h-3.5')}${escHtml(c.titre)}</button>
3807    <span class="flex-1 flex items-center gap-1">${c.lecons.map(x => `<span class="flex-1 rounded-full" style="height:4px;background:${window.ecoleLeconFaite(x.cle) ? 'var(--accent)' : x.cle === l.cle ? 'color-mix(in srgb,var(--accent) 45%,transparent)' : 'var(--bg-tertiary)'}"></span>`).join('')}</span>
3808    <span class="shrink-0 text-[max(0.75rem,12px)] font-semibold" style="color:var(--t-dim)">Leçon ${Number(rang) + 1} sur ${c.lecons.length}</span>
3809  </div>`
3810
3811  /* ── LE CORPS DE LA LEÇON ────────────────────────────────────────────────
3812   *
3813   * Il disait « le contenu de cette leçon n'est pas encore branché ». C'était
3814   * honnête, et c'était mieux qu'un lecteur vidéo mort — mais mille cent
3815   * trente-neuf leçons qui disent toutes la même phrase, ce n'est plus une
3816   * école, c'est un sommaire.
3817   *
3818   * TROIS ÉTATS, ET ILS NE SE RESSEMBLENT PAS :
3819   *   · ÉCRITE — le texte, rendu par le Markdown qui existe déjà ;
3820   *   · EN COURS D'ÉCRITURE — ce qui arrive, au fil de l'écriture ;
3821   *   · PAS ENCORE — un bouton, et la phrase honnête d'avant.
3822   * Confondre le troisième avec « en train de charger » ferait tourner un
3823   * indicateur pour toujours sur une leçon que personne n'a demandée. */
3824  const ecrite = window.ecoleLeconEcrite(l.cle)
3825  const enCours = STATE.ecoleLeconEnCours === l.cle
3826  /* LE CORPS ET LES QUESTIONS SE SÉPARENT AVANT LE RENDU. Laissées dans le
3827     Markdown, les lignes `QUESTION:{…}` s'afficheraient en JSON brut au fil du
3828     texte ET en boutons plus bas — deux fois la même chose, dont une illisible.
3829     `renderMarkdown` pose au passage le lecteur des vidéos : un lien YouTube
3830     dans la leçon devient un vrai lecteur, sans contrat de plus. */
3831  const questions = ecrite ? _ecoleQuestions(ecrite) : []
3832  const corps = ecrite
3833    ? `<article class="msg-content rounded-2xl px-6 py-5 mb-5" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${renderMarkdown(_ecoleCorpsSansQuestions(ecrite))}</article>`
3834      + _ecoleBlocQuestions(l.cle, questions)
3835    : enCours
3836      ? `<div class="rounded-2xl px-6 py-5 mb-5" style="background:var(--bg-input);border:1px solid var(--border)">
3837          <p class="text-[max(0.8125rem,13px)] font-semibold mb-2" style="color:var(--accent)">CREA écrit cette leçon…</p>
3838          <div class="msg-content" style="color:var(--t-secondary)">${renderMarkdown(STATE.ecoleLeconTexte || '')}</div>
3839        </div>`
3840      : `<div class="rounded-2xl overflow-hidden mb-5" style="background:var(--bg-input);border:1px solid var(--border)">
3841    <div class="relative flex items-center justify-center" style="aspect-ratio:16/9;background:#101314">
3842      <span class="inline-flex items-center justify-center rounded-full" style="width:62px;height:62px;background:rgba(255,255,255,.14);border:1.5px solid rgba(255,255,255,.5);color:#fff">${_icon(l.nature.icone, 'w-6 h-6')}</span>
3843      <span class="absolute" style="bottom:14px;left:18px;color:rgba(255,255,255,.86);font-size:12px;font-weight:600">${escHtml(l.nature.label)} · ${l.m} min</span>
3844    </div>
3845    <div class="p-5 flex items-center gap-4">
3846      <p class="flex-1 text-[max(0.9375rem,15px)] leading-relaxed" style="color:var(--t-muted)">Cette leçon n’est pas encore écrite. CREA peut la rédiger maintenant, à partir du cours et de son objectif — et elle restera écrite.</p>
3847      <button onclick="window.ecoleEcrireLecon('${escHtml(l.cle)}')" class="shrink-0 rounded-full px-5 py-2.5 text-[max(0.875rem,14px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Écrire la leçon</button>
3848    </div>
3849    ${STATE._ecoleLeconErreur ? `<div class="px-5 pb-4"><p class="text-[max(0.8125rem,13px)]" style="color:var(--danger)">${escHtml(STATE._ecoleLeconErreur)}</p></div>` : ''}
3850  </div>`
3851
3852  /* LE PROFESSEUR N'EST PLUS UNE BANDE DE BOUTONS QUI PART AILLEURS. Les trois
3853     demandes ouvraient le Studio Éducation par le geste des amorces : on
3854     quittait la leçon, et on perdait l'endroit. Elles ouvrent maintenant le
3855     panneau, qui reste PAR-DESSUS la leçon et sait d'où l'on parle. */
3856  const prof = `<div class="flex items-center gap-3.5 px-5 py-4 mb-5 rounded-2xl" style="background:var(--bg-secondary);border:1px solid var(--border)">
3857    <span class="shrink-0 inline-flex items-center justify-center rounded-xl" style="width:34px;height:34px;background:var(--bg-input);border:1px solid var(--border);color:var(--accent)">${_icon('gradcap', 'w-4 h-4')}</span>
3858    <span class="shrink-0 text-[max(0.84375rem,13.5px)] font-semibold" style="color:var(--t-secondary)">Bloqué sur cette leçon ?</span>
3859    <span class="flex-1 flex flex-wrap gap-2">${ECOLE_DEMANDES.map(d => `<button onclick="window.academiePanneau(true);window.academieDemander('${escHtml(d)}')" class="rounded-full px-3.5 py-1.5 text-[max(0.78125rem,12.5px)] font-medium" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${escHtml(d)}</button>`).join('')}</span>
3860  </div>`
3861
3862  const nav = `<div class="flex items-center justify-between gap-4">
3863    ${precedente
3864      ? `<button onclick="window.ecoleVue('lecon','${escHtml(precedente.cle)}
3864')" class="inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-[max(0.84375rem,13.5px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">${_icon('chevronLeft', 'w-4 h-4')}${escHtml(precedente.t.slice(0, 40))}</button>`
3865      : '<span></span>'}
3866    <button onclick="window.ecoleFaite('${escHtml(l.cle)}'${suivante ? `,'${escHtml(suivante.cle)}'` : ''})" class="inline-flex items-center gap-2 rounded-full px-6 py-3 text-[max(0.90625rem,14.5px)] font-semibold" style="background:${fait ? 'var(--bg-input)' : 'var(--accent)'};color:${fait ? 'var(--accent)' : '#fff'};border:${fait ? '1px solid var(--accent)' : 'none'}">
3867      ${fait ? 'Déjà faite' : 'Marquer comme faite'}${suivante ? ', et continuer' : ''} ${_icon('chevronRight', 'w-4 h-4')}
3868    </button>
3869  </div>`
3870
3871  return jauge + `<div class="flex items-center gap-2 mb-2">
3872      <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[max(0.71875rem,11.5px)] font-bold" style="background:var(--accent-bg);color:var(--accent)">${_icon(l.nature.icone, 'w-3 h-3')}${escHtml(l.nature.label.toUpperCase())}</span>
3873      <span class="text-[max(0.75rem,12px)]" style="color:var(--t-faint)">${l.m} min · ${escHtml(m.nom)} › ${escHtml(c.titre)}</span>
3874    </div>
3875    <p class="text-[max(1.6875rem,27px)] font-bold leading-tight mb-4" style="color:var(--t-primary)">${escHtml(l.t)}</p>`
3876    + corps + prof + nav
3877}
3878
3879/* LE FIL D'ARIANE — il dit où l'on est, et chaque cran ramène. Sans lui, on
3880   descend quatre étages et le seul retour est le bouton du navigateur. */
3881function _ecoleFil() {
3882  const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil'
3883  if (vue === 'accueil') return ''
3884  const cle = String(STATE.ecoleCle || '')
3885  const crans = [{ l: 'École', v: 'accueil', c: null }]
3886  const [mid, cid] = cle.split('/')
3887  const m = vue === 'filiere' ? null : window.ecoleMatiere(mid)
3888  const f = vue === 'filiere' ? window.ecoleFiliere(cle) : (m ? window.ecoleFiliere(m.f) : null)
3889  if (f) crans.push({ l: f.nom, v: 'filiere', c: f.id })
3890  if (m) crans.push({ l: m.nom, v: 'matiere', c: m.id })
3891  if (m && cid && (vue === 'cours' || vue === 'lecon')) {
3892    const c = window.ecoleCours(mid, cid)
3893    if (c) crans.push({ l: c.titre, v: 'cours', c: mid + '/' + cid })
3894  }
3895  return `<div class="flex flex-wrap items-center gap-1.5 mb-4 text-[max(0.78125rem,12.5px)]">${crans.map((x, i) => `
3896    <button onclick="window.ecoleVue('${x.v}'${x.c ? `,'${escHtml(x.c)}'` : ''})" style="color:${i === crans.length - 1 ? 'var(--t-secondary)' : 'var(--t-faint)'};background:none;border:none;font-weight:${i === crans.length - 1 ? '600' : '500'}">${escHtml(x.l)}</button>
3897    ${i < crans.length - 1 ? `<span style="color:var(--t-faint)">›</span>` : ''}`).join('')}</div>`
3898}
3899
3900function _academieEducation() {
3901  const vue = ECOLE_VUES.includes(STATE.ecoleVue) ? STATE.ecoleVue : 'accueil'
3902  const cle = STATE.ecoleCle
3903  /* ON DEMANDE LES LEÇONS DU COURS OUVERT, ET UNE SEULE FOIS PAR COURS. Le
3904     rendu est appelé à chaque frappe : sans ce garde-fou, l'écran interrogerait
3905     le serveur des dizaines de fois par seconde. Et on demande les clés du
3906     COURS, pas de la leçon seule — on va lire les suivantes. */
3907  if (vue === 'cours' || vue === 'lecon') {
3908    const [mid, cid] = String(cle || '').split('/')
3909    const c = window.ecoleCours(mid, cid)
3910    if (c && STATE._ecoleLeconsDemandees !== mid + '/' + cid) {
3911      STATE._ecoleLeconsDemandees = mid + '/' + cid
3912      window.ecoleChargerLecons(c.lecons.map(x => x.cle))
3913    }
3914  }
3915  const corps = vue === 'filiere' ? _ecoleFiliere(cle)
3916    : vue === 'matiere' ? _ecoleMatiere(cle)
3917    : vue === 'cours' ? _ecoleCours(cle)
3918    : vue === 'lecon' ? _ecoleLecon(cle)
3919    : _ecoleAccueil()
3920  /* LE PANNEAU ET SON BOUTON SONT RENDUS PAR LA SECTION, pas par chaque écran :
3921     ils suivent la personne partout dans l'École, et c'est exactement ce qui
3922     manquait — la porte vers CREA n'existait que dans une leçon. */
3923  /* LE PANNEAU ET SON BOUTON SONT MONTÉS D'UN ÉTAGE. Ils étaient rendus ici,
3924     donc dans l'École seule ; ils suivent maintenant la personne dans TOUTE
3925     l'Académie, et c'est `renderAcademieSpace` qui les pose — une fois, pour
3926     les deux sections. Les laisser aussi ici en aurait affiché deux. */
3927  return _ecoleFil() + corps
3928}
3929
3930/* LES DEUX SECTIONS, EN UN SEUL BLOC. C'étaient deux grosses gélules vertes
3931   portant chacune son libellé ET sa glose — « École  Apprendre une matière ».
3932   Deux boutons de tailles différentes, l'un criard, l'autre gris : ça se lit
3933   comme une promotion, pas comme une navigation.
3934   Un interrupteur segmenté dit la même chose en plus calme : un seul fond, un
3935   curseur clair sur la section active, et la glose descend dans le sous-titre
3936   de la page — où elle était déjà, sans faire doublon. */
3937function _academieOnglets(section) {
3938  return `<div class="inline-flex gap-0.5 p-0.5 rounded-xl shrink-0" style="background:var(--bg-tertiary)">${ACADEMIE_SECTIONS.map(s => `
3939    <button onclick="window.academieSection('${s.id}')" class="rounded-[10px] px-4 py-1.5 text-[max(0.8125rem,13px)] font-semibold transition-colors" style="${section === s.id ? 'background:var(--bg-input);color:var(--t-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)' : 'background:none;color:var(--t-dim)'};border:none">
3940      ${escHtml(s.label)}
3941    </button>`).join('')}</div>`
3942}
3943
3944/* ── LA BARRE DE SECTION — L'INTERRUPTEUR, LA PORTE VERS CREA, LES RÉGLAGES
3945 *
3946 * « À côté du bouton tutoriel là, tu vas mettre un bouton qui est lié au fait
3947 *   de pouvoir questionner CREA. »
3948 *
3949 * Il y était déjà, mais SEULEMENT en bas de page, et seulement dans une
3950 * section : une barre de saisie qu'il fallait faire défiler pour trouver. Le
3951 * même geste, remonté à côté de l'interrupteur, se voit sans chercher — et
3952 * ouvre le panneau, qui sait d'où l'on parle.
3953 *
3954 * LES RÉGLAGES SONT UNE ROUE, PAS UNE TROISIÈME SECTION. Une section est un
3955 * CONTENU qu'on parcourt ; les réglages sont un endroit où l'on va faire une
3956 * chose puis d'où l'on revient. Les mettre dans l'interrupteur les aurait mis
3957 * à égalité avec l'École, et on serait tombé dessus en cherchant un cours.
3958 */
3959function _academieBarreDeSection(section) {
3960  return `<div class="flex items-center gap-2 shrink-0">
3961    ${_academieOnglets(section)}
3962    <button onclick="window.academiePanneau(true)" class="inline-flex items-center gap-1.5 rounded-xl px-3.5 py-1.5 text-[max(0.8125rem,13px)] font-semibold" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-secondary)">
3963      ${_icon('talk', 'w-3.5 h-3.5')}Demander à CREA
3964    </button>
3965    <button onclick="window.academieVue('reglages')" title="Réglages de l'Académie" class="inline-flex items-center justify-center rounded-xl" style="width:32px;height:32px;background:var(--bg-input);border:1px solid var(--border);color:var(--t-dim)">
3966      ${_icon('gear', 'w-4 h-4')}
3967    </button>
3968  </div>`
3969}
3970
3971function renderAcademieSpace() {
3972  const section = ACADEMIE_SECTIONS.some(s => s.id === STATE.academieSection) ? STATE.academieSection : 'tutoriels'
3973  let vue = section === 'education' ? 'education' : (STATE.academieVue || 'apercu')
3974  // Le refus vaut aussi au RENDU : un état déjà posé ne doit pas passer.
3975  if (vue === 'registre' && isClientView()) vue = 'apercu'
3976  const cle = STATE.academieCle
3977  const role = vue === 'role' ? ACADEMIE_ROLES.find(r => r.id === cle) : null
3978  const fiche = vue === 'fiche' ? _academieCatalogue().find(r => r.type + ':' + r.id === cle) : null
3979  const lieuOk = vue === 'lieu' && _academieLieux().includes(cle)
3980  const fil = role ? role.label
3981    : lieuOk ? _academieTitreDuLieu(cle)
3982    : vue === 'registre' ? 'Le registre'
3983    : vue === 'reglages' ? "Réglages de l'Académie"
3984    : vue === 'ressources' ? 'Toutes les ressources'
3985    : fiche ? fiche.titre : ''
3986  const sousTitre = vue === 'registre' ? "Les sources branchées, ce qui en a été importé, et sous quelle licence."
3987    : vue === 'reglages' ? "Le bilan, les annonces, le dépôt — et, pour le propriétaire, le registre."
3988    : vue === 'ressources' ? "Tous les tutoriels et cas d'usage de l'Académie, en un seul endroit."
3989    : role ? "Prompts et cas d'usage pour ce métier."
3990    : lieuOk ? 'Ce qui se pratique ici.'
3991    : fiche ? fiche.quoi
3992    : section === 'education' ? "Huit filières, cinquante-six matières — des cours à suivre, pas des questions à poser."
3993    : "Huit domaines, vingt-cinq tutoriels — des gestes à faire, pas des fiches à lire."
3994  const corps = section === 'education' ? _academieEducation()
3995    : vue === 'registre' ? _academieRegistre()
3996    : vue === 'reglages' ? _academieReglages()
3997    : role ? _academiePageDeRole(role)
3998    : lieuOk ? _academiePageDeLieu(cle)
3999    : vue === 'ressources' ? _academieRessources()
4000    : fiche ? _academieFiche(cle)
4001    : _academieTutoriels()
4002  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
4003    ${_spaceMenuBtn()}
4004    <div class="flex-1 overflow-y-auto min-h-0">
4005      ${/* LA LARGEUR EST LA MÊME POUR LES DEUX SECTIONS, MAINTENANT QU'ELLES
4006           ONT LA MÊME FORME. Le `max-w-3xl` de l'espace vaut 768 px — la bonne
4007           mesure pour une colonne de TEXTE. Un CATALOGUE de cartes couchées
4008           dedans n'a la place que d'une par rangée, et le reste s'empile :
4009           c'était tout le reproche, « ça fait trop bloc empilé ». Les deux
4010           colonnes prennent donc 1120 px ; les pages de TEXTE — une fiche, un
4011           rôle, le registre, les réglages — gardent 768, parce qu'élargir une
4012           colonne de texte la rend moins lisible, pas plus. */''}
4013      <div class="${fil ? 'max-w-3xl' : 'max-w-[1120px]'} mx-auto px-4 pt-8 pb-6">
4014        ${_academieEnTete(fil, sousTitre, !!role, fil ? '' : _academieBarreDeSection(section))}
4015        ${corps}
4016        ${/* LA BARRE DE QUESTION EN BAS DE PAGE A QUITTÉ LES DEUX SECTIONS.
4017             Elle avait déjà quitté l'École, où elle était la panne : on
4018             descendait quatre étages pour arriver à une leçon, et sous la
4019             leçon il y avait une barre de saisie — le chat, en plus long.
4020             Aux tutoriels elle avait le même défaut, en moins visible : il
4021             fallait faire défiler toute la page pour la trouver, et ce qu'on y
4022             tapait ne savait rien de la page. Signalé : « la barre de
4023             questions tout en bas là, il faut supprimer, et puis même tous les
4024             autres blocs que tu as dessinés en bas ».
4025             Le panneau la remplace partout : il s'ouvre par-dessus, il sait
4026             d'où l'on parle, et il ne se cherche pas. */''}
4027      </div>
4028    </div>
4029    ${_academieBoutonDemander()}
4030    ${_academiePanneauChat()}
4031  </main>`
4032}
4033
4034const SANTE_PILLS = [{ label: 'Symptômes', icon: _icon('health') }, { label: 'Médicaments', icon: _icon('science') }, { label: 'Nutrition', icon: _icon('heart') }, { label: 'Santé mentale', icon: _icon('humanities') }, { label: 'Urgences', icon: _icon('law') }]
4035const SANTE_TOPICS = [
4036  { c1: '#059669', c2: '#0d9488', q: 'nutrition et alimentation équilibrée conseils santé', label: 'Nutrition & bien-être', img: 'macro photo of fresh colorful vegetables and fruits, healthy food photography, natural light', seed: 31 },
4037  { c1: '#7c3aed', c2: '#4338ca', q: 'santé mentale gestion du stress bien-être psychologique', label: 'Santé mentale', img: 'calm serene photo of meditation stones and soft natural light, wellness photography, peaceful mood', seed: 32 },
4038  { c1: '#dc2626', c2: '#b91c1c', q: 'prévention dépistage examens médicaux', label: 'Prévention & dépistage', img: 'macro photo of a stethoscope on a clean white medical desk, healthcare photography, soft lighting', seed: 33 },
4039]
4040// Une variation de poids qui monte n'est ni bonne ni mauvaise : ce module ne
4041// porte AUCUN jugement médical. La flèche dit le sens, la couleur reste neutre.
4042function _santeCarte(cle, t) {
4043  const v = t.dernier?.valeurs || {}
4044  const lisible = Object.values(v).map(x =>
4044 (Math.round(x * 10) / 10).toLocaleString('fr-FR')).join(' / ')
4045  const sens = t.variation == null ? '' : t.variation > 0.5 ? '↑' : t.variation < -0.5 ? '↓' : '→'
4046  return `<div class="rounded-xl p-3" style="background:var(--bg-secondary);border:1px solid var(--border)">
4047    <p class="text-[max(0.6875rem,11px)] mb-1" style="color:var(--t-faint)">${escHtml(t.libelle)}</p>
4048    <p class="text-[max(1.25rem,20px)] font-bold leading-none" style="color:var(--t-primary)">${escHtml(lisible)}<span class="text-[max(0.6875rem,11px)] font-medium ml-1" style="color:var(--t-faint)">${escHtml(t.unite)}</span></p>
4049    <p class="text-[max(0.6875rem,11px)] mt-1.5" style="color:var(--t-faint)">${
4050      t.variation == null
4051        // « Pas encore de comparaison » n'est pas « stable » : le dire évite de
4052        // faire croire à une mesure qui ne bouge pas.
4053        ? `${t.nb} mesure${t.nb > 1 ? 's' : ''} · pas encore de comparaison`
4054        : `${sens} ${Math.abs(t.variation).toFixed(1).replace('.', ',')} % sur 7 jours`
4055    }</p>
4056  </div>`
4057}
4058function _santeSaisie(cle, def) {
4059  const champs = def.champs.map(c => `<input id="sante-${cle}-${c.cle}" type="number" step="any" inputmode="decimal"
4060      placeholder="${escHtml(def.champs.length > 1 ? c.cle : def.unite)}"
4061      class="w-full rounded-lg px-2.5 py-1.5 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)" />`).join('')
4062  return `<div class="flex items-center gap-2">
4063    <span class="text-[max(0.75rem,12px)] shrink-0" style="color:var(--t-secondary);width:86px">${escHtml(def.libelle)}</span>
4064    <div class="flex gap-1.5 flex-1 min-w-0">${champs}</div>
4065    <button onclick="window.santeAjouter('${escHtml(cle)}')" class="shrink-0 rounded-lg px-2.5 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Ajouter</button>
4066  </div>`
4067}
4068function renderSanteSpace() {
4069  const d = (STATE.spaceSearch || {}).espaceSante || {}
4070  const s = (STATE.spaceData || {}).espaceSante || {}
4071  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
4072    ${_spaceMenuBtn()}
4073    <div class="flex-1 overflow-y-auto min-h-0">
4074      <div class="max-w-3xl mx-auto px-4 pt-12 pb-6">
4075        ${_spaceTitre('Santé', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })}
4076        <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceSante', sendId: 'btn-space-send-espaceSante', micId: 'btn-space-mic-espaceSante', placeholder: "Posez n'importe quelle question de santé", rows: 1 })}</div>
4077        ${_spaceChatArea('espaceSante', SPACE_SUGGESTIONS.espaceSante, true)}
4078        <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${SANTE_PILLS.map(p => `<button data-space-pill-espaceSante="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div>
4079        <div class="rounded-xl p-3.5 mb-5 flex items-start gap-2" style="background:var(--accent-bg);border:1px solid var(--border)">
4080          ${_icon('health', 'w-4 h-4 shrink-0 mt-0.5')}
4081          <p class="text-[max(0.71875rem,11.5px)] leading-relaxed" style="color:var(--t-secondary)">Informations générales, pas un avis médical — en cas d'urgence ou de doute, consultez un professionnel de santé.</p>
4082        </div>
4083        ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Recherche…</p>` :
4084          d.results ? _searchResultsList(d.results, 'Aucun résultat.') : `
4085          <!-- MON RELEVÉ — ce qui distingue un espace d'une coquille. Tenu à la
4086               main, rangé dans le dossier du compte, transmis à personne. -->
4087          <p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Mon relevé</p>
4088          ${Object.keys(s.tendances || {}).length ? `<div class="grid gap-2.5 mb-4" style="grid-template-columns:repeat(auto-fill,minmax(148px,1fr))">
4089            ${Object.entries(s.tendances).map(([k, t]) => _santeCarte(k, t)).join('')}
4090          </div>` : `<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-faint)">Aucune mesure pour l'instant. Ajoutez-en une ci-dessous et les tendances apparaîtront.</p>`}
4091          <div class="rounded-xl p-3.5 mb-2 flex flex-col gap-2" style="background:var(--bg-secondary);border:1px solid var(--border)">
4092            ${Object.entries(s.mesures || {}).map(([k, def]) => _santeSaisie(k, def)).join('')}
4093          </div>
4094          ${s.saisieErreur ? `<p class="text-[max(0.75rem,12px)] mb-4 rounded-lg px-3 py-2" style="background:rgba(239,68,68,.08);color:#ef4444;border:1px solid rgba(239,68,68,.2)">${escHtml(s.saisieErreur)}</p>` : ''}
4095          ${s.journalError ? `<p class="text-[max(0.75rem,12px)] mb-4" style="color:var(--t-faint)">Relevé indisponible — ${escHtml(s.journalError)}</p>` : ''}
4096          ${(s.journal || []).length ? `<details class="mb-5"><summary class="text-[max(0.78125rem,12.5px)] cursor-pointer" style="color:var(--t-dim)">Historique (${s.journal.length})</summary>
4097            <div class="flex flex-col gap-1 mt-2">${s.journal.slice(0, 60).map(e => `<div class="flex items-center justify-between text-[max(0.75rem,12px)] rounded-lg px-2.5 py-1.5" style="background:var(--bg-secondary);color:var(--t-secondary)">
4098              <span>${escHtml((s.mesures?.[e.type]?.libelle) || e.type)} · ${escHtml(Object.values(e.valeurs || {}).join(' / '))} ${escHtml(s.mesures?.[e.type]?.unite || '')}</span>
4099              <span class="flex items-center gap-2"><span style="color:var(--t-faint)">${escHtml(new Date(e.at).toLocaleDateString('fr-FR'))}</span>
4100              <button onclick="window.santeSupprimer(${jsAttr(e.id)})" style="background:none;border:none;color:var(--t-faint);cursor:pointer">✕</button></span>
4101            </div>`).join('')}</div></details>` : ''}
4102
4103          <!-- MÉDICAMENTS — la base publique de l'ANSM, pas la mémoire du modèle. -->
4104          <p class="text-[max(0.875rem,14px)] font-bold mb-2" style="color:var(--t-primary)">Médicaments</p>
4105          <div class="flex gap-2 mb-2">
4106            <input id="sante-medoc-q" type="search" placeholder="Nom d'un médicament ou d'une substance" onkeydown="if(event.key==='Enter')window.santeChercherMedicament()"
4107              class="flex-1 rounded-lg px-3 py-2 text-[max(0.8125rem,13px)]" style="background:var(--bg-input);border:1px solid var(--border);color:var(--t-primary)" />
4108            <button onclick="window.santeChercherMedicament()" class="rounded-lg px-3 py-2 text-[max(0.78125rem,12.5px)] font-semibold" style="background:var(--accent);color:#fff;border:none">Chercher</button>
4109          </div>
4110          ${s.medocChargement ? '<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-dim)">Recherche dans la base publique…</p>' : ''}
4111          ${s.medocRaison ? `<p class="text-[max(0.78125rem,12.5px)] mb-4" style="color:var(--t-faint)">${escHtml(s.medocRaison)}</p>` : ''}
4112          ${(s.medocs || []).length ? `<p class="text-[max(0.71875rem,11.5px)] mb-2" style="color:var(--t-faint)">${s.medocTotal} résultat${s.medocTotal > 1 ? 's' : ''} — source : base de données publique des médicaments (ANSM), Licence Ouverte</p>
4113          <div class="flex flex-col gap-1.5 mb-5">${s.medocs.map(m =>
4113 `<div class="rounded-xl px-3 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
4114            <p class="text-[max(0.8125rem,13px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(m.nom)}</p>
4115            <p class="text-[max(0.71875rem,11.5px)] mt-0.5" style="color:var(--t-secondary)">${escHtml(m.substances.map(x => x.nom + (x.dosage ? ' ' + x.dosage : '')).join(' · ') || '—')}</p>
4116            <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(m.forme)}${m.voies ? ' · ' + escHtml(m.voies) : ''}${m.titulaire ? ' · ' + escHtml(m.titulaire) : ''}</p>
4117            ${!m.commercialise ? '<p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">⚠ Non commercialisé actuellement</p>' : ''}
4118            ${m.surveillanceRenforcee ? '<p class="text-[max(0.6875rem,11px)] mt-1" style="color:#d97706">▲ Sous surveillance renforcée</p>' : ''}
4119          </div>`).join('')}</div>` : ''}
4120
4121          <!-- Dit une fois, clairement, plutôt que promis puis jamais livré. -->
4122          <p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-5 rounded-xl p-3" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-faint)">
4123            Ce relevé est tenu à la main et reste dans votre compte. <strong>Aucune connexion à un compte Ameli n'est possible</strong> : l'Assurance Maladie n'ouvre pas d'accès aux applications tierces, et Créa ne vous demandera jamais ces identifiants. Pour vos remboursements, téléchargez le relevé depuis votre espace et déposez-le dans une conversation — Créa sait le lire.
4124          </p>
4125
4126          ${(s.news || []).length ? `<p class="text-[max(0.875rem,14px)] font-bold mb-3" style="color:var(--t-primary)">Actualité santé</p>
4127          <div class="flex flex-col gap-2 mb-5">${s.news.slice(0, 6).map(a => `<button onclick="window.CREA_INTERNE && window.CREA_INTERNE.lireArticle({url:${jsAttr(a.url)},titre:${jsAttr(a.title || '')},image:${jsAttr(a.image || '')},source:${jsAttr(a.source || '')}})" class="text-left rounded-xl px-3 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
4128            <p class="text-[max(0.8125rem,13px)] font-semibold leading-snug" style="color:var(--t-primary)">${escHtml(a.title || '')}</p>
4129            <p class="text-[max(0.6875rem,11px)] mt-1" style="color:var(--t-faint)">${escHtml(a.source || '')}</p>
4130          </button>`).join('')}</div>` : ''}
4131
4132          <div class="flex items-center justify-between mb-4">
4133            <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Sujets tendance</p>
4134            <button data-space-scroll="sante-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button>
4135          </div>
4136          <div id="sante-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${SANTE_TOPICS.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceSante="${escHtml(t.q)}"`, t.seed)).join('')}</div>
4137        `}
4138      </div>
4139    </div>
4140  </main>`
4141}
4142
4143const BREVETS_PILLS = [{ label: 'Patentability', icon: _icon('search') }, { label: 'Technology Landscape', icon: _icon('network') }, { label: 'R&D', icon: _icon('edit') }]
4144const BREVETS_CATEGORIES = [
4145  { c1: '#1e3a8a', c2: '#1e40af', q: 'brevets informatique et logiciels récents', label: 'Computers & Software', img: 'macro photo of a blue printed circuit board microchip, dramatic lighting, tech product photography', seed: 21 },
4146  { c1: '#0891b2', c2: '#0e7490', q: 'brevets technologies médicales et santé', label: 'Medical & Healthcare Technology', img: 'macro photo of clear glass medical capsule pills, dark blue background, pharmaceutical product photography', seed: 22 },
4147  { c1: '#475569', c2: '#334155', q: 'brevets machines industrielles', label: 'Industrial Machinery', img: 'macro photo of a shiny metallic industrial 
4147coil spring, dark background, dramatic studio lighting', seed: 23 },
4148]
4149function renderBrevetsSpace() {
4150  const d = (STATE.spaceSearch || {}).espaceBrevets || {}
4151  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
4152    ${_spaceMenuBtn()}
4153    <div class="flex-1 overflow-y-auto min-h-0">
4154      <div class="max-w-3xl mx-auto px-4 pt-12 pb-6">
4155        ${_spaceTitre('Brevets', 'text-[max(2.375rem,38px)] font-bold', 'color:var(--t-primary)', { centre: true, marge: 'mb-8', chevron: 'w-6 h-6' })}
4156        <div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceBrevets', sendId: 'btn-space-send-espaceBrevets', micId: 'btn-space-mic-espaceBrevets', placeholder: "Posez n'importe quelle question sur les brevets", rows: 1 })}</div>
4157        ${_spaceChatArea('espaceBrevets', SPACE_SUGGESTIONS.espaceBrevets, true)}
4158        <div class="flex gap-2.5 overflow-x-auto pb-5" style="scrollbar-width:none">${BREVETS_PILLS.map(p => `<button data-space-pill-espaceBrevets="${escHtml(p.label)}" class="shrink-0 inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[max(0.875rem,14px)] font-medium" style="background:var(--bg-secondary);border:1px solid var(--border);color:var(--t-secondary)">${p.icon}${p.label}</button>`).join('')}</div>
4159        <details class="rounded-xl mb-5" style="background:var(--bg-secondary);border:1px solid var(--border)">
4160          <summary class="flex items-center justify-between px-4 py-3 cursor-pointer select-none list-none text-[max(0.875rem,14px)] font-medium" style="color:var(--t-primary)">Nouveau dans la recherche de brevets ? Apprenez à utiliser cet outil ${_icon('chevronDown', 'w-5 h-5 shrink-0')}</summary>
4161          <p class="px-4 pb-3.5 text-[max(0.78125rem,12.5px)] leading-relaxed" style="color:var(--t-dim)">Posez une question en langage naturel, filtrez par Patentability / Technology Landscape / R&amp;D, ou partez d'une catégorie ci-dessous — CREA cherche sur le web et résume les résultats pertinents.</p>
4162        </details>
4163        ${d.loading ? `<p class="text-center py-8 text-[max(0.875rem,14px)]" style="color:var(--t-dim)">Recherche…</p>` :
4164          d.results ? _searchResultsList(d.results, 'Aucun résultat.') : `
4165          <div class="flex items-center justify-between mb-4">
4166            <p class="text-[max(0.875rem,14px)] font-bold" style="color:var(--t-primary)">Explorer par catégorie</p>
4167            <button data-space-scroll="brevets-topics" style="color:var(--t-faint)">${_icon('chevronRight', 'w-5 h-5')}</button>
4168          </div>
4169          <div id="brevets-topics" class="flex gap-4 overflow-x-auto pb-2" style="scrollbar-width:none">${BREVETS_CATEGORIES.map(t => _spaceImageCard(t.img, t.label, t.c1, t.c2, `data-space-topic-espaceBrevets="${escHtml(t.q)}"`, t.seed)).join('')}</div>
4170        `}
4171      </div>
4172    </div>
4173  </main>`
4174}
4175
4176
4177/* ══════════════════════════════════════════════════════════════════════════
4178 * L'ESPACE COMPÉTENCES — chercher, absorber, faire jouer.
4179 * ══════════════════════════════════════════════════════════════════════════
4180 *
4181 * « Ajoute un espace skill dans Créa, connecte ce skill à Créa, et absorbe et
4182 *   rends possible tout ce skill via cet espace et autre. »
4183 *
4184 * Les neuf autres espaces regardent un DOMAINE — l'actualité, les marchés, la
4185 * santé. Celui-ci regarde ce que Créa SAIT FAIRE, et il est le seul dont le
4186 * contenu modifie l'organisme : une compétence absorbée ici entre dans le
4187 * prompt système de TOUTE l'application (`buildSystemPrompt`), pas seulement
4188 * dans le fil de cet écran. C'est la moitié « et autre » de la demande, et
4189 * c'est celle qu'un catalogue rate toujours.
4190 *
4191 * TROIS ÉTATS, ET ILS NE SE RESSEMBLENT PAS :
4192 *   · TROUVÉE   elle existe sur le marché ; Créa n'en a que la fiche.
4193 *   · ABSORBÉE  son texte est allé chercher dans son dépôt et rangé ici.
4194 *   · ACTIVE    ce texte entre dans le prompt — c'est là, et l
4194à seulement,
4195 *               qu'elle agit.
4196 * Confondre les trois donnerait le défaut classique du magasin d'extensions :
4197 * un bouton « installer » qui ne fait qu'allumer une pastille verte.
4198 * ══════════════════════════════════════════════════════════════════════════ */
4199
4200/* CE QUI RESTAIT DE L'ESPACE COMPÉTENCES.
4201 *
4202 * « Je ne t'ai même pas demandé de créer un espace, mais tu es parti en créer
4203 *   un espace compétence. »
4204 *
4205 * L'espace est parti ; ses gestes étaient restés. Huit fonctions posées sur
4206 * `window`, un état à eux, et la lecture d'un champ de recherche qu'aucun
4207 * rendu ne produit plus. Rien ne les appelait : ni le
4208 * Répertoire, ni une page, ni un onclick. Le scanner de branchements le
4209 * disait depuis le premier passage.
4210 *
4211 * C'est le pire genre de reste : du code qui a l'air vivant. On le lit, on
4212 * croit tenir la recherche de compétences, et on modifie une fonction que
4213 * personne n'exécute. Le vrai écran est `_renderCompetences` dans app.js.
4214 *
4215 * `_skillsPourPrompt` est parti avec `_common` : les compétences entrent dans
4216 * le prompt des espaces par la gorge (`CREA_SKILLS.texteDuFocus`), comme dans
4217 * le chat et les studios — un seul fil, pas un par surface.
4218 */
4219
4220// Relit les comptes quand la personne REVIENT dans Créa, une seule fois.
4221// Sans ce guet, il faudrait recharger la page à la main pour voir ses comptes —
4222// et personne ne devine qu'il faut le faire.
4223function _cfoGuetterRetour() {
4224  if (window._cfoGuetActif) return
4225  window._cfoGuetActif = true
4226  const auRetour = () => {
4227    if (document.visibilityState === 'hidden') return
4228    window.removeEventListener('focus', auRetour)
4229    document.removeEventListener('visibilitychange', auRetour)
4230    window._cfoGuetActif = false
4231    // Bridge a besoin d'un instant pour publier les comptes après la
4232    // synchronisation : relire à la milliseconde où l'onglet reprend le focus
4233    // ramènerait souvent une liste encore vide.
4234    setTimeout(() => window.cfoRafraichirBanque(), 1200)
4235  }
4236  window.addEventListener('focus', auRetour)
4237  document.addEventListener('visibilitychange', auRetour)
4238}
4239
4240// ═══════════════════════════════════════════════════════════════════════════
4241// TROIS BRANCHES DE PLUS — PROJETS, RELATIONS, JURIDIQUE
4242// ═══════════════════════════════════════════════════════════════════════════
4243//
4244// « Quelle autre branche aussi on peut rajouter dans cette liste ? »
4245//
4246// Les six branches existantes regardent toutes le MONDE : l'actualité, les
4247// marchés, la santé publique, l'éducation, les brevets. Aucune ne regardait le
4248// travail de celui qui les ouvre. C'est le trou qu'on comble ici.
4249//
4250// ── LA RÈGLE QU'ON S'EST DONNÉE : PAS DE COQUILLE ───────────────────────
4251//
4252// Une branche vide qui promet un domaine est pire qu'une branche absente : on
4253// clique, on ne trouve rien, et on cesse de cliquer sur les autres. Chacune des
4254// trois s'appuie donc sur quelque chose qui existe déjà :
4255//
4256//   PROJETS    window.CREA_PROJETS.PROJETS — mission, statut, objectifs,
4257//              tensions et liens, écrits et déjà utilisés pour nourrir les
4258//              réponses de CREA. Ils n'étaient VISIBLES nulle part : elle en
4259//              parlait, on ne pouvait pas les regarder.
4260//   RELATIONS  window.CREA_PERSONNES.load() — même situation exactement.
4261//   JURIDIQUE  aucun module, et on ne fait pas semblant. Elle s'appuie sur le
4262//              moteur du Fil avec la rubrique « Droit & Réglementation », et
4263//              sur l'état de conformité RÉEL du compte. C'est peu, mais c'est
4264//              vrai, et ça se dit.
4265
4266// Petit cadre commun : les trois branches se ressemblent, et devaient se
4267// ressembler — sinon on aurait trois écrans qui n'ont en commun que d'être
4268// nouveaux.
4269function _brancheCadre(titre, sousTitre, corps) {
4270  return `<main class="flex flex-1 min-h-0 min-w-0 flex-col studio-shell">
4271    ${_spaceMenuBtn()}
4272    <div class="flex-1 overflow-y-auto min-h-0">
4273      <div class="w-full px-4 md:px-8 pt-3 pb-10 mx-auto" style="max-width:1100px">
4274        <div class="mb-1">${_spaceTitre(titre, 'text-[max(1.1875rem,19px)] font-bold', 'color:var(--t-primary)')}</div>
4275        <p class="text-[max(0.75rem,12px)] mb-5" style="color:var(--t-dim)">${escHtml(sousTitre)}</p>
4276        ${corps}
4277      </div>
4278    </div>
4279  </main>`
4280}
4281
4282// Un état vide qui DIT pourquoi il est vide. « Aucun projet » et « le module
4283// n'a pas chargé » ne se ressemblent pas, et les confondre ferait chercher au
4284// mauvais endroit — c'est la règle absent ≠ panne, appliquée à un écran.
4285function _brancheVide(message) {
4286  return `<p class="text-center py-14 text-[max(0.78125rem,12.5px)]" style="color:var(--t-dim)">${escHtml(message)}</p>`
4287}
4288
4289function renderProjetsSpace() {
4290  const M = (window.CREA_PROJETS && window.CREA_PROJETS.PROJETS) || null
4291  const objectifs = ((STATE.spaceData || {}).espaceProjets || {}).goals || []
4292  const chargement = ((STATE.spaceData || {}).espaceProjets || {}).loading
4293  const dossiers = STATE.folders || []
4294
4295  // ── VOS DOSSIERS SONT VOS PROJETS RÉELS ────────────────────────────────
4296  //
4297  // « Pour Projet, il y a beaucoup de choses qui les relient : il y a les
4298  //   dossiers, il y a la bibliothèque, il y a les sessions. »
4299  //
4300  // La première version de cet écran ne montrait que la carte figée de
4301  // `CREA_PROJETS` — mission, tensions, liens. C'est le CADRE, et il était
4302  // juste. Mais le travail, lui, vit dans `STATE.folders` : les
4303  // conversations, les médias, les tâches, le journal. Deux notions de
4304  // « projet » coexistaient dans l'application, et cette branche n'en voyait
4305  // qu'une — celle qui ne bouge jamais.
4306  //
4307  // On ne recopie pas la vue de dossier : elle existe (`openProject`), elle est
4308  // riche, et la redessiner ici la ferait diverger. On donne la porte.
4309  const carteDossier = (f) => {
4310    const convs = (STATE.conversations || []).filter(c => (f.convIds || []).includes(c.id))
4311    const derniere = convs.map(c => c.updatedAt || c.createdAt || 0).sort((a, b) => b - a)[0]
4312    const taches = (f.tasks || [])
4313    const faites = taches.filter(t => t.done).length
4314    return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
4315      <div class="flex items-start gap-2 mb-1.5">
4316        ${f.icon ? `<span class="text-[max(0.9375rem,15px)] shrink-0">${escHtml(f.icon)}</span>` : ''}
4317        <p class="text-[max(0.84375rem,13.5px)] font-bold flex-1 min-w-0 truncate" style="color:var(--t-primary)">${escHtml(f.name || 'Dossier')}</p>
4318      </div>
4319      <div class="flex flex-wrap gap-1.5 mb-3">
4320        <span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${convs.length} session${convs.length > 1 ? 's' : ''}</span>
4321        ${taches.length ? `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${faites}/${taches.length} tâches</span>` : ''}
4322        ${derniere ? `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(new Date(derniere).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }))}</span>` : ''}
4323      </div>
4324      <div class="flex flex-wrap gap-1.5">
4325        <button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;
4325openProject('${escHtml(f.id)}')" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ouvrir le dossier</button>
4326        <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: f.name || '', depuis: 'espaceProjets', libelle: 'Projets' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit</button>
4327      </div>
4328    </div>`
4329  }
4330
4331  const carteProjet = (nom) => {
4332    const p = M[nom] || {}
4333    const tensions = (p.tensions || []).filter(Boolean)
4334    // LE LIEN ENTRE LE CADRE ET LE TRAVAIL. Quels dossiers parlent de ce
4335    // projet ? La détection existait déjà (`CREA_PROJETS.detecter`) et ne
4336    // servait qu'à nourrir les réponses : elle n'avait jamais rien MONTRÉ.
4337    const lies = dossiers.filter(f => {
4338      try {
4339        const texte = (f.name || '') + ' ' + (f.keywords || []).join(' ') + ' ' + (f.description || '')
4340        return (window.CREA_PROJETS.detecter(texte) || []).some(x => (x.nom || x) === nom)
4341      } catch (e) { return false }
4342    })
4343    return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
4344      <div class="flex items-start justify-between gap-3 mb-1.5">
4345        <p class="text-[max(0.84375rem,13.5px)] font-bold" style="color:var(--t-primary)">${escHtml(nom)}</p>
4346        ${p.statut ? `<span class="shrink-0 rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-secondary)">${escHtml(p.statut)}</span>` : ''}
4347      </div>
4348      ${p.mission ? `<p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-2.5" style="color:var(--t-dim)">${escHtml(p.mission)}</p>` : ''}
4349      ${(p.objectifs || []).length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Objectifs</p>
4350        <ul class="mb-2.5 space-y-1">${p.objectifs.map(o => `<li class="text-[max(0.71875rem,11.5px)] pl-3" style="color:var(--t-secondary);text-indent:-9px">— ${escHtml(o)}</li>`).join('')}</ul>` : ''}
4351      ${tensions.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:#b45309">Tensions</p>
4352        <ul class="mb-2.5 space-y-1">${tensions.map(t => `<li class="text-[max(0.71875rem,11.5px)] pl-3" style="color:var(--t-secondary);text-indent:-9px">— ${escHtml(t)}</li>`).join('')}</ul>` : ''}
4353      ${lies.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Vos dossiers sur ce projet</p>
4354        <div class="flex flex-wrap gap-1.5 mb-2.5">${lies.map(f => `<button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;openProject('${escHtml(f.id)}')" class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--accent-bg);color:var(--accent)">${escHtml(f.icon || '')} ${escHtml(f.name)}</button>`).join('')}</div>` : ''}
4355      ${(p.liens || []).length ? `<div class="flex flex-wrap gap-1.5 mb-2.5">${p.liens.map(l => `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(l)}</span>`).join('')}</div>` : ''}
4356      <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: nom, depuis: 'espaceProjets', libelle: 'Projets' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit aujourd'hui</button>
4357    </div>`
4358  }
4359
4360  const corps = `
4361    <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Vos dossiers</p>
4362    ${dossiers.length
4363      ? `<div class="grid gap-3 md:grid-cols-3 mb-6">${dossiers.slice(0, 24).map(carteDossier).join('')}</div>`
4364      : `<p class="text-[max(0.75rem,12px)] mb-6" style="color:var(--t-dim)">Aucun dossier pour l'instant — un dossier rassemble des sessions, des médias et des tâches autour d'un même sujet.</p>`}
4365
4366    <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Les projets de l'écosystème</p>
4367    ${!M
4368      ? _brancheVide("Le module des projets n'a pas chargé — ce n'est pas qu'il n'y a aucun projet.")
4369      : `<div class="grid gap-3 md:grid-cols-2 mb-6">${Object.keys(M).map(carteProjet).join('')}</div>`}
4370
4371    <!-- LES OBJECTIFS CHIFFRÉS VIENNENT DU SERVEUR (/api/goals), pas d'ici :
4372         ils changent, les projets non. Les mélanger dans une même source
4373         obligerait à redéployer pour cocher une case. -->
4374    <div class="flex items-center justify-between gap-3 mb-2">
4375      <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Objectifs suivis</p>
4376      <button id="btn-projets-rafraichir" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)]" style="border:1px solid var(--border);color:var(--t-secondary)">Rafraîchir</button>
4377    </div>
4378    ${chargement && !objectifs.length
4379      ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Chargement des objectifs…</p>`
4380      : objectifs.length
4381        ? `<div class="space-y-2">${objectifs.slice(0, 40).map(g => {
4382            const pct = Math.max(0, Math.min(100, Number(g.progress) || 0))
4383            return `<div class="rounded-xl px-3.5 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
4384              <div class="flex items-center justify-between gap-3 mb-1.5">
4385                <p class="text-[max(0.78125rem,12.5px)] font-medium truncate" style="color:var(--t-primary)">${escHtml(g.title || 'Sans titre')}</p>
4386                <span class="text-[max(0.6875rem,11px)] tabular-nums shrink-0" style="color:var(--t-faint)">${pct} %${g.deadline ? ' · ' + escHtml(String(g.deadline).slice(0, 10)) : ''}</span>
4387              </div>
4388              <div style="height:5px;border-radius:99px;background:var(--bg-input);overflow:hidden">
4389                <div style="height:100%;width:${pct}%;background:${g.done ? '#16a34a' : 'var(--accent)'}"></div>
4390              </div>
4391            </div>`
4392          }).join('')}</div>`
4393        : _brancheVide('Aucun objectif suivi pour le moment.')}`
4394
4395  /* ── UN DOMAINE SANS ENDROIT OÙ ÉCRIRE N'EST PAS UN DOMAINE ────────────
4396   *
4397   * MESURÉ : Projets déclarait un chat en QUATRE endroits — trois suggestions
4398   * dans SPACE_SUGGESTIONS, un lieu dans STUDIOS, un profil d'outils dans
4399   * PROFILS_DE_SURFACE, un appel à bindSpaceChat au démarrage — et n'avait
4400   * aucun champ où écrire. `bindSpaceChat` sortait à sa PREMIÈRE ligne, faute
4401   * de bouton d'envoi. C'est le défaut exact que ce fichier raconte pour
4402   * Découvrir en tête de page, et il était resté ici.
4403   *
4404   * EN HAUT, PAS EN BAS. Les trois espaces qui parlent (Éducation, Santé,
4405   * Brevets) posent leur barre au-dessus du contenu et leur fil juste
4406   * dessous : `enLigne` met la conversation dans le flux de la page. La barre
4407   * épinglée en bas a été retirée de trois autres espaces parce qu'elle
4408   * mangeait 36 à 41 % d'un écran de portable — on ne la ramène pas ici. */
4409  const parole = `<div class="mb-4">${renderUniversalInputBar({ inputId: 'space-input-espaceProjets', sendId: 'btn-space-send-espaceProjets', micId: 'btn-space-mic-espaceProjets', placeholder: 'Où en sont vos projets, ce qui bloque, ce qui vient', rows: 1 })}</div>
4410    ${_spaceChatArea('espaceProjets', SPACE_SUGGESTIONS.espaceProjets, true)}`
4411
4412  return _brancheCadre('Projets', 'Vos dossiers, les projets qui les cadrent, et ce qu\'on en dit dehors.', parole + corps)
4413}
4414
4415function renderRelationsSpace() {
4416  const gens = (window.CREA_PERSONNES && window.CREA_PERSONNES.load) ? window.CREA_PERSONNES.load() : null
4417  const dossiers = STATE.folders || []
4418
4419  // ── UNE RELATION N'EXISTE PAS TOUTE SEULE ──────────────────────────────
4420  //
4421  // « Relation, c'est lié à Coworker, c'est lié à Collab aussi, et c'est lié à
4422  //   Communauté. »
4423  //
4424  // La première version listait des fiches et s'arrêtait là : un annuaire.
4425  // Or une personne n'est pas une fiche, c'est un point de passage — elle
4426  // apparaît dans des dossiers, on monte quelque chose avec elle dans Collab, on la
4427  // croise dans Communauté, et Coworker est l'endroit d'où l'on agit. Sans ces
4428  // portes, il faut ressortir au menu et se rappeler soi-même le lien : le
4429  // travail que la machine aurait dû faire.
4430  const surfaces = [
4431    { wm: 'coworker', mode: true, libelle: 'Coworker', aide: 'Agir avec elle : tâches, suivi' },
4432    { wm: 'collab', libelle: 'Collab', aide: 'Faire quelque chose ensemble : projet, appel, coup de main' },
4433    { wm: 'community', libelle: 'Communauté', aide: 'La croiser : profils, publications' },
4434  ]
4435
4436  let corps
4437  if (!gens) {
4438    corps = _brancheVide("Le module des relations n'a pas chargé — ce n'est pas qu'il n'y a personne.")
4439  } else if (!gens.length) {
4440    corps = _brancheVide('Personne d\'enregistré pour le moment.')
4441  } else {
4442    const carte = (p) => {
4443      const nom = p.nom || p.id || 'Sans nom'
4444      // Les dossiers où cette personne apparaît. `detecter` existait et ne
4445      // servait qu'à nourrir les réponses : elle n'avait jamais rien montré.
4446      const lies = dossiers.filter(f => {
4447        try {
4448          const texte = (f.name || '') + ' ' + (f.keywords || []).join(' ') + ' ' + (f.description || '')
4449          return (window.CREA_PERSONNES.detecter(texte) || []).some(x => (x.id || x) === p.id)
4450        } catch (e) { return false }
4451      })
4452      return `<div class="rounded-2xl p-4" style="background:var(--bg-secondary);border:1px solid var(--border)">
4453        <div class="flex items-start justify-between gap-3 mb-1">
4454          <p class="text-[max(0.84375rem,13.5px)] font-bold" style="color:var(--t-primary)">${escHtml(nom)}</p>
4455          ${p.statut ? `<span class="shrink-0 rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-secondary)">${escHtml(p.statut)}</span>` : ''}
4456        </div>
4457        ${p.role ? `<p class="text-[max(0.71875rem,11.5px)] font-medium mb-1.5" style="color:var(--accent)">${escHtml(p.role)}</p>` : ''}
4458        ${p.contexte ? `<p class="text-[max(0.71875rem,11.5px)] leading-relaxed mb-2.5" style="color:var(--t-dim)">${escHtml(p.contexte)}</p>` : ''}
4459        ${(p.projets || []).length ? `<div class="flex flex-wrap gap-1.5 mb-2.5">${p.projets.map(x => `<span class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--bg-input);color:var(--t-faint)">${escHtml(x)}</span>`).join('')}</div>` : ''}
4460        ${lies.length ? `<p class="text-[max(0.625rem,10px)] font-semibold uppercase tracking-wide mb-1" style="color:var(--t-faint)">Vos dossiers où elle apparaît</p>
4461          <div class="flex flex-wrap gap-1.5 mb-2.5">${lies.map(f => `<button onclick="STATE.activeProjectId='${escHtml(f.id)}';STATE.folderActiveId=null;
4461openProject('${escHtml(f.id)}')" class="rounded-full px-2 py-0.5 text-[max(0.65625rem,10.5px)]" style="background:var(--accent-bg);color:var(--accent)">${escHtml(f.icon || '')} ${escHtml(f.name)}</button>`).join('')}</div>` : ''}
4462        <button data-vers-le-fil='${escHtml(JSON.stringify({ recherche: nom, depuis: 'espaceRelations', libelle: 'Relations' }))}' class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)] font-medium" style="border:1px solid var(--border);color:var(--t-secondary)">Ce qu'on en dit</button>
4463      </div>`
4464    }
4465    corps = `
4466      <!-- LES TROIS SURFACES OÙ UNE RELATION SE VIT. Elles existent depuis
4467           longtemps et cette branche les ignorait : on listait des gens sans
4468           pouvoir rien faire avec eux. -->
4469      <div class="grid gap-2 md:grid-cols-3 mb-6">
4470        ${surfaces.map(s => `<button onclick="${s.mode ? `openModeTab('${s.wm}')` : `STATE.workMode='${s.wm}';save();render()`}" class="text-left rounded-2xl px-3.5 py-3" style="background:var(--bg-secondary);border:1px solid var(--border)">
4471          <p class="text-[max(0.78125rem,12.5px)] font-bold mb-0.5" style="color:var(--t-primary)">${escHtml(s.libelle)}</p>
4472          <p class="text-[max(0.6875rem,11px)]" style="color:var(--t-dim)">${escHtml(s.aide)}</p>
4473        </button>`).join('')}
4474      </div>
4475      <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Les personnes</p>
4476      <div class="grid gap-3 md:grid-cols-2">${gens.map(carte).join('')}</div>`
4477  }
4478  return _brancheCadre('Relations', 'Les personnes autour des projets — et les endroits où l\'on travaille avec elles.', corps)
4479}
4480
4481function renderJuridiqueSpace() {
4482  // ── ON NE FAIT PAS SEMBLANT ──────────────────────────────────────────
4483  //
4484  // Aucun module juridique n'existe dans ce dépôt. Cette branche ne prétend
4485  // donc pas conseiller : elle rassemble ce qui EST vrai — une veille
4486  // réglementaire tirée du moteur du Fil, et l'état de conformité réel du
4487  // compte, lu dans l'état, pas inventé.
4488  //
4489  // Et elle le DIT, en haut, à la première ligne. Une branche qui laisserait
4490  // croire à un avis juridique serait pire qu'absente.
4491  const d = (STATE.spaceData || {}).espaceJuridique || {}
4492  const articles = d.articles || []
4493  const conformite = [
4494    ['Conditions acceptées', !!STATE.privacyTerms],
4495    ['Consentement RGPD', !!STATE.rgpdConsent],
4496    ['Données d\'entraînement', STATE.dataTrainingConsent === true ? true : STATE.dataTrainingConsent === false ? false : null],
4497    ['Mode confidentiel', !!STATE.privacyMode],
4498  ]
4499  const pastille = (v) => v === null
4500    ? `<span class="rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:var(--bg-input);color:var(--t-faint)">non renseigné</span>`
4501    : `<span class="rounded-full px-2 py-0.5 text-[max(0.625rem,10px)] font-semibold" style="background:${v ? 'rgba(22,163,74,.14)' : 'var(--bg-input)'};color:${v ? '#16a34a' : 'var(--t-faint)'}">${v ? 'oui' : 'non'}</span>`
4502
4503  const corps = `
4504    <div class="rounded-2xl p-3.5 mb-5" style="background:var(--bg-input);border:1px solid var(--border)">
4505      <p class="text-[max(0.71875rem,11.5px)] leading-relaxed" style="color:var(--t-dim)">Cette branche n'est pas un conseil juridique et n'en tient pas lieu. Elle rassemble une veille réglementaire et l'état de votre compte — rien de plus.</p>
4506    </div>
4507    <p class="text-[max(0.8125rem,13px)] font-bold mb-2" style="color:var(--t-primary)">Votre conformité</p>
4508    <div class="grid gap-2 md:grid-cols-2 mb-6">
4509      ${conformite.map(([libelle, v]) => `<div class="flex items-center justify-between gap-3 rounded-xl px-3.5 py-2.5" style="background:var(--bg-secondary);border:1px solid var(--border)">
4510        <span class="text-[max(0.75rem,12px)]" style="color:var(--t-secondary)">${escHtml(libelle)}</span>${pastille(v)}
4511      </div>`).join('')}
4512    </div>
4513    <div class="flex items-center justify-between gap-3 mb-2">
4514      <p class="text-[max(0.8125rem,13px)] font-bold" style="color:var(--t-primary)">Veille réglementaire</p>
4515      <button id="btn-juridique-rafraichir" class="rounded-full px-3 py-1 text-[max(0.71875rem,11.5px)]" style="border:1px solid var(--border);color:var(--t-secondary)">Rafraîchir</button>
4516    </div>
4517    ${d.loading && !articles.length
4518      ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">Lecture des sources…</p>`
4519      : d.juridiqueError
4520        ? `<p class="text-[max(0.75rem,12px)]" style="color:var(--t-dim)">
4520Veille indisponible — ${escHtml(d.juridiqueError)}.</p>`
4521        : articles.length
4522          // ── LES MÊMES CARTES QUE LE FIL, PAS DES BLOCS À PART ─────────
4523          //
4524          // « Ça bloque sur bloc — normalement ça devrait être comme Le Fil. »
4525          //
4526          // Redessiner ici des vignettes d'actualité, c'est se donner sa
4527          // propre date, son propre repli d'image, sa propre marque de
4528          // lecture — et diverger au premier changement. `CREA_FIL.carte` est
4529          // LA carte : l'article lu ici est lu là-bas, l'article gardé ici est
4530          // gardé là-bas. Une actualité est une actualité, quelle que soit la
4531          // porte par laquelle on la regarde.
4532          ? `<div class="dec-grille">${articles.slice(0, 12).map(a => window.CREA_FIL ? window.CREA_FIL.carte(a, false) : '').join('')}</div>`
4533          : _brancheVide('Aucune actualité réglementaire pour le moment.')}
4534    <!-- ET ON PEUT ALLER PLUS LOIN, SANS PERDRE LE CHEMIN DU RETOUR. -->
4535    <div class="mt-4">
4536      <button data-vers-le-fil='${escHtml(JSON.stringify({ sujet: 'Droit & Réglementation', depuis: 'espaceJuridique', libelle: 'Juridique' }))}' class="rounded-full px-4 py-1.5 text-[max(0.75rem,12px)] font-semibold" style="background:var(--t-primary);color:var(--bg-primary)">Tout voir dans Le Fil</button>
4537    </div>`
4538  return _brancheCadre('Juridique', 'Veille réglementaire et état de conformité de votre compte.', corps)
4539}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.