/* SPDX-License-Identifier: AGPL-3.0-only
   SPDX-FileCopyrightText: 2026 Com'Together
   Com'Together — Ma communication. Copyright (C) 2026 Com'Together.
   Programme libre sous AGPL-3.0-only — voir LICENSE. */
/* ============================================================================
 * Com'Together CAA — moteur applicatif (100% local)
 * ==========================================================================*/
"use strict";

window.BUILD = "399";
// PROFILS locaux : le profil actif détermine les clés de stockage. Le profil « default »
// garde les clés HISTORIQUES (aucune migration : les données existantes = le profil par défaut).
let _activeProfile = "default";
function _profSuffix() { return _activeProfile === "default" ? "" : "__" + _activeProfile; }
const LS = {
  get settings() { return "ct_settings_v1" + _profSuffix(); },
  get overrides() { return "ct_overrides_v1" + _profSuffix(); },
  seen: "ct_seen_v1",
  profiled: "ct_profiled_v1",
  profiles: "ct_profiles_v1",
  undo: "ct_undo_v1",          // dernier instantané annulable (rien n'est jamais perdu)
};
// Registre des profils : { active, list:[{id,name,pin?}] }. pin = empreinte SHA-256 (verrou local doux).
function profilesReg() {
  try { const r = JSON.parse(localStorage.getItem(LS.profiles)); if (r && r.active && Array.isArray(r.list) && r.list.length) return r; } catch {}
  return { active: "default", list: [{ id: "default", name: "Mon profil" }] };
}
function saveProfilesReg(r) { localStorage.setItem(LS.profiles, JSON.stringify(r)); }
async function sha256Hex(str) {
  if (!(self.crypto && self.crypto.subtle)) return "plain:" + str;   // repli si pas de contexte sécurisé
  const buf = await self.crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return Array.from(new Uint8Array(buf)).map(x => x.toString(16).padStart(2, "0")).join("");
}
/* ---------- Chiffrement local des sauvegardes (AES-GCM, clé dérivée d'un mot de passe) ---------- */
// 100 % local : aucune donnée ni mot de passe n'est envoyé. Sans le mot de passe = irrécupérable (assumé).
function cryptoOk() { return !!(self.crypto && self.crypto.subtle); }
function _b64(buf) { const a = new Uint8Array(buf); let s = ""; for (let i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return btoa(s); }
function _unb64(str) { const bin = atob(str); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; }
async function _deriveKey(password, salt) {
  const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
  return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: 200000, hash: "SHA-256" }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}
async function encryptText(text, password) {
  const salt = crypto.getRandomValues(new Uint8Array(16)), iv = crypto.getRandomValues(new Uint8Array(12));
  const key = await _deriveKey(password, salt);
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text));
  return { kdf: "PBKDF2-SHA256-200000", cipher: "AES-GCM-256", salt: _b64(salt), iv: _b64(iv), ct: _b64(ct) };
}
async function decryptText(obj, password) {
  const key = await _deriveKey(password, _unb64(obj.salt));
  const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: _unb64(obj.iv) }, key, _unb64(obj.ct));
  return new TextDecoder().decode(pt);
}

const DEFAULT_SETTINGS = {
  appName: "",        // NOM que la personne donne à SON outil (« La voix de Léa »…) ; vide = « Ma communication »
  cols: 7,            // niveau de grille : 7 (49 cases mini), 9, 12
  pageSize: 49,       // cases de catégorie PAR PAGE (pagination anti-scroll) : 49 / 66 / 84 / 108
  coachDone: false,   // bulles d'aide (visite guidée) déjà vues ?
  tipsOff: false,     // aides « juste-à-temps » coupées (désactivable directement depuis une bulle)
  menuHidden: false,  // barre de menu du bas masquée ?
  popupPos: "bottom", // position des pop-up/fenêtres : center | left | right | top | bottom
  catMode: "colonne", // barre de catégories : colonne | bandeau | popup (moins envahissant)
  catCollapsed: false, // barre de catégories repliée EN PLACE (même disposition, revient au même endroit)
  columnOrder: [],     // ordre personnalisé des colonnes de l'accueil (par titre) — glisser-déposer / flèches
  settingsExpert: false, // Réglages : false = mode Simple (essentiel), true = tout afficher
  calmTint: 25,       // intensité des couleurs des cases (0 = très doux → 60 = vif)
  rate: 1,            // vitesse voix
  pitch: 1,
  voice: "",          // nom de voix système (override avancé)
  modelVoice: "",     // voix du PARTENAIRE en modélisation (vide = auto, genre opposé + grave)
  voiceProfile: "neutre", // enfant | fille | garcon | femme | homme | neutre
  genreLocuteur: "neutre", // accord quand JE parle de moi : 'feminin' | 'masculin' | 'neutre'
  engine: "navigateur", // 'navigateur' (Web Speech / voix système) | 'piper' (voix locale optionnelle)
  piperModel: "fr_FR-siwis-medium", // modèle Piper actif (remplaçable ; SIWIS n'est qu'un modèle parmi d'autres)
  voiceParams: {},    // réglages PAR VOIX : { [voiceId]: { rate, pitch, volume, systemVoiceName } }
  partnerVoice: { systemVoiceName: "", rate: 1, pitch: 1, volume: 1 }, // voix du PARTENAIRE (modélisation) — INDÉPENDANTE de la voix de la personne
  settingsSchemaVersion: 1, // version du schéma des réglages ; migration voix -> v2 (nettoyage des anciennes clés prévu à v3)
  voiceMigrated: false, // (hérité) marqueur de migration ; la source de vérité est désormais settingsSchemaVersion
  previewPhrase: "Bonjour, voici un aperçu de cette voix.", // phrase d'aperçu neutre, éditable, distincte du message
  mood: "neutre",     // ton/intonation de la voix
  moodAuto: true,     // adapter le ton automatiquement quand une émotion est posée
  fontScale: 1,
  theme: "light",
  contrast: "normal",
  speakOnTap: true,   // vocalise chaque mot touché
  readAlong: true,    // LITTÉRATIE : surligne chaque mot de la phrase pendant sa lecture (facultatif, réversible)
  decoder: true,      // LITTÉRATIE : appui long sur un mot de l'aperçu = décodeur graphémique (T2L), jamais automatique
  pictoAssist: true,  // montrer le picto à côté des mots tapés (clavier) et des phrases (scènes, cahier)
  scan: false,        // balayage
  scanSpeed: 1200,
  scanCycles: 3,      // tours de balayage des groupes avant pause anti-fatigue (0 = jamais)
  dwell: false,       // commande oculaire : sélection par fixation du regard (+ cibles élargies)
  hoverClick: false,  // PC : activation au survol de la souris sans clic (même moteur, cibles normales)
  dwellMs: 1200,      // durée avant activation (regard OU survol)
  eyegaze: false,     // agrandit les cibles + espacement (confort oculométrie)
  toutPetit: false,   // PROFIL TOUT-PETIT (couche d'accès sur la grille STABLE) : pulsation douce au
                      // toucher (contingence), focus renforcé, zone de grossissement fixe à la sélection.
                      // N'agrandit PAS les cases (ça les faisait déborder). JAMAIS moins de mots ;
                      // immersion/modélisation par le partenaire ; on ne teste jamais l'enfant.
  zoomOnSelect: true, // zone de grossissement FIXE : le mot sélectionné s'affiche en grand (picto + mot),
                      // sans déplacer la grille. Actif avec le profil tout-petit ; désactivable.
  zoomMs: 1000,       // durée d'affichage du grossissement (ms)
  litVowels: false,   // LITTÉRATIE (opt-in, à tester) : repère discret commun sur les voyelles du clavier
                      // principal. Jamais par défaut ni une couleur par lettre. Pas de preuve solide chez
                      // les tout-petits → option pédagogique, pas dogme.
  packs: null,        // packs pan-francophones activés (null = pas encore choisi -> socle par défaut)
  variants: {},       // variante régionale préférée par concept { conceptId: "mot choisi" }
  // --- accessibilité visuelle ---
  symbolScale: 1,     // taille des pictos
  showPicto: true,    // afficher les pictogrammes
  showText: true,     // afficher le texte
  colorCoding: true,  // code couleur Fitzgerald
  popup: false,       // catégories en pop-up (surcouche) au lieu de navigation
  categoryLayout: "vertical", // 'vertical' (colonne, défaut) | 'horizontal' (bandeau)
  catFolders: true,   // MENU en DOSSIERS (~19 regroupements à 2 niveaux) au lieu de 68 catégories à plat.
                      // Aucun mot retiré ; prototype réversible (off = ancienne liste, utile pour comparer/tester).
  catFav: [],         // catégories ÉPINGLÉES : restent à 1 CLIC en tête du menu (même en mode dossiers).
                      // Corrige le « clic en plus » des dossiers pour ce dont on a besoin vite (ex. consentement).
  spiral: "off",      // 'off' | 'supplement' (surface Fréquents en +) | 'main' (accueil en spirale)
  keyboardOnHome: false, // clavier ancré visible depuis l'accueil
  grammarBarMode: "auto", // 'auto' (apparaît quand utile) | 'always' | 'never' (jamais — place libérée)
  toneBarMode: "auto",    // 'auto' (défaut : apparaît avec une émotion ou via le bouton 🎭) | 'always' | 'never'
  stablePositions: true,  // Baker : automatisation motrice — rien ne bouge (fige la zone « En lien », verrouille la spirale)
  verbAdapt: false,       // RETIRÉ : la métamorphose des colonnes selon le verbe (faisait bouger des cases). Remplacée par les PORTES en tête de « En lien ». Réglage conservé off ; VERB_ASSOC dormant.
  registre: "ecrit",  // négation : 'ecrit' (je NE veux pas) | 'oral' (je veux pas) — le « ne » est très souvent omis à l'oral
  expGrammar: false,  // EXPÉRIMENTAL (opt-in) : moteur syntaxique jsRealB (Apache 2.0) chargé à la demande pour les
                      // constructions que le moteur linéaire ne fait pas (clitiques ordonnés « je le lui donne »).
                      // Repli systématique sur le moteur actuel. À TESTER, verrouillé par le corpus-or.
  pictoStyle: "vif",  // style des pictos custom : 'vif' (défaut, couleurs vives) | 'sepia' (vintage) — choix/personnalisation
  anchorMode: false,  // mode ANCRES (compaction sémantique FR, optionnel — jamais imposé) : ajoute un tableau d'icônes-ancres polysémiques
  anchorLearn: true,  // mode apprentissage du mode ancres : bannière d'aide + exemple concret « je + ancre = phrase »
  showModelBtn: true, // bouton « Montrer » (modélisation) dans la barre du haut — masquable (inutile à qui n'apprend pas l'outil)
  encourage: false,   // encouragements (« Bien joué 👏 ») — OFF par défaut (pas d'infantilisation), activable (utile à certains profils TDAH)
  kbLayout: "azerty", // disposition du clavier : 'azerty' | 'qwerty' | 'abc' (choisie par la personne, jamais imposée)
  kbBig: false,       // clavier à GRANDES touches (accès moteur/visuel)
  predCount: 8,       // nb de prédictions : 0 (aucune) | 3 | 5 | 8
  skinTone: "default", // teinte de peau des pictos-personnages (diversité, activable) — recolore #ffeec8
  hairTone: "default", // teinte des cheveux (recolore #9e5c26, seulement sur les personnages)
  lang2: "",          // 2e langue (bilinguisme) : "" = aucune ; sinon code BCP-47 (en-US, es-ES, ar…)
};

// Préréglages d'AFFICHAGE / d'ACCÈS (jamais un « niveau » de la personne).
// Densité et taille = choix visuel/moteur, à décider AVEC la personne ; modifiable à tout moment.
const PRESETS = {
  grandes:    { cols: 7, symbolScale: 1.35, fontScale: 1.1, showText: true, showPicto: true, colorCoding: true,
                spiral: "off", keyboardOnHome: false, categoryLayout: "vertical", scan: false, speakOnTap: true,
                grammarBarMode: "auto", toneBarMode: "auto" },
  dense:      { cols: 9, symbolScale: 1.0, fontScale: 1.0, showText: true, showPicto: true, colorCoding: true,
                spiral: "supplement", keyboardOnHome: true, categoryLayout: "vertical", scan: false,
                grammarBarMode: "auto", toneBarMode: "auto" },
  contacteur: { cols: 7, symbolScale: 1.2, fontScale: 1.05, showText: true, showPicto: true, colorCoding: true,
                spiral: "off", keyboardOnHome: false, categoryLayout: "vertical", scan: true, scanSpeed: 1500, speakOnTap: true,
                grammarBarMode: "auto", toneBarMode: "auto" },
};
function applyPreset(name) {
  Object.assign(state.settings, PRESETS[name] || {});
  saveSettings(); applyLook(); refresh(); toggleScan(); toggleDwell();
}
function maybeShowProfile() { if (!localStorage.getItem(LS.profiled)) { const p = document.getElementById("profile"); if (p) p.hidden = false; } }
function finishProfile() {
  const inp = document.getElementById("profile-name");
  if (inp && inp.value.trim()) { state.settings.appName = inp.value.trim().slice(0, 40); saveSettings(); applyAppName(); }
  const p = document.getElementById("profile"); if (p) p.hidden = true; localStorage.setItem(LS.profiled, "1");
  if (typeof maybeCoach === "function") maybeCoach();
}
function reconfigure() { const p = document.getElementById("profile"); if (p) p.hidden = false; const n = document.getElementById("profile-name"); if (n) n.value = (state.settings.appName || ""); }

const state = {
  vocab: null,
  settings: { ...DEFAULT_SETTINGS },
  overrides: { hidden: [], edited: {}, added: {} },
  tokens: [],         // message en cours
  tense: "present",
  board: "home",      // 'home' | fringe id
  view: "board",      // board | advocacy | write | edit | settings
  ctxPage: 0,         // page courante d'une catégorie (pagination anti-scroll)
  modeling: false,    // mode modélisation partenaire : montrer les mots sans les ajouter
  editing: false,
  writeBuffer: "",
};
window.state = state;   // exposé (même origine) pour l'outil de test spatial et le débogage

/* ---------- Persistance ---------- */
function load() {
  _activeProfile = profilesReg().active || "default";
  window.CT_PROFILE = _activeProfile;   // pour custom.js (base média du profil)
  try { Object.assign(state.settings, JSON.parse(localStorage.getItem(LS.settings)) || {}); } catch {}
  try { Object.assign(state.overrides, JSON.parse(localStorage.getItem(LS.overrides)) || {}); } catch {}
  if (!(state.settings.cols >= 7)) state.settings.cols = 7;   // JAMAIS moins de 49 cases (7×7)
}
function saveSettings() { localStorage.setItem(LS.settings, JSON.stringify(state.settings)); }
function saveOverrides() { localStorage.setItem(LS.overrides, JSON.stringify(state.overrides)); }

/* ---------- Profils locaux (isolation, bascule, verrou doux) ---------- */
function activeProfileName() { const r = profilesReg(); const p = r.list.find(x => x.id === r.active); return p ? p.name : "Mon profil"; }
// Recharge tout l'état depuis les clés du profil actif (après bascule/création).
function loadActiveProfileState() {
  window.CT_PROFILE = _activeProfile;
  if (window.CT && CT.onProfileSwitch) CT.onProfileSwitch();   // reconnecter la base média du profil
  state.settings = { ...DEFAULT_SETTINGS };
  state.overrides = { hidden: [], edited: {}, added: {} };
  state.tokens = []; state.board = "home";
  load();
  applyLook(); refresh();
}
async function switchProfile(id) {
  const r = profilesReg(); const p = r.list.find(x => x.id === id); if (!p || id === _activeProfile) return;
  if (p.pin) {   // verrou doux : demande le code, comparé par empreinte (pas un chiffrement des données)
    const entry = await ctPrompt("Code du profil « " + p.name + " » :");
    if (entry == null) return;
    if ((await sha256Hex(entry)) !== p.pin) { ctAlert("Code incorrect."); return; }
  }
  r.active = id; saveProfilesReg(r); _activeProfile = id;
  loadActiveProfileState(); setView("board");
}
function createProfile(name) {
  name = (name || "").trim() || "Nouveau compte";
  const id = (self.crypto && crypto.randomUUID) ? crypto.randomUUID() : ("p" + Date.now());
  const r = profilesReg(); r.list.push({ id, name }); r.active = id; saveProfilesReg(r); _activeProfile = id;
  // Profil NEUF = vocabulaire par défaut complet (49 cases), aucun média.
  loadActiveProfileState(); saveSettings(); saveOverrides(); setView("board");
}
function renameProfile(id, name) {
  name = (name || "").trim(); if (!name) return;
  const r = profilesReg(); const p = r.list.find(x => x.id === id); if (!p) return; p.name = name; saveProfilesReg(r); renderSettings();
}
async function setProfilePin(id) {
  const r = profilesReg(); const p = r.list.find(x => x.id === id); if (!p) return;
  if (p.pin) { if (await ctConfirm("Retirer le code de « " + p.name + " » ?", "", { ok: "Retirer", cancel: "Garder" })) { delete p.pin; saveProfilesReg(r); renderSettings(); } return; }
  const code = await ctPrompt("Choisis un code pour « " + p.name + " » (verrou doux sur cet appareil) :");
  if (!code) return;
  p.pin = await sha256Hex(code); saveProfilesReg(r); renderSettings();
  ctAlert("Code posé. Note : c'est un verrou local pour éviter les accès accidentels, pas un chiffrement des données. Pour un partage protégé, utilise « Tout sauvegarder » chiffré.");
}
async function deleteProfile(id) {
  const r = profilesReg();
  if (id === "default") { ctAlert("Le compte par défaut ne peut pas être supprimé."); return; }
  if (r.list.length <= 1) { ctAlert("Il faut garder au moins un compte."); return; }
  const p = r.list.find(x => x.id === id); if (!p) return;
  if (!await ctConfirm("Retirer le compte « " + p.name + " » ? Ses données (vocabulaire, scènes, enregistrements) sont CONSERVÉES dans la corbeille — vous pourrez le restaurer quand vous voulez.", "", { ok: "Retirer", cancel: "Annuler" })) return;
  // SOFT-DELETE : rien n'est effacé. On garde toutes ses données (localStorage + base média)
  // et on déplace simplement le profil vers la corbeille. Aucune action irréversible.
  r.list = r.list.filter(x => x.id !== id);
  r.trash = (r.trash || []).concat([{ id: p.id, name: p.name, ...(p.pin ? { pin: p.pin } : {}), trashedAt: Date.now() }]);
  const wasActive = (r.active === id);
  if (wasActive) r.active = r.list[0].id;
  saveProfilesReg(r);
  if (wasActive) { _activeProfile = r.active; loadActiveProfileState(); }
  renderSettings();
  toast("Compte « " + p.name + " » mis à la corbeille (récupérable).", null, "🗑️",
    { label: "↺ Annuler", fn: () => { restoreProfile(id); toast("Compte restauré.", null, "↺"); } });
}
// Restaurer un profil depuis la corbeille (ses données n'ont jamais été effacées).
function restoreProfile(id) {
  const r = profilesReg(); const p = (r.trash || []).find(x => x.id === id); if (!p) return;
  r.trash = r.trash.filter(x => x.id !== id);
  r.list = r.list.concat([{ id: p.id, name: p.name, ...(p.pin ? { pin: p.pin } : {}) }]);
  saveProfilesReg(r); renderSettings();
}

/* ---------- Synthèse vocale ---------- */
let VOICES = [], ALL_VOICES = [];
// CONFIDENTIALITÉ (RGPD, non négociable) : certaines voix du navigateur sont « en ligne »
// (localService === false) — prononcer avec elles ENVERRAIT le texte à un serveur distant.
// On ne propose et n'utilise QUE des voix locales. localService indéfini = local (beaucoup de
// navigateurs ne renseignent pas la propriété alors que la voix est locale) ; seul false = distant.
function isLocalVoice(v) { return !v || v.localService !== false; }
let _remoteVoiceWarned = false;
function warnRemoteVoiceOnce() {
  if (_remoteVoiceWarned) return; _remoteVoiceWarned = true;
  console.warn("Voix « en ligne » ignorée (confidentialité) : le texte n'est pas envoyé à un serveur distant.");
  try { toast("La seule voix disponible ici est une voix en ligne. Pour garder vos données sur l'appareil, elle n'est pas utilisée : installez une voix locale (Piper) ou une voix hors-ligne du système.", "info"); } catch (e) {}
}
function loadVoices() {
  ALL_VOICES = window.speechSynthesis?.getVoices() || [];
  VOICES = ALL_VOICES.filter(v => /fr/i.test(v.lang) && isLocalVoice(v));   // français ET local uniquement
}
// Langues/accents disponibles sur l'appareil (pour l'accent PAR CELLULE : « six seven » en anglais…).
function availableLangs() {
  const seen = new Map();
  ALL_VOICES.forEach(v => { const l = (v.lang || "").replace("_", "-"); if (l && !seen.has(l)) seen.set(l, v.name); });
  return [...seen.keys()].sort();
}
window.availableLangs = availableLangs;
// Prononciation d'un mot TAPÉ SEUL quand la lecture littérale serait fautive
// (« y » pronom -> « i », pas « i grec »). Le texte affiché ne change pas.
const SPEAK_ALONE = { "y": "i" };
/* ---------- Teinte de peau (diversité) — recolore la couleur chair #ffeec8 des pictos ----------
   La peau est le MÊME code partout (personnages custom + Mulberry) : on la remplace à l'affichage
   par la teinte choisie (habits/cheveux/contours inchangés). Activable ; « default » = telle quelle. */
const SKIN_TONES = { default: "", claire: "#ffe0bd", moyenne: "#f1c27d", mate: "#e0ac69",
  caramel: "#c68642", foncee: "#8d5524", ebene: "#5c3a21" };
const SKIN_LABELS = { default: "Par défaut", claire: "Claire", moyenne: "Moyenne", mate: "Mate",
  caramel: "Caramel", foncee: "Foncée", ebene: "Ébène" };
// Cheveux : recolore le brun Mulberry #9e5c26 UNIQUEMENT dans les pictos qui ont de la peau
// (=personnages) → jamais un objet brun. Le noir #231f20 (contours) n'est JAMAIS touché.
const HAIR_TONES = { default: "", noir: "#2b2320", chatain: "#6b4423", brun: "#3f2a17",
  blond: "#d2a24c", roux: "#a0522d", gris: "#9a9a9a", blanc: "#e8e8e8" };
const HAIR_LABELS = { default: "Par défaut", noir: "Noirs", chatain: "Châtains", brun: "Bruns",
  blond: "Blonds", roux: "Roux", gris: "Gris", blanc: "Blancs" };
const _skinCache = new Map();
async function recolorURI(path, skin, hair) {     // recolore peau (#ffeec8) et/ou cheveux (#9e5c26) d'un picto-personne
  if (!skin && !hair) return null;
  const key = path + "|" + (skin || "") + "|" + (hair || "");
  if (_skinCache.has(key)) return _skinCache.get(key);
  let uri = null;
  try {
    const r = await fetch(path); let t = await r.text();
    if (/#ffeec8/i.test(t)) {                     // c'est un personnage (il a de la peau)
      if (skin) t = t.replace(/#ffeec8/gi, skin);
      if (hair) t = t.replace(/#9e5c26/gi, hair);
      uri = "data:image/svg+xml," + encodeURIComponent(t);
    }
  } catch (e) { /* hors-ligne / non-svg */ }
  _skinCache.set(key, uri); return uri;
}
function applySkin(img, item) {                    // teinte peau + cheveux, globale OU par cellule
  let skinKey = state.settings.skinTone, hairKey = state.settings.hairTone;
  if (item) {                                      // override PAR CELLULE (prioritaire)
    const e = ov(item.id);
    const s = (e.skin ?? item.skin); if (s) skinKey = s;
    const h = (e.hair ?? item.hair); if (h) hairKey = h;
  }
  const skin = SKIN_TONES[skinKey] || "";
  const hair = HAIR_TONES[hairKey] || "";
  if (!skin && !hair) return;
  const p = img.getAttribute("src"); if (!p || !p.endsWith(".svg")) return;
  recolorURI(p, skin, hair).then(u => { if (u && img.getAttribute("src") === p) img.src = u; });
}
window.applySkin = applySkin;

/* ---------- BILINGUISME (2e langue) ----------
   Langues fréquentes dans l'espace francophone. La 2e langue s'affiche sous le mot et se
   dit dans sa voix (si installée sur l'appareil). Traduction par CELLULE (éditeur) prioritaire ;
   un noyau vérifié est fourni pour quelques langues — les autres se remplissent par cellule
   ou via un jeu de traductions validé (on ne « devine » pas une langue qu'on ne maîtrise pas). */
const LANG2 = [
  ["", "Aucune"], ["en-US", "Anglais"], ["zh-CN", "Chinois"], ["ar", "Arabe"], ["es-ES", "Espagnol"],
  ["it-IT", "Italien"], ["pt-PT", "Portugais"], ["de-DE", "Allemand"], ["ln", "Lingala"],
  ["sw", "Swahili"], ["wo", "Wolof"], ["ff", "Peul"],
];
// Noyau de traductions VÉRIFIÉ (mots de base). Étendable ; clés = norm(label).
const TRANS = {
  "en-US": { "je": "I", "tu": "you", "il": "he", "elle": "she", "nous": "we", "vous": "you", "oui": "yes", "non": "no",
    "bonjour": "hello", "au revoir": "bye", "merci": "thank you", "s'il te plaît": "please", "manger": "to eat",
    "boire": "to drink", "vouloir": "to want", "pouvoir": "can", "aller": "to go", "faire": "to do", "aimer": "to love",
    "aider": "to help", "encore": "more", "fini": "finished", "eau": "water", "maison": "home", "toilettes": "toilet",
    "j'ai mal": "it hurts", "j'ai besoin de": "I need", "content": "happy", "triste": "sad", "chaud": "hot", "froid": "cold" },
  "es-ES": { "je": "yo", "tu": "tú", "il": "él", "elle": "ella", "nous": "nosotros", "vous": "vosotros", "oui": "sí", "non": "no",
    "bonjour": "hola", "au revoir": "adiós", "merci": "gracias", "s'il te plaît": "por favor", "manger": "comer",
    "boire": "beber", "vouloir": "querer", "pouvoir": "poder", "aller": "ir", "faire": "hacer", "aimer": "amar",
    "aider": "ayudar", "encore": "más", "fini": "terminado", "eau": "agua", "maison": "casa", "toilettes": "baño",
    "j'ai mal": "me duele", "j'ai besoin de": "necesito", "content": "contento", "triste": "triste", "chaud": "caliente", "froid": "frío" },
};
function translationFor(item) {
  const code = state.settings.lang2; if (!code || !item) return null;
  const e = ov(item.id);
  const per = (e && e.tr) ?? item.tr;
  if (per) return per;                                  // traduction PAR CELLULE (prioritaire)
  const dict = TRANS[code]; return dict ? (dict[norm(labelFor(item))] || null) : null;
}
window.translationFor = translationFor;
// Dit le français PUIS la 2e langue (deux énoncés à la suite, sans que l'un annule l'autre).
function speakBilingual(fr, tr, lang) {
  if (!("speechSynthesis" in window)) { speakOut(fr); return; }
  window.speechSynthesis.cancel();
  const pv = voiceParams("system");
  const mk = (text, lg) => {
    const u = new SpeechSynthesisUtterance(text); u.lang = lg || "fr-FR";
    u.rate = Math.max(0.1, Math.min(3, pv.rate)); u.volume = Math.max(0, Math.min(1, pv.volume));
    if (lg) { const base = lg.split("-")[0].toLowerCase();   // 2e langue : voix LOCALE uniquement
      const v = ALL_VOICES.find(x => isLocalVoice(x) && (x.lang || "").toLowerCase().replace("_", "-") === lg.toLowerCase())
             || ALL_VOICES.find(x => isLocalVoice(x) && (x.lang || "").toLowerCase().startsWith(base));
      if (v) u.voice = v; else { warnRemoteVoiceOnce(); return null; } }   // pas de voix locale pour cette langue -> on n'énonce pas via une voix distante
    else { const v = pv.systemVoiceName ? VOICES.find(x => x.name === pv.systemVoiceName) : pickVoice(); if (v) u.voice = v; }
    return u;
  };
  const uf = mk(fr, "fr-FR"); if (uf) window.speechSynthesis.speak(uf);
  const ut = mk(tr, lang); if (ut) window.speechSynthesis.speak(ut);   // mis en file : joué après le français
}
// Hésitations : à prononcer TENUES, lentes et graves (euuuuuh…), jamais brèves.
const HESITATIONS = new Set(["euh", "heu", "hum", "humm", "euh…"]);
if ("speechSynthesis" in window) {
  loadVoices();
  window.speechSynthesis.onvoiceschanged = loadVoices;
}
// Profils de voix : hauteur/vitesse + préférence de genre pour la voix système.
const VOICE_PROFILES = {
  neutre: { pitch: 1.0 },
  enfant: { pitch: 1.7, rate: 1.05 },
  fille:  { pitch: 1.5, gender: "f" },
  garcon: { pitch: 1.35, gender: "m" },
  femme:  { pitch: 1.05, gender: "f" },
  homme:  { pitch: 0.8, gender: "m" },
};
const V_FEM = ["hortense", "julie", "amelie", "amélie", "audrey", "marie", "virginie", "celine", "céline", "lea", "léa", "manon", "charlotte", "aurelie", "elise", "female", "femme", "woman"];
const V_MALE = ["paul", "thomas", "nicolas", "henri", "claude", "mathieu", "antoine", "male", "homme", "man"];
function pickVoice(gender) {
  if (!VOICES.length) return null;
  if (gender) {
    const hints = gender === "f" ? V_FEM : V_MALE;
    const m = VOICES.find(v => hints.some(h => v.name.toLowerCase().includes(h)));
    if (m) return m;
  }
  return VOICES[0];
}
// Système de TON : intonation de la voix selon l'humeur (prosodie).
// p=hauteur, r=vitesse, v=volume. Vitesse et volume sont bien respectés par les
// voix Windows (la hauteur beaucoup moins) -> on marque le ton surtout par r et v.
const MOODS = {
  neutre:  { p: 1.0,  r: 1.0,  v: 1.0,  label: "Neutre",  emo: "😐" },
  joyeux:  { p: 1.25, r: 1.15, v: 1.0,  label: "Joyeux",  emo: "😊" },
  triste:  { p: 0.75, r: 0.72, v: 0.7,  label: "Triste",  emo: "😢" },
  colere:  { p: 0.9,  r: 1.35, v: 1.0,  label: "Fâché",   emo: "😠" },
  calme:   { p: 1.0,  r: 0.78, v: 0.8,  label: "Calme",   emo: "😌" },
  peur:    { p: 1.35, r: 1.3,  v: 0.85, label: "Peur",    emo: "😨" },
  excite:  { p: 1.4,  r: 1.35, v: 1.0,  label: "Excité",  emo: "🤩" },
  fatigue: { p: 0.8,  r: 0.68, v: 0.65, label: "Fatigué", emo: "😴" },
};
const EMOTION_MOOD = {
  "content": "joyeux", "heureux": "joyeux", "joyeux": "joyeux", "fier": "joyeux", "amoureux": "joyeux",
  "triste": "triste", "en colere": "colere", "fache": "colere", "jaloux": "colere",
  "calme": "calme", "j ai peur": "peur", "peur": "peur", "inquiet": "peur", "stresse": "peur",
  "surpris": "excite", "excite": "excite", "fatigue": "fatigue", "malade": "fatigue",
};
function speak(text, lang, opts) {
  if (!text || !("speechSynthesis" in window)) return;
  window.speechSynthesis.cancel();
  const u = new SpeechSynthesisUtterance(text);
  u.lang = lang || "fr-FR";
  const mood = MOODS[state.settings.mood] || MOODS.neutre;
  const o = opts || {};                          // {rate, pitch} : réglages fins (ex. « euh » lent et grave)
  // SOURCE DE VÉRITÉ UNIQUE : voix + vitesse + hauteur + volume via voiceParams(system).
  const pv = voiceParams("system");
  u.pitch = Math.max(0, Math.min(2, mood.p * (o.pitch || 1) * pv.pitch));
  u.rate = Math.max(0.1, Math.min(3, mood.r * (o.rate || 1) * pv.rate));
  u.volume = Math.max(0, Math.min(1, (mood.v ?? 1) * pv.volume));
  let v = null;
  if (lang) {                                   // ACCENT par cellule : voix de la langue demandée
    const base = lang.split("-")[0].toLowerCase();
    v = ALL_VOICES.find(x => (x.lang || "").toLowerCase().replace("_", "-") === lang.toLowerCase())
     || ALL_VOICES.find(x => (x.lang || "").toLowerCase().startsWith(base));
  }
  if (!v && pv.systemVoiceName) v = VOICES.find(x => x.name === pv.systemVoiceName) || null;
  if (!v) v = pickVoice();
  // APERÇU : voix « en attente » choisie mais pas encore appliquée.
  if (o.voiceName) { const vv = ALL_VOICES.find(x => x.name === o.voiceName); if (vv) v = vv; }
  // MODÉLISATION : voix du PARTENAIRE, avec ses PROPRES paramètres (jamais ceux
  // de la personne — modifier la voix du partenaire ne doit pas toucher la sienne).
  if (o.modeling) {
    const pp = state.settings.partnerVoice || {};
    const name = pp.systemVoiceName || state.settings.modelVoice;   // compat ancienne clé
    u.pitch = Math.max(0, Math.min(2, mood.p * (pp.pitch || 1)));
    u.rate = Math.max(0.1, Math.min(3, mood.r * (pp.rate || 1)));
    u.volume = Math.max(0, Math.min(1, (mood.v ?? 1) * (pp.volume || 1)));
    const chosen = name ? ALL_VOICES.find(x => x.name === name) : null;
    if (chosen) { v = chosen; }
    else {   // pas de voix explicite : voix auto différente + plus grave, pour la distinguer à l'oreille
      u.pitch = Math.max(0, Math.min(2, u.pitch * 0.72));
      const vm = pickVoice(); if (vm && (!v || vm.name !== v.name)) v = vm;
    }
  }
  // GARDE-FOU confidentialité : si la voix résolue est « en ligne » (distante), on ne l'utilise
  // JAMAIS (cela enverrait le texte dehors). On bascule sur une voix locale ; à défaut, on
  // n'énonce pas via une voix distante et on prévient une fois (Piper reste l'option locale sûre).
  if (v && v.localService === false) {
    const base = (u.lang || "fr-FR").split("-")[0].toLowerCase();
    const local = ALL_VOICES.find(x => isLocalVoice(x) && (x.lang || "").toLowerCase().startsWith(base)) || VOICES[0] || null;
    if (local) { v = local; } else { warnRemoteVoiceOnce(); return; }
  }
  if (v) u.voice = v;
  // LITTÉRATIE — lecture accompagnée : surligne chaque mot au fil de la voix système.
  // Uniquement pour la phrase de l'aperçu (o.highlight) et si l'option est active.
  if (o.highlight && state.settings.readAlong !== false && !o.modeling) {
    u.onboundary = ev => { if (ev.name === undefined || ev.name === "word") highlightPreviewAt(ev.charIndex || 0); };
    const done = () => clearReadAlong();
    u.onend = done; u.onerror = done;
  }
  window.speechSynthesis.speak(u);
}

// Parole qui RÉSOUT une promesse à la fin (pour enchaîner sans couper), avec la voix
// système choisie. rate/pitch sont des MULTIPLICATEURS des réglages de la personne
// (utilisé par le décodeur pour une voix plus vive). Ne touche pas au moteur Piper.
function speakP(text, opts) {
  return new Promise(resolve => {
    if (!text || !("speechSynthesis" in window)) { resolve(); return; }
    try {
      window.speechSynthesis.cancel();
      const o = opts || {};
      const u = new SpeechSynthesisUtterance(text);
      u.lang = o.lang || "fr-FR";
      const pv = voiceParams("system");
      u.rate = Math.max(0.1, Math.min(3, (o.rate != null ? o.rate : 1) * pv.rate));
      u.pitch = Math.max(0, Math.min(2, (o.pitch != null ? o.pitch : 1) * pv.pitch));
      u.volume = Math.max(0, Math.min(1, pv.volume));
      let v = null;
      if (o.lang) { const base = o.lang.split("-")[0].toLowerCase();
        v = ALL_VOICES.find(x => (x.lang || "").toLowerCase().replace("_", "-") === o.lang.toLowerCase()) || ALL_VOICES.find(x => (x.lang || "").toLowerCase().startsWith(base)); }
      if (!v && pv.systemVoiceName) v = VOICES.find(x => x.name === pv.systemVoiceName) || null;
      if (!v) v = pickVoice();
      if (v) u.voice = v;
      let done = false; const fin = () => { if (done) return; done = true; resolve(); };
      u.onend = fin; u.onerror = fin;
      window.speechSynthesis.speak(u);
    } catch (e) { resolve(); }
  });
}
window.speakP = speakP;

/* ---------- Voix de synthèse Piper (locale) — ton par TEMPO + VOLUME ----------
 * GARDE-FOU ÉDITORIAL : une voix de synthèse NE REMPLACE PAS la voix de la personne ;
 * elle est l'un des moyens QU'ELLE CHOISIT pour rendre son message audible. Ne jamais
 * écrire « voix naturelle/vraie », « l'outil parle pour vous », « donner une voix »,
 * « voix robotique ». Dire : voix classique / de synthèse / neuronale ; l'outil VOCALISE
 * le message composé. [[ton-copy-caa]] */
// On étire le tempo SANS toucher la hauteur (preservesPitch) : pas d'effet « chipmunk ».
// L'émotion passe par le rythme et le volume, sans décalage de hauteur.
let _curAudio = null;
async function speakPiper(text, voiceId) {
  const blob = await window.PiperTTS.synth(text, voiceId);
  const url = URL.createObjectURL(blob);
  if (_curAudio) { try { _curAudio.pause(); } catch {} }
  const audio = new Audio(url);
  audio.preservesPitch = true; audio.mozPreservesPitch = true; audio.webkitPreservesPitch = true;
  const mood = MOODS[state.settings.mood] || MOODS.neutre;
  const pv = voiceParams(voiceId || (state.settings.piperModel || "fr_FR-siwis-medium"));   // réglages de CE modèle
  // vitesse par voix (× lisible) ; la hauteur est fixée par le modèle (pas de réglage).
  const rate = (1 + ((mood.r || 1) - 1) * 0.6) * pv.rate;
  audio.playbackRate = Math.max(0.8, Math.min(1.22, rate));
  audio.volume = Math.max(0.15, Math.min(1, (mood.v ?? 1) * pv.volume));
  audio.onended = () => URL.revokeObjectURL(url);
  audio.play().catch(() => {});
  _curAudio = audio;
}
// Parole Piper qui ATTEND la fin de l'audio (promesse) — pour l'activité ABC (décodeur),
// où chaque son doit finir avant le suivant. Renvoie une promesse résolue à la fin.
async function speakPiperP(text, opts) {
  opts = opts || {};
  const voiceId = opts.voiceId || _decPiperVoice || state.settings.piperModel || "fr_FR-siwis-medium";
  const blob = await window.PiperTTS.synth(text, voiceId);
  const url = URL.createObjectURL(blob);
  if (_curAudio) { try { _curAudio.pause(); } catch {} }
  const audio = new Audio(url);
  audio.preservesPitch = true; audio.mozPreservesPitch = true; audio.webkitPreservesPitch = true;
  const pv = voiceParams(voiceId);
  audio.playbackRate = Math.max(0.6, Math.min(1.4, (opts.rate != null ? opts.rate : 1) * (pv.rate || 1)));
  audio.volume = Math.max(0.15, Math.min(1, pv.volume != null ? pv.volume : 1));
  _curAudio = audio;
  return new Promise(res => { let d = false; const f = () => { if (d) return; d = true; URL.revokeObjectURL(url); res(); }; audio.onended = f; audio.onerror = f; audio.play().catch(f); });
}
window.speakPiperP = speakPiperP;
// ABC/décodeur : on veut SYSTÉMATIQUEMENT Piper (sons nets). On prépare le moteur
// et on repère un modèle installé ; sinon on prévient (installation dans Réglages → Voix).
let _decPiperVoice = null;
async function prepPiperForDecoder() {
  try {
    await ensurePiper();
    const stored = await piperStoredIds();
    const model = state.settings.piperModel || "fr_FR-siwis-medium";
    _decPiperVoice = stored.includes(model) ? model : (stored[0] || null);
    return !!_decPiperVoice;
  } catch (e) { _decPiperVoice = null; return false; }
}
window.prepPiperForDecoder = prepPiperForDecoder;
window.decPiperReady = () => !!_decPiperVoice;
// ABC : installe AUTOMATIQUEMENT la voix locale Piper (téléchargement unique) quand la
// personne active le décodeur, puis l'utilise partout. Repli voix système pendant le téléchargement.
async function installPiperForDecoder(onProgress) {
  await ensurePiper();
  const model = state.settings.piperModel || "fr_FR-siwis-medium";
  const stored = await piperStoredIds();
  if (!stored.includes(model)) await window.PiperTTS.install(model, onProgress);
  _decPiperVoice = model;
  return true;
}
window.installPiperForDecoder = installPiperForDecoder;
// Piper = MODULE OPTIONNEL chargé À LA DEMANDE (jamais au démarrage). Ainsi le
// cœur (AGPL) tourne sans jamais charger le phonémiseur espeak-ng (GPLv3) : ce
// dernier n'est téléchargé/exécuté que si la personne choisit la voix Piper.
let _piperLoading = null;
function ensurePiper() {
  if (window.PiperTTS) return Promise.resolve();
  if (!_piperLoading) _piperLoading = import("./piper.js?v=" + (window.BUILD || "")).catch(e => { _piperLoading = null; throw e; });
  return _piperLoading;
}

/* ---------- Gestion GÉNÉRIQUE des voix ----------
 * Une voix = une entrée du registre. « système » (défaut, hors-ligne) et voix
 * locales Piper (optionnelles, à installer). SIWIS n'est qu'UN modèle du
 * registre, remplaçable : ajouter une voix = ajouter une entrée + son modèle. */
const VOICE_REGISTRY = [
  { id: "system", kind: "system", name: "Voix du système", lang: "appareil",
    desc: "Fournie par votre appareil, hors-ligne, prête à l'emploi." },
  { id: "fr_FR-siwis-medium", kind: "piper", name: "Piper — français (SIWIS)", lang: "fr-FR",
    desc: "Voix locale, plus expressive. À installer une fois.", sizeMB: 61,
    license: "MIT (poids à confirmer)", source: "rhasspy/piper-voices", redistribuable: false,
    note: "Installation en attente de clarification de licence (voir VOICES.md)." },
];
// Réglages PAR VOIX (une vitesse qui va à une voix ne va pas forcément à une autre).
const VOICE_PARAM_DEFAULTS = { rate: 1, pitch: 1, volume: 1, systemVoiceName: "" };
function voiceParams(id) {
  const stored = (state.settings.voiceParams || {})[id];
  // Compat TEMPORAIRE (1-2 versions) : si aucun réglage par-voix pour la voix
  // système, on relit les anciennes clés globales. La migration les recopie
  // normalement une fois ; ceci couvre un état non encore migré.
  if (id === "system" && !stored) {
    return Object.assign({}, VOICE_PARAM_DEFAULTS, {
      rate: (typeof state.settings.rate === "number") ? state.settings.rate : 1,
      systemVoiceName: state.settings.voice || "",
    });
  }
  return Object.assign({}, VOICE_PARAM_DEFAULTS, stored || {});
}
function setVoiceParam(id, key, val) {
  const vp = state.settings.voiceParams || (state.settings.voiceParams = {});
  vp[id] = Object.assign({}, VOICE_PARAM_DEFAULTS, vp[id] || {}, { [key]: val });
  saveSettings();
}
function resetVoiceParams(id) { const vp = state.settings.voiceParams || {}; delete vp[id]; saveSettings(); }
// Schéma des réglages. Le nettoyage des anciennes clés voix (s.rate, s.voice,
// s.voiceProfile, s.modelVoice) est prévu à cette version, après 1-2 versions
// stables — PAS maintenant. Constante explicite plutôt qu'une date flottante.
// Remove legacy voice settings after settings schema v3 migration.
const REMOVE_LEGACY_VOICE_KEYS_AT_SCHEMA = 3;
// Migration UNIQUE vers le schéma v2 : COMPLÈTE (jamais n'écrase) voiceParams.system
// et partnerVoice à partir des anciennes clés globales. Les anciennes clés restent
// en stockage (compat) jusqu'au schéma v3.
function migrateVoiceSettingsOnce() {
  const s = state.settings;
  if ((s.settingsSchemaVersion || 1) >= 2) return;
  const vp = s.voiceParams || (s.voiceParams = {});
  const had = vp.system || {};                       // réglages déjà posés (à NE PAS écraser)
  const sys = Object.assign({}, VOICE_PARAM_DEFAULTS, had);
  if (had.rate === undefined && typeof s.rate === "number") sys.rate = s.rate;
  if (!had.systemVoiceName && s.voice) sys.systemVoiceName = s.voice;
  // On NE migre PAS la hauteur depuis « profil de voix » : mapping arbitraire et
  // stéréotype (« voix féminine = plus aiguë »). pitch reste 1, réglable par la personne.
  vp.system = sys;
  // Voix du PARTENAIRE : clé propre, indépendante — on complète sans écraser.
  const pvz = s.partnerVoice || (s.partnerVoice = { systemVoiceName: "", rate: 1, pitch: 1, volume: 1 });
  if (!pvz.systemVoiceName && s.modelVoice) pvz.systemVoiceName = s.modelVoice;
  s.settingsSchemaVersion = 2;
  s.voiceMigrated = true;   // compat
  saveSettings();
}
// Capacités RÉELLES par moteur : n'afficher que ce qui agit vraiment.
function voiceCaps(v) {
  return v.kind === "piper"
    ? { rate: true, pitch: false, volume: true, systemVoice: false, pitchNote: "Hauteur définie par le modèle" }
    : { rate: true, pitch: true, volume: true, systemVoice: true };
}
// Voix SÉLECTIONNÉE (choix enregistré dans les réglages).
function selectedVoiceId() { return (state.settings.engine === "piper") ? (state.settings.piperModel || "fr_FR-siwis-medium") : "system"; }
// Voix de REPLI : toujours la voix du système.
const FALLBACK_VOICE_ID = "system";
// Machine d'états par voix. Transitoires dans voiceRuntime ; base = installée ?
const voiceRuntime = {};   // { id: { state:'installing'|'verifying'|'removing'|'error'|'unavailable', pct? } }
function voiceState(v, installedIds) {
  if (v.kind === "system") return "available";
  const rt = voiceRuntime[v.id]; if (rt && rt.state) return rt.state;   // installing/verifying/removing/error/unavailable
  return installedIds.includes(v.id) ? "ready" : "not_installed";
}
function voiceUsable(stateStr) { return stateStr === "available" || stateStr === "ready"; }
// Voix RÉELLEMENT UTILISÉE : la sélectionnée si utilisable, sinon le repli.
function effectiveVoiceId(installedIds) {
  const sel = selectedVoiceId();
  const v = VOICE_REGISTRY.find(x => x.id === sel);
  return (v && voiceUsable(voiceState(v, installedIds))) ? sel : FALLBACK_VOICE_ID;
}
// Ne JAMAIS sélectionner une voix non utilisable (règle CAA : pas de voix fantôme).
function setActiveVoice(id, installedIds) {
  const v = VOICE_REGISTRY.find(x => x.id === id); if (!v) return false;
  if (!voiceUsable(voiceState(v, installedIds || []))) return false;
  if (v.kind === "system") { state.settings.engine = "navigateur"; }
  else { state.settings.engine = "piper"; state.settings.piperModel = v.id; }
  saveSettings();
  return true;
}
// Modèles Piper réellement installés en local (OPFS) — sans charger le moteur.
async function piperStoredIds() {
  try {
    const root = await navigator.storage.getDirectory();
    const dir = await root.getDirectoryHandle("piper").catch(() => null);
    if (!dir) return [];
    const out = [];
    for await (const name of dir.keys()) { if (name.endsWith(".onnx")) out.push(name.replace(/\.onnx$/, "")); }
    return out;
  } catch (e) { return []; }
}
const VOICE_PREVIEW_PHRASE = "Bonjour, voici un aperçu de cette voix.";   // défaut ; phrase éditable via state.settings.previewPhrase
function previewPhrase() { return (state.settings.previewPhrase || "").trim() || VOICE_PREVIEW_PHRASE; }
// Aperçu UNIQUEMENT si la voix est réellement utilisable (aucun appel réseau
// automatique pour fabriquer un extrait d'une voix non installée).
async function previewVoice(v, installedIds) {
  if (v.kind === "system") { speak(previewPhrase()); return; }
  if (!voiceUsable(voiceState(v, installedIds || []))) return;   // pas d'aperçu sans modèle local
  try { await ensurePiper(); await speakPiper(previewPhrase(), v.id); }
  catch (e) { voiceRuntime[v.id] = { state: "error" }; renderVoiceManager(); }
}
async function installVoice(v) {
  if (v.kind !== "piper") return;
  voiceRuntime[v.id] = { state: "installing", pct: 0 }; renderVoiceManager();
  try {
    await ensurePiper();
    await window.PiperTTS.install(v.id, p => { if (p && p.total) { voiceRuntime[v.id] = { state: "installing", pct: Math.round(100 * p.loaded / p.total) }; renderVoiceManager(); } });
    voiceRuntime[v.id] = { state: "verifying" }; renderVoiceManager();
    const ids = await piperStoredIds();
    if (!ids.includes(v.id)) throw new Error("modèle absent après installation");
    delete voiceRuntime[v.id];   // -> 'ready' via la liste OPFS
  } catch (e) { voiceRuntime[v.id] = { state: "error" }; }
  renderVoiceManager();
}
// Suppression ENCADRÉE : si on supprime la voix utilisée, on confirme, on
// annonce la conséquence et on bascule explicitement sur la voix système.
async function removeVoice(v) {
  if (v.kind !== "piper") return;
  const wasSelected = selectedVoiceId() === v.id;
  if (wasSelected) {
    const ok = await ctConfirm("Cette voix est actuellement utilisée. Après sa suppression, la voix du système sera sélectionnée. Continuer ?", "", { ok: "Continuer", cancel: "Annuler" });
    if (!ok) return;
  }
  voiceRuntime[v.id] = { state: "removing" }; renderVoiceManager();
  try { await ensurePiper(); await window.PiperTTS.remove(v.id); } catch (e) {}
  delete voiceRuntime[v.id];
  if (wasSelected) {
    state.settings.engine = "navigateur"; saveSettings();
    if (window.toast) toast("Voix supprimée. La voix du système est maintenant sélectionnée.", null, "🔈");
  }
  renderVoiceManager();
}
const VOICE_STATE_LABEL = { available: "Disponible", ready: "Installée", not_installed: "Non installée",
  installing: "Installation…", verifying: "Vérification…", removing: "Suppression…", error: "Erreur", unavailable: "Indisponible" };
// Réglages fins REGROUPÉS dans la carte de la voix utilisée, pilotés par les
// capacités réelles du moteur (on n'affiche que ce qui agit vraiment).
function voiceFinePanel(v) {
  const caps = voiceCaps(v);
  const p = voiceParams(v.id);
  const wrap = document.createElement("div"); wrap.className = "voice-fine";
  const line = (txt) => { const e = document.createElement("p"); e.className = "voice-fine-line"; e.textContent = txt; wrap.appendChild(e); };
  // 2. Moteur & hors-ligne
  line(v.kind === "piper"
    ? "Moteur : Piper (voix locale) · fonctionne hors-ligne une fois installée."
    : "Moteur : voix du système · hors-ligne, fournie par l'appareil.");
  // 3. Choix de la voix (système : voix précise de l'appareil)
  if (caps.systemVoice) {
    const row = document.createElement("label"); row.className = "voice-fine-row";
    row.append(Object.assign(document.createElement("span"), { className: "vf-lab", textContent: "Voix" }));
    const sel = document.createElement("select");
    sel.appendChild(Object.assign(document.createElement("option"), { value: "", textContent: "(automatique)" }));
    (typeof VOICES !== "undefined" ? VOICES : []).forEach(sv => {
      const o = document.createElement("option"); o.value = sv.name; o.textContent = sv.name + (sv.lang ? " — " + sv.lang : "");
      if (sv.name === p.systemVoiceName) o.selected = true; sel.appendChild(o);
    });
    sel.onchange = () => { setVoiceParam(v.id, "systemVoiceName", sel.value); };
    row.appendChild(sel); wrap.appendChild(row);
  }
  // slider générique avec valeur lisible
  const slider = (labelTxt, key, min, max, step, fmt) => {
    const row = document.createElement("label"); row.className = "voice-fine-row";
    row.append(Object.assign(document.createElement("span"), { className: "vf-lab", textContent: labelTxt }));
    const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step; inp.value = p[key];
    const out = Object.assign(document.createElement("span"), { className: "vf-val", textContent: fmt(p[key]) });
    inp.oninput = () => { out.textContent = fmt(parseFloat(inp.value)); setVoiceParam(v.id, key, parseFloat(inp.value)); };
    row.append(inp, out); wrap.appendChild(row);
  };
  const asX = x => (Math.round(x * 10) / 10).toFixed(1).replace(".0", "") + "×";
  const asPct = x => Math.round(x * 100) + " %";
  // 4. Vitesse (plage raisonnable, valeur lisible en ×)
  slider("Vitesse", "rate", 0.7, 1.4, 0.1, asX);
  // 5. Hauteur — seulement si le moteur la supporte ; sinon mention explicite.
  if (caps.pitch) slider("Hauteur", "pitch", 0.7, 1.4, 0.1, asX);
  else line("Hauteur définie par le modèle.");
  // 6. Volume
  slider("Volume", "volume", 0.3, 1, 0.1, asPct);
  // 7. Aperçu (phrase neutre, éditable, distincte du message)
  const prow = document.createElement("div"); prow.className = "voice-fine-row";
  prow.append(Object.assign(document.createElement("span"), { className: "vf-lab", textContent: "Aperçu" }));
  const pin = document.createElement("input"); pin.type = "text"; pin.className = "vf-preview"; pin.value = previewPhrase();
  pin.setAttribute("aria-label", "Phrase d'aperçu pour tester la voix");
  pin.onchange = () => { state.settings.previewPhrase = pin.value; saveSettings(); };
  const pb = document.createElement("button"); pb.type = "button"; pb.className = "btn-secondary"; pb.textContent = "🔊 Écouter";
  pb.onclick = () => previewVoice(v, []);
  prow.append(pin, pb); wrap.appendChild(prow);
  // 8. Stockage (Piper)
  if (v.kind === "piper" && v.sizeMB) line("Stockage : ~" + v.sizeMB + " Mo en local (bouton « Supprimer les fichiers locaux » ci-dessus).");
  // Rétablir UNIQUEMENT les réglages de cette voix (pas de reset global).
  const reset = document.createElement("button"); reset.type = "button"; reset.className = "btn-secondary vf-reset";
  reset.textContent = "Rétablir les réglages de cette voix";
  reset.onclick = () => { resetVoiceParams(v.id); renderVoiceManager(); };
  wrap.appendChild(reset);
  return wrap;
}
// Rendu du gestionnaire de voix (liste générique + machine d'états + actions).
async function renderVoiceManager() {
  const host = document.getElementById("voice-manager"); if (!host) return;
  const installed = await piperStoredIds();
  const selected = selectedVoiceId();
  const effective = effectiveVoiceId(installed);
  host.innerHTML = "";
  const btn = (cls, txt) => { const b = document.createElement("button"); b.type = "button"; b.className = cls; b.textContent = txt; return b; };
  // Ligne d'entête : voix réellement utilisée (transparence sélectionnée/utilisée).
  const usedName = (VOICE_REGISTRY.find(x => x.id === effective) || {}).name || "Voix du système";
  const usedLine = document.createElement("p"); usedLine.className = "voice-used";
  usedLine.textContent = "Voix utilisée : " + usedName + (selected !== effective ? " (la voix sélectionnée est indisponible)" : "");
  host.appendChild(usedLine);
  const locNote = document.createElement("p"); locNote.className = "hint"; locNote.style.marginTop = "-4px";
  locNote.textContent = "Seules des voix locales sont proposées : le texte n'est jamais envoyé à un serveur (confidentialité).";
  host.appendChild(locNote);

  VOICE_REGISTRY.forEach(v => {
    const stt = voiceState(v, installed);
    const usable = voiceUsable(stt);
    const isEffective = v.id === effective;
    const isSelected = v.id === selected;
    const rt = voiceRuntime[v.id];

    const card = document.createElement("div"); card.className = "voice-card" + (isEffective ? " is-active" : "");
    const head = document.createElement("div"); head.className = "voice-head";
    head.appendChild(Object.assign(document.createElement("span"), { className: "voice-name", textContent: v.name }));
    // Badge d'état
    const bg = document.createElement("span"); bg.className = "voice-badge";
    if (isEffective) { bg.textContent = "Voix active"; }
    else if (isSelected && !usable) { bg.textContent = "Sélectionnée — indisponible"; bg.classList.add("warn"); }
    else if (rt && rt.state === "installing") { bg.textContent = "Installation " + (rt.pct || 0) + " %"; }
    else if (v.kind === "piper") { bg.textContent = VOICE_STATE_LABEL[stt] || stt; bg.classList.add(stt === "ready" ? "ok" : stt === "error" ? "warn" : "off"); }
    if (bg.textContent) head.appendChild(bg);
    card.appendChild(head);

    card.appendChild(Object.assign(document.createElement("p"), { className: "voice-desc", textContent: v.desc }));
    if (v.kind === "piper") {
      card.appendChild(Object.assign(document.createElement("p"), { className: "voice-meta",
        textContent: [v.lang, v.sizeMB ? v.sizeMB + " Mo" : null, v.license, v.source].filter(Boolean).join(" · ") }));
    }

    const acts = document.createElement("div"); acts.className = "voice-actions";
    // Choisir : actif seulement si UTILISABLE et pas déjà la voix effective.
    if (isEffective) { const b = btn("btn-secondary", "Voix active ✓"); b.disabled = true; acts.appendChild(b); }
    else {
      const b = btn("btn-primary", "Choisir cette voix");
      if (!usable) { b.disabled = true; b.title = "Installez d'abord cette voix."; }
      else b.onclick = () => { if (setActiveVoice(v.id, installed)) renderVoiceManager(); };
      acts.appendChild(b);
    }
    // Aperçu : seulement si utilisable ; sinon message explicite, sans réseau.
    if (v.kind === "system" || usable) {
      const prev = btn("btn-secondary", "🔊 Écouter un aperçu"); prev.onclick = () => previewVoice(v, installed); acts.appendChild(prev);
    } else if (v.kind === "piper") {
      acts.appendChild(Object.assign(document.createElement("span"), { className: "voice-status", textContent: "Aperçu disponible après installation" }));
    }
    // Installer / Supprimer (Piper)
    if (v.kind === "piper") {
      if (stt === "not_installed" || stt === "error") {
        const ib = btn("btn-secondary", "Installer une voix locale");
        if (v.redistribuable === false) { ib.disabled = true; ib.title = v.note || "Installation indisponible pour le moment."; }
        else ib.onclick = () => installVoice(v);
        acts.appendChild(ib);
      } else if (stt === "ready") {
        const rb = btn("btn-secondary", "Supprimer les fichiers locaux"); rb.onclick = () => removeVoice(v); acts.appendChild(rb);
      }
    }
    card.appendChild(acts);
    if (v.kind === "piper" && v.redistribuable === false && v.note && stt === "not_installed") {
      card.appendChild(Object.assign(document.createElement("p"), { className: "voice-note", textContent: "⚠️ " + v.note }));
    }
    // Réglages fins REGROUPÉS dans la carte de la voix réellement utilisée.
    if (isEffective) card.appendChild(voiceFinePanel(v));
    host.appendChild(card);
  });
}
window.renderVoiceManager = renderVoiceManager;
function warmPiper() {
  const set = (t) => { const el = document.getElementById("piper-status"); if (el) el.textContent = t; };
  set("Chargement du moteur Piper…");
  ensurePiper().then(() => {
    set("Préparation de la voix Piper (téléchargement local, une seule fois)…");
    return window.PiperTTS.warm((p) => { if (p && p.total) set(`Voix Piper : ${Math.round(100 * p.loaded / p.total)} %`); });
  }).then(() => set("Voix Piper prête ✓"))
    .catch(() => { set("La voix locale Piper n'est pas installée. La voix du système est utilisée pour le moment."); notifyPiperMissing(); });
}
// Bascule vers la voix système JAMAIS silencieuse : on prévient explicitement
// (la voix participe à l'identité de la personne) — une fois par session.
let _piperMissingNotified = false;
function notifyPiperMissing() {
  if (_piperMissingNotified) return;
  _piperMissingNotified = true;
  if (window.toast) toast("La voix locale Piper n'est pas installée. La voix du système est utilisée pour le moment.", null, "🔈");
}
// ---------- Lexique de corrections de PRONONCIATION (identité vocale) ----------
// Standard d'esprit W3C PLS : par mot AFFICHÉ, on stocke une prononciation.
//   { display, speech, phonemes }
//   - speech   : ré-écriture pour la VOIX DU SYSTÈME (seul levier fiable hors-ligne)
//                ex. « CAA » -> « Communication Alternative et Augmentée », « Priscilla » -> « Prissila ».
//   - phonemes : phonèmes eSpeak (injection Piper `[[ ]]`) — Piper/cloud uniquement, PAS la voix système.
// Données dans state.overrides.pron (100 % local). Voir [[appli-caa-roadmap]] (banger #13).
function pronMap() { return (state.overrides && state.overrides.pron) || {}; }
// Clé de comparaison d'un mot : NFC + minuscules + apostrophes unifiées (', ʼ, ' -> ').
function pronKeyOf(s) { return String(s || "").normalize("NFC").toLowerCase().replace(/[’ʼ]/g, "'"); }
function pronRepOf(e, forPiper) { return (forPiper && e.phonemes) ? "[[" + e.phonemes + "]]" : (e.speech || null); }
// Applique les corrections à un texte, en UNE SEULE passe (aucun remplacement récursif :
// le texte inséré n'est jamais re-balayé). Règles :
//  - mot/expression ENTIER (frontières = ni lettre, ni marque, ni chiffre) ;
//  - Unicode : accents, ligatures (NFC), apostrophes (', ʼ, '), traits d'union ;
//  - les expressions les plus LONGUES sont prioritaires ;
//  - casse : correspondance INSENSIBLE à la casse, remplacement rendu tel qu'enregistré.
//  - forPiper : injecte les phonèmes `[[ ]]` si présents (Piper/eSpeak), sinon la ré-écriture.
function applyPron(text, forPiper) {
  if (!text) return text;
  const map = pronMap();
  const list = Object.keys(map).map(k => map[k]).filter(e => e && e.display && (e.speech || e.phonemes));
  if (!list.length) return text;
  const src = text.normalize ? text.normalize("NFC") : text;
  list.sort((a, b) => b.display.length - a.display.length);    // longues expressions d'abord
  const byKey = {}; const parts = [];
  for (const e of list) {
    const d = e.display.normalize ? e.display.normalize("NFC") : e.display;
    byKey[pronKeyOf(d)] = e;
    // échappe la regex, puis rend l'apostrophe et le trait d'union tolérants aux variantes.
    const esc = d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
                 .replace(/['’ʼ]/g, "['’ʼ]")
                 .replace(/[-‐‑–]/g, "[-‐‑–]");
    parts.push(esc);
  }
  const B = "\\p{L}\\p{M}\\p{N}";
  let re = null;
  try { re = new RegExp("(?<![" + B + "])(" + parts.join("|") + ")(?![" + B + "])", "giu"); } catch (_) { re = null; }
  if (!re) {   // repli (moteur sans lookbehind / \p) : remplacement mot entier, une passe par entrée
    let out = src;
    for (const e of list) { const rep = pronRepOf(e, forPiper); if (rep != null) out = out.split(e.display).join(rep); }
    return out;
  }
  return src.replace(re, (m, g1) => { const e = byKey[pronKeyOf(g1)]; const rep = e ? pronRepOf(e, forPiper) : null; return rep == null ? m : rep; });
}
// Initialise le lexique VIDE (aucune correction imposée : l'identité vocale commence
// par ne rien décider à la place de la personne). Un exemple est PROPOSÉ dans l'UI.
function ensurePron() {
  if (!state.overrides) return;
  if (state.overrides.pron === undefined) state.overrides.pron = {};
}
// Gestionnaire du lexique de prononciation (liste + ajout/édition/suppression + Écouter).
function renderPronManager() {
  const host = document.getElementById("pron-manager"); if (!host) return;
  ensurePron();
  const map = state.overrides.pron || (state.overrides.pron = {});
  host.innerHTML = "";
  const rows = Object.keys(map).map(k => ({ k, e: map[k] })).sort((a, b) => a.e.display.localeCompare(b.e.display, "fr"));
  rows.forEach(({ k, e }) => {
    const row = document.createElement("div"); row.className = "pron-row";
    const info = document.createElement("div"); info.className = "pron-info";
    info.appendChild(Object.assign(document.createElement("span"), { className: "pron-disp", textContent: e.display }));
    info.appendChild(Object.assign(document.createElement("span"), { className: "pron-arrow", textContent: "→" }));
    info.appendChild(Object.assign(document.createElement("span"), { className: "pron-speech", textContent: e.speech || "(phonèmes seuls)" }));
    if (e.phonemes) info.appendChild(Object.assign(document.createElement("span"), { className: "pron-ph", textContent: "[[" + e.phonemes + "]]" }));
    row.appendChild(info);
    const acts = document.createElement("div"); acts.className = "pron-acts";
    const hear = document.createElement("button"); hear.type = "button"; hear.className = "btn-secondary"; hear.textContent = "🔊";
    hear.title = "Écouter"; hear.onclick = () => speakOut(e.display);
    const del = document.createElement("button"); del.type = "button"; del.className = "btn-secondary"; del.textContent = "🗑️";
    del.title = "Supprimer"; del.onclick = () => { delete map[k]; saveOverrides(); renderPronManager(); };
    acts.append(hear, del); row.appendChild(acts); host.appendChild(row);
  });
  // Formulaire d'ajout
  const form = document.createElement("div"); form.className = "pron-add";
  const disp = Object.assign(document.createElement("input"), { type: "text", placeholder: "Mot affiché (ex. CAA, Priscilla)" });
  const speech = Object.assign(document.createElement("input"), { type: "text", placeholder: "Prononcé (ré-écriture, ex. Prissila)" });
  const ph = Object.assign(document.createElement("input"), { type: "text", placeholder: "Phonèmes (Piper, facultatif)" });
  const preview = document.createElement("button"); preview.type = "button"; preview.className = "btn-secondary"; preview.textContent = "🔊 Écouter";
  preview.onclick = () => { const t = speech.value.trim() || disp.value.trim(); if (t) speak(t); };
  const add = document.createElement("button"); add.type = "button"; add.className = "btn-primary"; add.textContent = "Ajouter";
  add.onclick = () => {
    const d = disp.value.trim(); if (!d) return;
    if (!speech.value.trim() && !ph.value.trim()) return;
    map[norm(d)] = { display: d, speech: speech.value.trim(), phonemes: ph.value.trim() };
    saveOverrides(); renderPronManager();
  };
  form.append(disp, speech, ph, preview, add); host.appendChild(form);
  // Exemple PROPOSÉ (non imposé) : remplit le formulaire, la personne décide.
  if (!rows.length) {
    const ex = document.createElement("p"); ex.className = "hint"; ex.style.margin = "6px 0 0";
    ex.append(document.createTextNode("Exemple : certaines personnes préfèrent entendre le sigle en entier. "));
    const exBtn = document.createElement("button"); exBtn.type = "button"; exBtn.className = "linklike";
    exBtn.textContent = "Pré-remplir « CAA → Communication Alternative et Augmentée »";
    exBtn.onclick = () => { disp.value = "CAA"; speech.value = "Communication Alternative et Augmentée"; ph.value = ""; disp.focus(); };
    ex.appendChild(exBtn);
    ex.append(document.createTextNode(" (d'autres voudront « cé a a ». À toi de choisir.)"));
    host.appendChild(ex);
  }
}
window.renderPronManager = renderPronManager;
// Parole "de sortie" (phrase complète) : moteur choisi (Piper si dispo), sinon navigateur.
function speakOut(text) {
  if (!text) return;
  // Est-ce la phrase de l'aperçu (celle affichée dans #preview) ? -> lecture accompagnée.
  const pvEl = document.getElementById("preview");
  const isSentence = !!pvEl && (pvEl._raw === text || pvEl.textContent === text);
  if (state.settings.engine === "piper" && window.PiperTTS && window.PiperTTS.ok) {
    // Piper (audio) n'expose pas d'événement « boundary » : pas de surlignage mot à mot ;
    // le mot écrit reste affiché. Repli voix système si le modèle local manque.
    if ("speechSynthesis" in window) window.speechSynthesis.cancel();
    const piperText = applyPron(text, true);
    speakPiper(piperText).catch(e => { console.warn("Voix Piper indisponible, repli sur la voix système :", e); notifyPiperMissing(); const t = applyPron(text, false); speak(t, null, (isSentence && t === text) ? { highlight: true } : null); });
  } else {
    // Voix système : ré-écriture (speech). Le surlignage n'est gardé que si le texte n'a pas changé.
    const t = applyPron(text, false);
    speak(t, null, (isSentence && t === text) ? { highlight: true } : null);
  }
}

/* ---------- Accès vocabulaire (avec overrides) ---------- */
function isHidden(id) { return state.overrides.hidden.includes(id); }
function ov(id) { return state.overrides.edited[id] || {}; }
function labelFor(item) { return ov(item.id).label ?? item.label; }
function colorFor(item) { return ov(item.id).color ?? item.color; }
function sayFor(item) { return ov(item.id).say ?? item.say; }
function pictoFor(item) {
  const o = ov(item.id).sym_path;                                       // override perso (Modifier) : prioritaire
  if (o != null) return o;
  if (state.settings.pictoStyle === "sepia" && item && item.sym_sepia) return item.sym_sepia;  // style sépia (vintage) au choix
  return item.sym_path;
}
window.pictoFor = pictoFor;   // (réassigné plus bas aussi, sans effet)
window.pictoFor = pictoFor;
function langFor(item) { return ov(item.id).lang ?? item.lang ?? null; }   // accent/voix par cellule
function mediaOf(item) { return ov(item.id).media ?? item.media ?? null; }

function columnItems() { return (state.vocab.columns || []).flatMap(c => c.items); }
function homeItems() {
  const base = columnItems().filter(i => !isHidden(i.id));
  const added = (state.overrides.added["home"] || []).filter(i => !isHidden(i.id));
  return [...base, ...added];
}
// VOIE RAPIDE « Mes mots » (accessible SANS écrire) : les mots que la personne utilise le plus + les
// plus récents + ceux qu'elle épingle, en gros pictos → on RECONNAÎT, on ne tape pas. 100 % local.
function mesMotsItems(limit) {
  limit = limit || 24;
  const u = state.overrides.usage || {};
  const fav = state.overrides.wordFav || [];
  const seen = new Set(); const out = [];
  fav.forEach(id => { const it = itemById(id); if (it && !seen.has(id)) { seen.add(id); out.push(it); } });   // épinglés d'abord
  Object.keys(u)
    .map(id => ({ id, n: (u[id].n || 0), t: (u[id].t || 0) }))
    .sort((a, b) => (b.n - a.n) || (b.t - a.t))   // les plus utilisés, puis les plus récents
    .forEach(r => { if (out.length >= limit || seen.has(r.id)) return; const it = itemById(r.id); if (it) { seen.add(r.id); out.push(it); } });
  return out.slice(0, limit);
}
function findBoard(id) {
  if (id === "__mesmots") return { id, label: "Mes mots", color: "petit", items: mesMotsItems() };
  if (id === "__variantes") return { id, label: "Variantes régionales", color: "nom", items: variantItems() };
  return (state.vocab.functions || []).find(f => f.id === id)
      || (state.vocab.themes || []).find(f => f.id === id)
      || (state.vocab.packs || []).find(f => f.id === id);
}
// Packs pan-francophones activés par la personne (null au 1er lancement -> socle par défaut).
function enabledPacks() {
  const on = state.settings.packs;
  const all = state.vocab.packs || [];
  if (on == null) return all.filter(p => p.default);
  return all.filter(p => on.includes(p.id));
}
// Variantes régionales : le mot préféré de la personne pour chaque concept.
function variantOf(s) {
  const chosen = (state.settings.variants || {})[s.id];
  return (s.variants || []).find(v => v.label === chosen) || s.variants[0];
}
function variantItems() {
  return (state.vocab.synonyms || []).map(s => {
    const v = variantOf(s);
    return { id: "var-" + s.id, label: v.label, say: v.say || undefined, color: "nom", sym_path: s.sym_path };
  });
}

/* ---------- Message ---------- */
// Moteur de phrase : la logique vit dans sentence.js (window.Sentence), PURE et testable
// hors DOM. Ici on ne fait que fournir le CONTEXTE depuis l'état courant et déléguer.
function _sentenceCtx() { return { tense: state.tense, registre: state.settings.registre, genre: state.settings.genreLocuteur }; }
function tokenDisplay(tok, i) { return Sentence.tokenDisplay(tok, i, state.tokens, _sentenceCtx()); }
function isDeterminer(label) { return Sentence.isDeterminer(label); }
function conjugatePhrase(label) { return Sentence.conjugatePhrase(label, _sentenceCtx()); }
// Accorde le dernier nom + son déterminant + ses adjectifs (flag 'plural' ou 'fem').
// Enveloppe : montre l'avant→après et laisse annuler (audit §4.6). opts.silent = pas d'annonce
// (utilisé quand c'est le bouton « Annuler » lui-même qui rejoue l'accord en sens inverse).
function agreeLastNoun(flag, opts) {
  const before = sentence();
  const changed = _agreeLastNounCore(flag);
  if (!opts || !opts.silent) {
    announceTransform(before, flag === "fem" ? "féminin" : "pluriel", () => agreeLastNoun(flag, { silent: true }));
    showFormZoom(changed);   // profil tout-petit : nouvelle forme (chats, contente…) en grand
  }
}
function _agreeLastNounCore(flag) {
  // Forme « participe passé » : les boutons féminin/pluriel accordent le PARTICIPE (la case verbe).
  if (state.tense === "participepasse") {
    for (let i = state.tokens.length - 1; i >= 0; i--) {
      if (state.tokens[i].verb) { state.tokens[i][flag] = !state.tokens[i][flag]; renderMessage(); return state.tokens[i]; }
    }
  }
  let ni = -1;
  for (let i = state.tokens.length - 1; i >= 0; i--) {
    const t = state.tokens[i]; if (t.verb || t.phrase) continue;
    if (t.color === "nom") { ni = i; break; }
    if (ni < 0) ni = i;                          // à défaut : le dernier mot
  }
  if (ni < 0) return null;
  const val = !state.tokens[ni][flag];
  state.tokens[ni][flag] = val;
  for (let j = ni - 1; j >= 0; j--) {            // gauche : déterminant + adjectifs
    const t = state.tokens[j];
    if (t.color === "descriptif" || isDeterminer(t.label)) t[flag] = val; else break;
  }
  for (let j = ni + 1; j < state.tokens.length; j++) {   // droite : adjectifs
    const t = state.tokens[j];
    if (t.color === "descriptif") t[flag] = val; else break;
  }
  renderMessage();
  return state.tokens[ni];
}
/* ---------- Moteur syntaxique EXPÉRIMENTAL (jsRealB, opt-in, chargé à la demande) ----------
   Pour les constructions que le moteur linéaire ne fait pas (clitiques ordonnés « je le lui donne »).
   Chargé seulement si le réglage est activé ; REPLI systématique sur Sentence.build. jsRealB = Apache 2.0. */
let _jsr = null, _jsrLoading = null;
function loadJsRealB() {
  if (_jsr) return Promise.resolve(_jsr);
  if (_jsrLoading) return _jsrLoading;
  _jsrLoading = new Promise(res => {
    const s = document.createElement("script");
    s.src = "assets/jsrealb/jsRealB.js?v=" + (window.BUILD || "");
    s.onload = () => { try { const api = window.jsRealB; if (api && api.loadFr) { api.loadFr(); _jsr = api; } } catch (e) {} res(_jsr); };
    s.onerror = () => res(null);
    document.head.appendChild(s);
  });
  return _jsrLoading;
}
// Mapper BORNÉ jetons -> jsRealB : logique PURE et TESTABLE dans lang/jsr-map.js (window.JSRMap),
// verrouillée par tests/run-jsr-map.cjs. Ici : simple délégation avec l'API jsRealB chargée.
function jsrTry(tokens, ctx) {
  return (_jsr && window.JSRMap) ? window.JSRMap.mapTokens(_jsr, tokens, ctx) : null;
}
function sentence() {
  const ctx = _sentenceCtx();
  if (state.settings.expGrammar && _jsr) { const r = jsrTry(state.tokens, ctx); if (r) return r; }   // syntaxe avancée si dispo + mappable
  return Sentence.build(state.tokens, ctx);   // moteur linéaire (repli / défaut)
}
// Rendre une transformation grammaticale COMPRÉHENSIBLE et RÉVERSIBLE (audit §4.6) :
// on montre « avant » → « après » + l'étiquette (temps/forme/accord), avec un bouton Annuler.
// Discret : rien ne s'affiche si la phrase n'a pas changé (ou si elle est vide).
function announceTransform(before, kindLabel, restoreFn) {
  const after = sentence();
  if (!before || !after || before === after) return;
  const msg = "« " + before + " » → « " + after + " »" + (kindLabel ? "  ·  " + kindLabel : "");
  try { toast(msg, "info", null, restoreFn ? { label: "↺ Annuler", fn: restoreFn } : null); } catch (e) {}
}
function renderMessage() {
  const box = document.getElementById("message");
  box.innerHTML = "";
  state.tokens.forEach((t, i) => {
    const el = document.createElement("button");
    el.type = "button";
    el.className = "msg-token";
    const disp = tokenDisplay(t, i);
    el.setAttribute("aria-label", "Retirer « " + disp + " »");
    if (t.sym_path) { const im = document.createElement("img"); im.src = t.sym_path; im.alt = ""; el.appendChild(im); applySkin(im); }
    const s = document.createElement("span"); s.textContent = disp; el.appendChild(s);
    el.addEventListener("click", () => { state.tokens.splice(i, 1); renderMessage(); refreshBoardContext(); });
    box.appendChild(el);
  });
  box.scrollLeft = box.scrollWidth;
  renderPreview();
  updateBars();
}
// LITTÉRATIE — aperçu en MOTS surlignables : chaque mot est un <span.pw> avec ses
// offsets de caractères dans la phrase parlée, pour la lecture accompagnée (surlignage
// synchronisé avec la voix). Le textContent reste EXACTEMENT sentence() (ce qui est dit).
function renderPreview() {
  const pv = document.getElementById("preview");
  if (!pv) return;
  const text = sentence();
  pv.textContent = "";
  pv._raw = text;
  if (!text) return;
  const re = /\S+/g; let last = 0, m;
  while ((m = re.exec(text))) {
    if (m.index > last) pv.appendChild(document.createTextNode(text.slice(last, m.index)));
    const sp = document.createElement("span");
    sp.className = "pw"; sp.textContent = m[0];
    sp.dataset.start = String(m.index); sp.dataset.end = String(m.index + m[0].length);
    pv.appendChild(sp);
    last = m.index + m[0].length;
  }
  if (last < text.length) pv.appendChild(document.createTextNode(text.slice(last)));
}
function clearReadAlong() {
  document.querySelectorAll("#preview .pw.pw-on").forEach(n => n.classList.remove("pw-on"));
}
// Surligne le mot dont l'intervalle de caractères contient charIndex (événement boundary).
function highlightPreviewAt(charIndex) {
  const pv = document.getElementById("preview"); if (!pv) return;
  const spans = pv.querySelectorAll(".pw"); let hit = null;
  spans.forEach(s => { if (charIndex >= +s.dataset.start && charIndex < +s.dataset.end) hit = s; });
  if (hit) { spans.forEach(s => s.classList.remove("pw-on")); hit.classList.add("pw-on"); }
}
// Divulgation progressive (audit) : la grammaire et le ton n'apparaissent que
// quand ils servent -> moins de surcharge, l'essentiel d'abord.
function updateBars() {
  const gb = document.getElementById("grammarbar"), tb = document.getElementById("tonebar");
  const inView = state.view !== "settings";
  const hasVerb = state.tokens.some(t => t.verb);
  const hasAgree = state.tokens.some(t => t.color === "nom" || t.color === "descriptif" || isDeterminer(t.label));
  const hasEmotion = state.tokens.some(t => EMOTION_MOOD[norm(t.label)]) || state.settings.mood !== "neutre";
  const gMode = state.settings.grammarBarMode || "auto";
  const tMode = state.settings.toneBarMode || "auto";
  if (gb) {
    // STABILITÉ : au repos la barre garde sa place (invisible, hauteur réservée) -> la grille ne
    // saute pas. SEUL le mode « jamais » la retire vraiment (place libérée, car elle ne revient pas).
    const gOff = (gMode === "never");
    const gAuto = (gMode === "always") || (gMode === "auto" && (hasVerb || hasAgree));
    // grammarForce : true = forcée visible · false = masquée à la main pour CETTE phrase · null = auto.
    const gShow = inView && !gOff && (state.grammarForce === false ? false : state.grammarForce === true ? true : gAuto);
    gb.hidden = !inView || gOff;                  // hors vues tableau OU « jamais » -> retirée
    gb.classList.toggle("gb-resting", !gShow);
  }
  // Barre de ton : en mode auto, elle apparaît sur émotion — MAIS un clic manuel
  // (state.toneForce) prime : true = forcer l'affichage, false = forcer le masquage
  // (même quand il y a une émotion), null = comportement auto.
  let tShown;
  if (!inView) tShown = false;
  else if (tMode === "always") tShown = true;
  else if (tMode === "never") tShown = false;
  else tShown = (state.toneForce === true) ? true : (state.toneForce === false) ? false : hasEmotion;
  // STABILITÉ : comme la grammaire, la barre de ton garde TOUJOURS sa place -> la grille ne bouge pas.
  if (tb) { tb.hidden = !inView; tb.classList.toggle("tb-resting", !tShown); }
  const tt = document.getElementById("tone-toggle");
  if (tt) { tt.classList.toggle("is-on", !!tShown && state.view !== "settings");
            tt.setAttribute("aria-pressed", String(!!tShown)); }
}
function addToken(item) {
  if (state.modeling) { modelWord(item); return; }   // mode modélisation : on MONTRE, on n'ajoute pas
  const tok = {
    id: item.id, label: labelFor(item), say: sayFor(item), color: colorFor(item),
    subject: item.subject, verb: item.verb, sym_path: item.sym_path, phrase: !!item.phrase,
    lang: langFor(item),
  };
  const prev = state.tokens[state.tokens.length - 1];
  if (item.id && !item.phrase) { recordUsage(item.id); if (prev && prev.id) recordBigram(prev.id, item.id); }
  // Ton automatique selon l'émotion — MAIS jamais sur une émotion NIÉE : « pas content »
  // ne doit pas sonner joyeux. On regarde les mots récents (négation posée avant l'émotion).
  if (state.settings.moodAuto) {
    const mm = EMOTION_MOOD[norm(labelFor(item))];
    if (mm) {
      const NEG = new Set(["ne", "n'", "pas", "non", "jamais", "plus", "aucun", "rien", "sans", "ni", "pas bon"]);
      const negated = state.tokens.slice(-3).some(t => t.color === "negation" || NEG.has(norm(t.label)));
      if (!negated) setMood(mm, false);   // sinon on laisse le ton neutre (pas de contresens)
    }
  }
  state.tokens.push(tok);
  renderMessage();
  showSelectZoom(item);   // profil tout-petit : grossissement fixe du mot sélectionné (ne bouge pas la grille)
  if (findState) onFindPossibleHit(item);   // jeu Chercher : confirmation douce si c'est la cible (jamais de sanction sinon)
  if (state.tokens.length === 1) {
    if (state.settings.encourage) jitTip("first-word", "Bien joué 👏 Touche 🔊 Dire pour entendre.", "👏");
    else jitTip("first-word", "Touche 🔊 Dire pour entendre la phrase.", "🔊");
  }
  const _m = mediaOf(item);
  if (_m && _m.kind === "bruitage" && window.CT && CT.playBruitage) CT.playBruitage(_m.name);   // bruitage synthétisé
  else if (_m && _m.kind === "audio" && window.CT && CT.playAudio) CT.playAudio(_m.key);   // voix enregistrée = vraie voix
  else if (state.settings.speakOnTap) {
    let say = item.phrase ? labelFor(item) : (sayFor(item) || tokenDisplay(tok, state.tokens.length - 1));
    // Prononciation du mot TAPÉ SEUL : « y » (pronom, j'y vais) = son « i », pas « i grec ».
    // N'affecte que l'écoute isolée ; l'orthographe de la phrase reste « y ».
    say = SPEAK_ALONE[norm(say)] || say;
    // Hésitation « euh » : son /ø/ comme dans « deux » (on fait dire « eux », bien lu /ø/),
    // TENU, lent et plus grave. NB : la synthèse ne peut pas vraiment tenir 2 s -> pour un
    // « euuuh » vraiment long, enregistrer un son (Modifier → 🎤) qui jouera tel quel.
    const tr = translationFor(item);            // BILINGUISME : dire aussi la 2e langue si dispo
    if (HESITATIONS.has(norm(labelFor(item)))) speak("eux", null, { rate: 0.32, pitch: 0.85 });
    else if (tok.lang) speak(say, tok.lang);     // ACCENT par cellule (voix navigateur : anglais, etc.)
    else if (tr) speakBilingual(say, tr, state.settings.lang2);   // français puis 2e langue
    else speakOut(say);
  }
  refreshBoardContext();   // zone « En lien » + fringe adaptatif (colonnes suivent le verbe)
}

/* ---------- Contexte : vocabulaire spécifique lié aux sélections (Baker) ---------- */
function norm(s) { return (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); }
function recordBigram(prevId, id) {
  if (!prevId || !id || prevId === id) return;
  const b = (state.overrides.bigrams ||= {});
  (b[prevId] ||= {}); b[prevId][id] = (b[prevId][id] || 0) + 1; saveOverrides();
}
function usageScore(id) { const e = (state.overrides.usage || {})[id]; return e ? e.n : 0; }
// RACCOURCIS DE PHRASES : on APPREND les phrases que la personne construit souvent (≥ 2 mots), pour
// les reproposer en UN SEUL tap dans « En lien » quand le message est vide. Aucune case ni étape en
// plus (la colonne est déjà là). 100 % local. Honnête : ses propres phrases répétées, jamais imposées
// (elles n'apparaissent qu'après répétition et se masquent d'un ✕).
function recordPhrase() {
  const toks = (state.tokens || []).filter(t => t && t.label);
  if (toks.length < 2) return;
  const key = toks.map(t => norm(t.label)).join(" ");
  const p = (state.overrides.phrases ||= {});
  const compact = toks.map(t => ({ label: t.label, say: t.say, color: t.color, verb: t.verb, subject: t.subject, phrase: t.phrase, id: t.id, sym_path: t.sym_path }));
  const e = p[key] || { toks: compact, n: 0, t: 0 };
  e.toks = compact; e.n++; e.t = (state.overrides.usageSeq || 0); p[key] = e;
  const keys = Object.keys(p);
  if (keys.length > 80) {                                  // borne : on garde les 80 plus fréquentes
    keys.sort((a, b) => (p[a].n - p[b].n) || (p[a].t - p[b].t));
    for (const k of keys.slice(0, keys.length - 80)) delete p[k];
  }
  saveOverrides();
}
function topPhrases(minN, limit) {
  const p = state.overrides.phrases || {}, hidden = state.overrides.phrasesHidden || [];
  return Object.keys(p).map(k => ({ key: k, toks: p[k].toks, n: p[k].n, t: p[k].t }))
    .filter(x => x.n >= (minN || 2) && Array.isArray(x.toks) && x.toks.length >= 2 && hidden.indexOf(x.key) < 0)
    .sort((a, b) => (b.n - a.n) || (b.t - a.t))
    .slice(0, limit || 4);
}
// Phrases rapides ADAPTATIVES : classées selon 4 signaux, TOUJOURS des propositions (jamais imposé).
//   • fréquence (n) : ce que la personne dit souvent · • récence (t) : ce qu'elle a dit récemment
//   • CONTEXTE : ce qui est déjà tapé (message vide -> top habituelles ; en cours -> celles qui CONTINUENT la phrase)
//   • chaque phrase garde un ✕ pour ne plus la proposer. `cont` = complète la phrase en cours.
function contextPhrases(limit) {
  const p = state.overrides.phrases || {}, hidden = state.overrides.phrasesHidden || [];
  const cur = (state.tokens || []).map(t => norm(t.label));
  const now = state.overrides.usageSeq || 0;
  const rows = Object.keys(p).map(k => ({ key: k, toks: p[k].toks, n: p[k].n || 1, t: p[k].t || 0 }))
    .filter(x => Array.isArray(x.toks) && x.toks.length >= 2 && hidden.indexOf(x.key) < 0);
  rows.forEach(x => {
    const seq = x.toks.map(t => norm(t.label));
    x.cont = cur.length > 0 && seq.length > cur.length && cur.every((w, i) => seq[i] === w);
    const freq = Math.min(x.n, 20) * 2;             // fréquence (plafonnée)
    const recent = (now ? Math.max(0, 6 - (now - x.t)) : 0);  // récence : bonus si dit récemment
    x.score = freq + recent + (x.cont ? 40 : 0);    // le contexte (continuation) prime
  });
  const pool = cur.length ? rows.filter(x => x.cont) : rows.filter(x => x.n >= 2);
  return pool.sort((a, b) => (b.score - a.score) || (b.t - a.t)).slice(0, limit || 4);
}
function hidePhrase(key) {
  const h = (state.overrides.phrasesHidden ||= []);
  if (h.indexOf(key) < 0) h.push(key);
  saveOverrides();
}
function itemById(id) {
  if (!state._idx) {
    state._idx = {};
    (state.vocab.columns || []).forEach(c => c.items.forEach(it => state._idx[it.id] = it));
    (state.vocab.functions || []).forEach(f => f.items.forEach(it => state._idx[it.id] = it));
    (state.vocab.themes || []).forEach(f => f.items.forEach(it => state._idx[it.id] = it));
    (state.vocab.packs || []).forEach(f => f.items.forEach(it => state._idx[it.id] = it));
  }
  if (state._idx[id]) return state._idx[id];
  for (const b in (state.overrides.added || {})) {
    const it = (state.overrides.added[b] || []).find(x => x.id === id); if (it) return it;
  }
  return null;
}
// Liens sémantiques mots-déclencheurs -> thème (mot-clé du titre du thème)
const SEMANTIC = [
  { w: ["manger", "mange", "faim", "repas", "nourriture", "cuisine", "gateau", "boire", "bois", "soif"], t: "nourriture" },
  { w: ["jouer", "joue", "jeu", "jouet"], t: "jouet" },
  { w: ["mal", "malade", "corps", "docteur", "bobo", "tete", "main"], t: "corps" },
  { w: ["dehors", "sortir", "parc", "aller", "vais", "promener", "jardin"], t: "exterieur" },
  { w: ["animal", "chien", "chat", "animaux"], t: "animaux" },
  { w: ["mettre", "mets", "habiller", "vetement", "froid", "chaud"], t: "vetement" },
  { w: ["voiture", "rouler", "partir", "transport", "train", "velo"], t: "vehicule" },
  { w: ["maison", "chambre", "meuble", "dormir", "table"], t: "meuble" },
];
// ---- FRINGE ADAPTATIF : mots fréquents associés au VERBE courant ----
// Socle (Personnes/Actions/Questions) TOUJOURS fixe ; seules ces 3 couleurs de colonnes s'adaptent.
const ADAPT_COLORS = new Set(["descriptif", "social", "petit"]);
// Par verbe (infinitif) : mots ASSOCIÉS au verbe, proposés EN TÊTE de chaque colonne adaptable.
// ⚠️ RÈGLE : ne jamais confondre FRÉQUENCE linguistique, UTILITÉ communicationnelle et PRIORITÉ
// d'accès. Ici = hypothèses ÉDITORIALES (`source:"editorial"`), PAS un classement de fréquence ni
// « les mots les plus utiles ». Trois provenances possibles à distinguer (cf. [[phrases-frequence-sourcing]]) :
//   "editorial" (le système suppose) · "corpus" (les données montrent) · "personal" (la personne choisit).
// Chaque suggestion porte sa provenance. Ces mots n'ÉCRASENT rien (les mots d'origine restent, via « Tout voir »).
const VERB_ASSOC = {
  "aller":    { descriptif: ["vite", "doucement", "loin", "ensemble"], social: ["à la maison", "dehors", "au parc", "à l'école", "chez le docteur"], petit: ["à", "au", "chez"] },
  "manger":   { descriptif: ["bon", "pas bon", "chaud", "froid", "encore"], social: ["j'ai faim", "s'il te plaît", "fini"], petit: ["du", "de la", "un peu"] },
  "boire":    { descriptif: ["chaud", "froid", "bon"], social: ["j'ai soif", "encore", "fini"], petit: ["du", "de l'", "un peu"] },
  "vouloir":  { descriptif: ["encore"], social: ["oui", "non", "s'il te plaît", "ça"], petit: ["un", "une", "le", "la"] },
  "pouvoir":  { descriptif: [], social: ["oui", "non", "aide-moi", "s'il te plaît"], petit: [] },
  "faire":    { descriptif: ["bien", "mal", "ensemble"], social: ["quoi", "ça", "comme ça"], petit: [] },
  "jouer":    { descriptif: ["ensemble", "dehors", "encore"], social: ["avec toi", "à quoi"], petit: ["avec", "à", "au"] },
  "dormir":   { descriptif: ["fatigué", "tranquille", "bien"], social: ["bonne nuit", "au lit"], petit: [] },
  "mettre":   { descriptif: ["chaud", "froid", "propre"], social: ["mon manteau", "mes chaussures"], petit: ["le", "la", "mon", "ma"] },
  "regarder": { descriptif: ["beau", "joli", "grand"], social: ["ça", "la télé", "avec toi"], petit: ["le", "la", "ce"] },
  "parler":   { descriptif: ["fort", "doucement"], social: ["avec toi", "à quelqu'un", "de ça"], petit: ["à", "de", "avec"] },
  "être":     { descriptif: ["content", "triste", "fatigué", "malade", "grand", "petit"], social: ["oui", "non", "d'accord"], petit: [] },
  "avoir":    { descriptif: ["froid", "chaud", "faim", "soif", "mal", "peur"], social: ["encore", "besoin"], petit: ["un", "une", "du"] },
  "prendre":  { descriptif: ["chaud", "froid"], social: ["ça", "s'il te plaît"], petit: ["le", "la", "un", "du"] },
  "arrêter":  { descriptif: [], social: ["stop", "s'il te plaît", "maintenant"], petit: [] },
  "aimer":    { descriptif: ["beaucoup", "bien", "trop"], social: ["ça", "toi"], petit: ["le", "la", "les"] },
};
// Verbe qui « gouverne » les colonnes adaptatives = le DERNIER verbe posé dans le message.
function currentVerb() {
  for (let i = state.tokens.length - 1; i >= 0; i--) { const t = state.tokens[i]; if (t.verb) return t.verb; }
  return null;
}
// ÉCLAIRER LES PORTES (pas pousser dans une pièce) : le dernier verbe structurant met en LUMIÈRE
// les catégories existantes liées, pour aider à poursuivre la phrase. Les cases ne bougent pas,
// aucune n'est masquée : les non-éclairées restent en veille (toujours lisibles, toujours touchables).
// L'association verbe→catégories est ÉDITORIALE (hypothèse sémantique plausible), à PERSONNALISER —
// jamais « les plus fréquentes ». Ids = vraies catégories de vocab.default.json (functions/themes).
// cf. mémoire [[phrases-frequence-sourcing]] : fréquence ≠ utilité ≠ priorité.
const VERB_CATEGORIES = {
  aller:      ["th-lieux-batiments", "th-transports", "th-voyages", "th-ecole-travail"],
  partir:     ["th-lieux-batiments", "th-transports", "th-voyages"],
  venir:      ["th-lieux-batiments", "th-transports"],
  sortir:     ["th-lieux-batiments", "th-loisirs", "th-transports"],
  manger:     ["th-aliments-fruits-legumes", "th-aliments-pains-feculents", "th-aliments-viandes-poissons-laitages", "th-aliments-repas-desserts", "th-cuisine-ustensiles-preparer"],
  boire:      ["th-boire", "th-aliments-repas-desserts"],
  cuisiner:   ["th-cuisine-ustensiles-preparer", "th-aliments-fruits-legumes", "th-aliments-viandes-poissons-laitages"],
  mettre:     ["th-vetements", "th-vetements-sport"],
  porter:     ["th-vetements", "th-vetements-sport"],
  regarder:   ["th-ecrans-techno", "th-loisirs", "th-art-creer"],
  voir:       ["th-ecrans-techno", "th-loisirs", "th-lieux-batiments"],
  jouer:      ["th-loisirs", "th-sport", "th-musique"],
  faire:      ["th-loisirs", "th-sport", "th-cuisine-ustensiles-preparer", "th-art-creer"],
  parler:     ["th-gens-relations-descriptions", "th-emotions", "fn-raconter-partager", "th-communiquer"],
  dire:       ["th-communiquer", "th-emotions", "fn-raconter-partager"],
  ecouter:    ["th-musique", "th-communiquer"],
  chanter:    ["th-musique"],
  dormir:     ["th-lieux-batiments", "th-sante-soins-materiel"],
  laver:      ["th-hygiene", "th-corps-parties-du-corps"],
  soigner:    ["th-sante-soins-materiel", "th-corps-parties-du-corps"],
  acheter:    ["th-argent", "th-aliments-fruits-legumes", "th-vetements"],
  travailler: ["th-ecole-travail", "th-outils", "th-informatique"],
  aimer:      ["th-loisirs", "th-emotions", "th-gens-relations-descriptions"]
};
// Applique/retire la lumière sur les tuiles de catégorie selon le verbe courant. Sans effet
// destructif : ne touche qu'aux classes CSS, ne réordonne rien, ne cache rien.
function litRelatedCategories() {
  const tabs = document.getElementById("tabs");
  if (!tabs) return;
  const v = currentVerb();
  const set = new Set((v && VERB_CATEGORIES[v]) || []);
  tabs.querySelectorAll(".tab[data-cat]").forEach(el => el.classList.toggle("tab-lit", set.has(el.dataset.cat)));
  tabs.classList.toggle("has-lit", set.size > 0);
}
// PORTES en tête de « En lien » : raccourcis vers les catégories liées au verbe courant. Toujours
// visibles (pas besoin de faire défiler la barre), la barre principale reste immobile. Toucher une
// porte OUVRE la vraie catégorie (navigation habituelle) — surtout pas une boucle ouvrir/refermer.
function catShortcutTiles() {
  const v = currentVerb();
  const ids = (v && VERB_CATEGORIES[v]) || [];
  const out = [];
  ids.slice(0, 4).forEach(id => { const cat = findBoard(id); if (cat) out.push(catShortcutTile(cat)); });
  return out;
}
function catShortcutTile(cat) {
  const el = document.createElement("button"); el.type = "button";
  el.className = "tile cat ctx-door"; el.dataset.color = cat.color || "nom";
  el.setAttribute("aria-label", "Catégorie liée : " + cat.label + ". Toucher pour l'ouvrir.");
  const sym = catThumb(cat);
  if (sym) { const im = document.createElement("img"); im.src = sym; im.alt = ""; el.appendChild(im); }
  el.appendChild(Object.assign(document.createElement("span"), { className: "tile-label" + labelClass(cat.label), textContent: cat.label }));
  el.appendChild(Object.assign(document.createElement("span"), { className: "ctx-door-arrow", textContent: "›", "aria-hidden": "true" }));
  el.addEventListener("click", () => openBoard(cat.id));
  return el;
}
// PROVENANCE (immuable) vs STATUT (évolutif) — deux dimensions distinctes.
//   origin : d'OÙ vient la suggestion. IMMUABLE. Formes :
//     { type:"editorial" }                                   → le système SUPPOSE (hypothèse).
//     { type:"corpus", corpusId, corpusVersion, subset,      → les DONNÉES montrent. Auditable :
//       extractionDate, method, score }                        recalculable / retirable si la méthode est bancale.
//     { type:"personal", createdBy:"user"|"partner" }        → la PERSONNE (ou un proche) a créé le mot.
//   userStatus : ce que la personne en FAIT. ÉVOLUTIF. "default" | "adopted" | "pinned" | "hidden".
// ⚠️ La personne prime par CHOIX, pas par surveillance : l'usage répété ne réorganise JAMAIS la grille
//    tout seul (accès facile, erreur motrice, partenaire qui oriente… ≠ préférence). Hiérarchie :
//    1) choix explicite (épingler/masquer) > 2) perso décidé avec elle > 3) usage PROPOSÉ à validation
//    > 4) corpus > 5) éditorial. Voir [[phrases-frequence-sourcing]].
function getSuggStatus(verb, label) {
  const m = (state.overrides.suggStatus || {})[verb]; return (m && m[norm(label)]) || "default";
}
function setSuggStatus(verb, label, status) {
  const s = (state.overrides.suggStatus ||= {}); const m = (s[verb] ||= {});
  if (status === "default") { delete m[norm(label)]; if (!Object.keys(m).length) delete s[verb]; }
  else m[norm(label)] = status;
  saveOverrides(); if (state.view === "board") renderBoard();
}
// Fabrique un item affichable (picto résolu depuis la banque) pour un mot associé au verbe.
function assocItem(label, color, verb, origin) {
  const sym = (window.CT && CT.pictoFor) ? CT.pictoFor(label) : null;
  return { id: "assoc-" + color + "-" + norm(label).replace(/\s+/g, "-"), label, color, sym_path: sym || undefined,
    _suggestion: true, _verb: verb, origin: origin || { type: "editorial" }, userStatus: getSuggStatus(verb, label) };
}
// RETIRÉ : la métamorphose des colonnes (Descriptifs/Petits mots/Social → « POUR <verbe> »).
// Elle faisait BOUGER des cases (contre la stabilité spatiale) et doublonnait les « portes ».
// Ce sont désormais les PORTES en tête de « En lien » (catShortcutTiles) qui aident à poursuivre
// la phrase, SANS déplacer aucune case. Les colonnes du socle restent fixes, toujours.
// La banque VERB_ASSOC + assocItem()/provenance reste en réserve pour de futures suggestions
// PERSONNELLES (décidées avec la personne), jamais pour remodeler la grille toute seule.
function adaptColumnItems(color, baseItems) { return baseItems; }
// Menu de la personne sur une suggestion : épingler (toujours proposer) / masquer (ne plus proposer)
// / proposition normale. Choix EXPLICITE — jamais déduit de l'usage. Ouvert depuis le badge ✨/📌.
function openSuggMenu(item, anchor) {
  const old = document.getElementById("sugg-menu"); if (old) old.remove();
  const v = item._verb, cur = item.userStatus || "default";
  const menu = document.createElement("div"); menu.id = "sugg-menu"; menu.className = "sugg-menu";
  menu.setAttribute("role", "menu");
  menu.appendChild(Object.assign(document.createElement("div"), { className: "sugg-menu-h", textContent: "« " + item.label + " » avec « " + v + " »" }));
  const mk = (txt, status) => { const b = document.createElement("button"); b.type = "button"; b.className = "sugg-menu-b" + (cur === status ? " on" : ""); b.textContent = txt + (cur === status ? "  ✓" : ""); b.addEventListener("click", () => { menu.remove(); setSuggStatus(v, item.label, status); }); return b; };
  menu.appendChild(mk("📌 Toujours proposer", "pinned"));
  menu.appendChild(mk("🙈 Ne plus proposer", "hidden"));
  menu.appendChild(mk("↺ Proposition normale", "default"));
  document.body.appendChild(menu);
  const r = anchor.getBoundingClientRect();
  menu.style.left = Math.max(6, Math.min(r.left, window.innerWidth - menu.offsetWidth - 6)) + "px";
  menu.style.top = Math.min(r.bottom + 4, window.innerHeight - menu.offsetHeight - 6) + "px";
  const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener("click", close, true); } };
  setTimeout(() => document.addEventListener("click", close, true), 0);
}
function findTheme(kw) { return (state.vocab.themes || []).find(t => norm(t.label).includes(norm(kw))); }
function themeForTrigger(tok) {
  const lab = norm(tok.label) + " " + norm(tok.verb || "");
  for (const s of SEMANTIC) if (s.w.some(x => lab.includes(norm(x)))) { const th = findTheme(s.t); if (th) return th; }
  return null;
}
function themeContaining(id) { return (state.vocab.themes || []).find(t => (t.items || []).some(it => it.id === id)); }
// Résout un id de bigramme vers son LABEL normalisé, quelle que soit son origine (socle c-, pack pm-,
// association assoc-, promenade anc-…) → permet d'agréger les enchaînements PAR MOT, pas par emplacement.
function _bigramSrcLabel(id) {
  const it = itemById(id); if (it && it.label) return norm(it.label);
  let m = id.match(/^(?:assoc|anc)-[^-]+-(.+)$/); if (m) return norm(m[1]);
  m = id.match(/^(?:c|p|pm|vb|d|w|f|adv|mine|t|s|sc)-(.+)$/); if (m) return norm(m[1]);
  return norm(id);
}
function computeContext() {
  // Baker : en « Positions stables », la zone « En lien » NE change pas selon la
  // sélection -> même geste, même endroit. On sert un jeu fixe d'amorces.
  if (state.settings.stablePositions) {
    return ((state.vocab.functions || [])[0]?.items || []).filter(i => !isHidden(i.id)).slice(0, 14);
  }
  const last = state.tokens.filter(t => t.id && !t.phrase).slice(-1)[0];
  const scored = new Map();
  const add = (id, w) => { if (id) scored.set(id, (scored.get(id) || 0) + w); };
  if (last) {
    // Enchaînements appris (perso), AGRÉGÉS PAR MOT : on somme les bigrammes de TOUTES les variantes
    // d'id du dernier mot (même label) → « le mot d'après » habituel est proposé même si le mot a été
    // tapé ailleurs (socle, catégorie, promenade). Prédiction plus juste = moins de clics, aucun ajouté.
    // (N'agit que si « Positions stables » est OFF — sinon on a renvoyé le jeu fixe plus haut.)
    const lastLab = norm(labelFor(last));
    const bgAll = state.overrides.bigrams || {};
    const merged = {};
    Object.keys(bgAll).forEach(srcId => {
      if (_bigramSrcLabel(srcId) !== lastLab) return;
      const m = bgAll[srcId]; Object.keys(m).forEach(nid => { merged[nid] = (merged[nid] || 0) + m[nid]; });
    });
    Object.keys(merged).forEach(id => add(id, 3 * merged[id]));
    const th = themeForTrigger(last);                                // lien sémantique
    if (th) th.items.forEach(it => add(it.id, 1));
    const parent = themeContaining(last.id);                         // même thème que le dernier mot
    if (parent) parent.items.forEach(it => add(it.id, 1.5));
  }
  let arr = [...scored.entries()].map(([id, w]) => ({ it: itemById(id), w: w + usageScore(id) * 0.5 })).filter(x => x.it);
  if (arr.length < 14) {                                             // complément : amorces (Demander)
    const starters = (state.vocab.functions || [])[0]?.items || [];
    starters.forEach(it => { if (!scored.has(it.id)) arr.push({ it, w: 0.1 + usageScore(it.id) }); });
  }
  arr = arr.filter(x => !last || x.it.id !== last.id);
  arr.sort((a, b) => b.w - a.w);
  const seen = new Set(), out = [];
  for (const x of arr) { if (seen.has(x.it.id)) continue; seen.add(x.it.id); out.push(x.it); if (out.length >= 14) break; }
  return out;
}
// Mots ASSOCIÉS (éditoriaux) au verbe courant → remontés dans « En lien » pour aider à continuer la
// phrase (aller → à la maison/au parc/vite… ; manger → bon/chaud/j'ai faim…). Proposés, JAMAIS imposés,
// JAMAIS « les plus fréquents » (associations plausibles, pas une fréquence prouvée). Épinglés en tête,
// masqués retirés — choix explicite via ✨, pas de surveillance. Le SOCLE ne bouge pas ; seule « En lien » s'adapte.
function assocContextItems() {
  const v = currentVerb();
  if (!v || typeof VERB_ASSOC === "undefined" || !VERB_ASSOC[v]) return [];
  const pinned = [], normal = [], seen = new Set();
  ["social", "descriptif", "petit"].forEach(color => {
    (VERB_ASSOC[v][color] || []).forEach(lab => {
      const k = norm(lab); if (seen.has(k)) return; seen.add(k);
      const st = getSuggStatus(v, lab);
      if (st === "hidden") return;
      (st === "pinned" ? pinned : normal).push(assocItem(lab, color, v));
    });
  });
  return pinned.concat(normal);
}
// Sous-ensemble d'une liste (colonne « Tout voir ») EN LIEN avec le début de phrase courant.
// Additif et honnête : sert UNIQUEMENT à surligner ces mots en tête du pop-up, jamais à
// réordonner ni retirer la liste (l'ordre reste prévisible). Associations éditoriales +
// enchaînements du contexte, JAMAIS « les plus fréquents » (aucune fréquence de corpus prouvée).
function contextRelevantItems(items) {
  if (!items || !items.length) return [];
  const set = new Set();
  try { assocContextItems().forEach(it => set.add(norm(labelFor(it)))); } catch (e) {}
  try { computeContext().forEach(it => set.add(norm(labelFor(it)))); } catch (e) {}
  if (!set.size) return [];
  const out = [], seen = new Set();
  items.forEach(it => { const k = norm(labelFor(it)); if (set.has(k) && !seen.has(k)) { seen.add(k); out.push(it); } });
  return out;
}
function fillContext() {
  const zone = document.getElementById("context-zone"); if (!zone) return;
  zone.innerHTML = "";
  let title = "En lien", color = "nom", items, isCat = false;
  if (state.board !== "home") {
    const f = findBoard(state.board);
    if (f) { isCat = true; title = f.label; color = f.color; items = [...(f.items || []).filter(i => !isHidden(i.id)), ...(state.overrides.added[state.board] || [])]; }
  }
  if (!items) items = computeContext();
  if (state.board === "home") {
    // ACCUEIL : 1 colonne « En lien ». En TÊTE, les PORTES = raccourcis vers les catégories
    // liées au verbe courant (toujours visibles, la barre principale ne bouge pas) ; puis les
    // mots liés. Ouvrir une porte = navigation normale, pas un aller-retour en boucle.
    const doors = catShortcutTiles();
    const assoc = assocContextItems();   // mots associés au verbe (éditoriaux, ✨ épingler/masquer)
    const phrases = contextPhrases(4);   // ADAPTATIF : habituelles si vide, sinon celles qui CONTINUENT la phrase
    const col = vcolEl((doors.length || assoc.length || phrases.length) ? "Pour continuer" : title, color, [], true);
    const g = col.querySelector(".vgrid");
    const CAP = 16, used = new Set();
    let n = 0;
    // RACCOURCIS : phrases répétées, en un tap (insère tous les mots). Un ✕ pour ne plus la proposer.
    phrases.forEach(p => {
      const chip = document.createElement("button");
      chip.type = "button"; chip.className = "tile phrase-chip";
      let disp; try { disp = Sentence.build(p.toks, _sentenceCtx()); } catch (e) { disp = p.toks.map(t => t.label).join(" "); }
      const ic = document.createElement("span"); ic.className = "ct-ic phrase-ic"; ic.innerHTML = (typeof CT_ICONS !== "undefined" ? CT_ICONS.bubble : "💬");
      const lab = document.createElement("span"); lab.className = "tile-label"; lab.textContent = disp;
      chip.append(ic, lab); chip.setAttribute("aria-label", (p.cont ? "Compléter : " : "Phrase enregistrée : ") + disp);
      if (p.cont) chip.classList.add("phrase-cont");
      chip.addEventListener("click", () => {
        // continuation : n'ajoute QUE la suite (complète la phrase en cours) ; sinon : ajoute tout.
        const start = p.cont ? state.tokens.length : 0;
        p.toks.slice(start).forEach(t => addToken(Object.assign({}, t)));
      });
      const x = document.createElement("span"); x.className = "phrase-hide"; x.textContent = "✕"; x.setAttribute("role", "button"); x.title = "Ne plus proposer cette phrase";
      x.addEventListener("click", (e) => { e.stopPropagation(); hidePhrase(p.key); fillContext(); });
      chip.appendChild(x); g.appendChild(chip); n++;
    });
    doors.forEach(d => g.appendChild(d));
    n += doors.length;
    assoc.forEach(it => { if (n < CAP) { g.appendChild(tileEl(it)); used.add(norm(labelFor(it))); n++; } });
    items.forEach(it => { const k = norm(labelFor(it)); if (n < CAP && !used.has(k)) { g.appendChild(tileEl(it)); used.add(k); n++; } });
    zone.appendChild(col);
    renderCtxPager(null);
    return;
  }
  // CATÉGORIE = 11 mots + une case « + » (12e) qui ouvre un POP-UP avec TOUT le reste.
  // Fini le défilement/pagination : la grille reste légère et harmonisée partout.
  const CAP = 11, total = items.length;
  const shown = items.slice(0, CAP);
  const ncols = (shown.length + 1) > 6 ? 2 : 1;
  const perCol = Math.max(1, Math.ceil((shown.length + 1) / ncols));
  const cols = chunk(shown, perCol);
  if (!cols.length) cols.push([]);
  cols.forEach((part, idx) => zone.appendChild(vcolEl(idx === 0 ? title : "", color, part, true)));
  const lastGrid = zone.lastChild && zone.lastChild.querySelector(".vgrid");
  if (lastGrid) { const bid = state.board; lastGrid.appendChild(makePlusTile(total, items.slice(CAP, CAP + 3), () => { if (window.CT && CT.openPopup) CT.openPopup(bid); })); }
  renderCtxPager(null);
}
// Case « Tout voir » : signifiant EXPLICITE — aperçu de vrais mini-pictos de ce qui est caché
// + compteur « +N » + le mot. `onOpen` ouvre le pop-up (catégorie OU colonne de l'accueil).
function makePlusTile(total, preview, onOpen) {
  const el = document.createElement("button");
  el.className = "tile tile-more"; el.setAttribute("type", "button");
  const rest = Math.max(0, total - 11);
  el.setAttribute("aria-label", rest ? ("Voir tous les mots — " + rest + " de plus") : "Voir tous les mots");
  const peek = document.createElement("span"); peek.className = "more-peek"; peek.setAttribute("aria-hidden", "true");
  (preview || []).forEach(it => { const sp = (typeof pictoFor === "function" ? pictoFor(it) : it.sym_path); if (sp) { const im = document.createElement("img"); im.src = sp; im.alt = ""; peek.appendChild(im); } });
  if (rest > 0) { const b = document.createElement("span"); b.className = "more-count"; b.textContent = "+" + rest; peek.appendChild(b); }
  else { const g = document.createElement("span"); g.className = "more-grid";
    g.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false"><g fill="currentColor"><rect x="3" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.6"/></g></svg>';
    peek.appendChild(g); }
  el.append(peek, Object.assign(document.createElement("span"), { className: "tile-label", textContent: "Tout voir" }));
  el.addEventListener("click", () => { if (typeof onOpen === "function") onOpen(); });
  return el;
}
// Pager flottant (bas-centre) : pages d'une catégorie, sans jamais défiler.
function renderCtxPager(info) {
  let p = document.getElementById("ctx-pager");
  if (!info || info.pages <= 1) { if (p) p.hidden = true; return; }
  if (!p) { p = document.createElement("div"); p.id = "ctx-pager"; p.className = "ctx-pager";
    p.style.cssText = "position:fixed;left:50%;transform:translateX(-50%);bottom:70px;z-index:25;background:var(--surface);border:2px solid var(--line);border-radius:999px;box-shadow:0 4px 12px rgba(0,0,0,.18);padding:4px 8px";
    document.body.appendChild(p); }
  p.hidden = false; p.innerHTML = "";
  const prev = document.createElement("button"); prev.textContent = "‹"; prev.setAttribute("aria-label", "Page précédente");
  prev.disabled = info.page <= 0; prev.addEventListener("click", () => { state.ctxPage = info.page - 1; fillContext(); });
  const dots = document.createElement("div"); dots.className = "dots";
  for (let i = 0; i < info.pages; i++) { const d = document.createElement("button"); d.className = "dot" + (i === info.page ? " on" : "");
    d.setAttribute("aria-label", "Page " + (i + 1)); d.addEventListener("click", () => { state.ctxPage = i; fillContext(); }); dots.appendChild(d); }
  const lab = document.createElement("span"); lab.className = "pg-label"; lab.textContent = "Page " + (info.page + 1) + "/" + info.pages;
  const next = document.createElement("button"); next.textContent = "›"; next.setAttribute("aria-label", "Page suivante");
  next.disabled = info.page >= info.pages - 1; next.addEventListener("click", () => { state.ctxPage = info.page + 1; fillContext(); });
  p.append(prev, dots, lab, next);
}

/* ---------- Usage (fréquence + récence) pour la spirale — tout local ---------- */
function recordUsage(id) {
  const u = (state.overrides.usage ||= {});
  const seq = (state.overrides.usageSeq = (state.overrides.usageSeq || 0) + 1);
  const e = u[id] || { n: 0, t: 0 };
  e.n++; e.t = seq; u[id] = e;
  saveOverrides();
}
function isSpiralView() {
  if (state.settings.stablePositions) return false;   // Baker : la spirale bouge -> interdite en positions stables
  return state.view === "board" &&
    (state.board === "__freq" || (state.settings.spiral === "main" && state.board === "home"));
}

/* ---------- Barre de ton (humeur de la voix) ---------- */
function renderToneBar() {
  const bar = document.getElementById("tonebar"); if (!bar) return;
  bar.innerHTML = "";
  const lab = document.createElement("span"); lab.className = "grammarbar-label";
  lab.innerHTML = `Ton <span class="ct-ic ct-ic-inline">${(typeof MOOD_ICONS !== "undefined" ? MOOD_ICONS.joyeux : "")}</span> :`;
  bar.appendChild(lab);
  Object.keys(MOODS).forEach(k => {
    const m = MOODS[k];
    const b = document.createElement("button");
    b.className = "tone-btn" + (state.settings.mood === k ? " is-active" : "");
    b.dataset.mood = k;
    b.setAttribute("aria-pressed", String(state.settings.mood === k));
    b.title = m.label;
    const ic = (typeof MOOD_ICONS !== "undefined" && MOOD_ICONS[k]) ? `<span class="ct-ic ct-ic-face" aria-hidden="true">${MOOD_ICONS[k]}</span>` : `<span aria-hidden="true">${m.emo}</span>`;
    b.innerHTML = `${ic} ${m.label}`;
    b.addEventListener("click", () => setMood(k, true));
    bar.appendChild(b);
  });
}
function setMood(k, preview) {
  if (!MOODS[k]) return;
  state.settings.mood = k; saveSettings();
  document.querySelectorAll(".tone-btn").forEach(x => {
    const on = x.dataset.mood === k; x.classList.toggle("is-active", on); x.setAttribute("aria-pressed", String(on));
  });
  if (preview) speakOut(state.tokens.length ? sentence() : MOODS[k].label);
}

/* ---------- Rendu de la grille ---------- */
// Taille de label selon le mot le plus long (évite toute coupure de mot).
function labelClass(txt) {
  // longueur du plus long segment SANS espace (un composé à trait d'union comme
  // « peut-être » compte pour sa longueur totale → il rétrécit assez pour tenir).
  const longest = Math.max(0, ...String(txt).split(/\s+/).map(w => w.length));
  return longest >= 12 ? " lbl-xxs" : longest >= 9 ? " lbl-xs" : longest >= 7 ? " lbl-sm" : "";
}
window.labelClass = labelClass;
// Pictos réutilisés pour DES MOTS DIFFÉRENTS (mémoïsé) -> à différencier visuellement.
function dupPaths() {
  if (state._dup) return state._dup;
  const labs = {};
  const add = it => { if (!it || !it.sym_path) return; (labs[it.sym_path] = labs[it.sym_path] || new Set()).add((it.label || "").toLowerCase()); };
  const v = state.vocab || {};
  (v.columns || []).forEach(c => (c.items || []).forEach(add));
  ["functions", "themes", "packs"].forEach(g => (v[g] || []).forEach(f => (f.items || []).forEach(add)));
  const s = new Set();
  Object.keys(labs).forEach(p => { if (labs[p].size >= 2) s.add(p); });
  return (state._dup = s);
}
// Couleur STABLE dérivée du mot (même mot -> toujours la même pastille).
function twinHue(label) {
  let h = 0; const s = (label || "").toLowerCase();
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return `hsl(${h % 360} 70% 42%)`;
}
// CONTINGENCE (profil tout-petit) : « quand je touche, il se passe quelque chose, et c'est MOI ».
// Brève pulsation visuelle au toucher — s'ajoute au retour vocal (speakOnTap). Uniquement en mode
// tout-petit, pour ne pas alourdir l'usage courant. Purement visuel, réversible, sans état.
function tapPulse(el) {
  if (!el || !document.body.classList.contains("tout-petit")) return;
  el.classList.remove("tap-pulse"); void el.offsetWidth; el.classList.add("tap-pulse");
  setTimeout(() => { if (el) el.classList.remove("tap-pulse"); }, 480);
}
function tileEl(item, opts = {}) {
  const el = document.createElement("button");
  el.className = "tile" + (opts.cat ? " cat" : "") + (opts.back ? " back" : "");
  el.dataset.color = colorFor(item) || item.color || "petit";
  el.setAttribute("aria-label", opts.back ? "Retour" : (opts.cat ? "Catégorie " + labelFor(item) : labelFor(item)));
  el.setAttribute("type", "button");
  const _sym = pictoFor(item);
  if (_sym) {
    const im = document.createElement("img"); im.src = _sym; im.alt = ""; el.appendChild(im); applySkin(im, item);
  } else if (!opts.back && !opts.cat) el.classList.add("word-tile");   // mot seul, en grand
  const txt = opts.back ? "‹ retour" : labelFor(item);
  const lab = document.createElement("span"); lab.className = "tile-label" + labelClass(txt); lab.textContent = txt;
  el.appendChild(lab);
  if (!opts.back && !opts.cat) {                         // 2e langue (bilinguisme) sous le mot
    const tr = translationFor(item);
    if (tr) { const t2 = document.createElement("span"); t2.className = "tile-tr"; t2.textContent = tr; el.appendChild(t2); }
  }
  const badge = document.createElement("span"); badge.className = "edit-badge ct-ic ct-ic-badge"; badge.innerHTML = (typeof CT_ICONS !== "undefined" ? CT_ICONS.pencil : "✎"); el.appendChild(badge);
  let root = el;   // élément inséré dans la grille : la case (bouton) par défaut
  if (item._suggestion) {   // mot proposé lié au verbe (emplacement « suggestion » explicite)
    el.classList.add("tile-suggestion");
    if (item.userStatus === "pinned") el.classList.add("tile-pinned");
    el.setAttribute("aria-label", labelFor(item) + " (proposé avec le verbe « " + (item._verb || "") + " »)");
    // Badge d'options (épingler / masquer) : bouton distinct, JAMAIS enfant de la case.
    // Bouton-dans-bouton = HTML invalide + piège lecteur d'écran (WCAG 4.1.2). On enveloppe
    // donc la case + le badge dans un conteneur positionné : le badge est un SIBLING du bouton,
    // en overlay dans le coin (target ≥ 24×24, WCAG 2.5.8).
    const wrap = document.createElement("div"); wrap.className = "sugg-wrap";
    const sb = document.createElement("button"); sb.type = "button"; sb.className = "sugg-badge ct-ic ct-ic-badge";
    sb.innerHTML = (typeof CT_ICONS !== "undefined" ? (item.userStatus === "pinned" ? CT_ICONS.pin : CT_ICONS.sparkle) : (item.userStatus === "pinned" ? "📌" : "✨"));
    sb.setAttribute("aria-label", "Options de la suggestion « " + labelFor(item) + " » : épingler ou masquer");
    sb.addEventListener("click", (ev) => { ev.stopPropagation(); openSuggMenu(item, sb); });
    wrap.appendChild(el); wrap.appendChild(sb);
    root = wrap;
  }
  if (window.CT) CT.decorateTile(el, item, opts);
  el.addEventListener("click", () => {
    if (state.editing && !opts.back) return editItem(item, opts);
    if (opts.back) return openBoard("home");
    if (opts.cat) return (state.settings.popup && window.CT) ? CT.openPopup(item.id) : openBoard(item.id);
    tapPulse(el);
    addToken(item);
  });
  return root;
}
// Messages essentiels TOUJOURS accessibles (refus / protection / navigation), épinglés en haut
// de la grille, jamais masqués — quelle que soit l'étape de la phrase ou la catégorie ouverte.
const SAFETY = [
  { id: "safe-oui", label: "oui", color: "social" },
  { id: "safe-non", label: "non", color: "social" },
  { id: "safe-stop", label: "stop", color: "social" },
  { id: "safe-aide", label: "aide", say: "j'ai besoin d'aide", color: "social" },
  { id: "safe-mal", label: "j'ai mal", phrase: true },
  { id: "safe-partir", label: "je veux partir", phrase: true },
];
// Bandeau de sécurité : SIBLING de #board (au-dessus des colonnes), pleine largeur, hauteur fixe.
// (Le board est un flex nowrap : y insérer une pleine largeur casserait la mise en page.)
function renderSafetyStrip() {
  const board = document.getElementById("board"); if (!board || !board.parentNode) return;
  let strip = document.getElementById("safety-strip");
  if (strip) strip.remove();
  strip = document.createElement("div"); strip.id = "safety-strip"; strip.className = "safety-strip";
  strip.setAttribute("role", "group"); strip.setAttribute("aria-label", "Messages essentiels, toujours accessibles");
  SAFETY.forEach(it => {
    const b = document.createElement("button"); b.type = "button"; b.className = "safety-btn";
    const sym = it.sym_path || (window.CT && CT.pictoFor ? CT.pictoFor(it.label) : null);
    if (sym) { const im = document.createElement("img"); im.src = sym; im.alt = ""; b.appendChild(im); }
    b.appendChild(Object.assign(document.createElement("span"), { textContent: it.label }));
    b.setAttribute("aria-label", "Dire : " + (it.say || it.label));
    b.addEventListener("click", () => { tapPulse(b); addToken(it); if (!state.settings.speakOnTap) speakOut(it.say || it.label); });
    strip.appendChild(b);
  });
  board.parentNode.insertBefore(strip, board);
}
function renderBoard() {
  const board = document.getElementById("board");
  board.style.setProperty("--cols", state.settings.cols);
  board.innerHTML = "";
  board.classList.toggle("spiral", isSpiralView());
  // Vue spirale (Fréquents, ou accueil adaptatif) — jamais en positions stables
  if (window.CT && isSpiralView()) {
    board.classList.remove("columns");
    CT.renderSpiralBoard(board, state.board === "__freq" ? "freq" : "home");
    return;
  }
  // MODE ANCRES (compaction sémantique, optionnel) : tableau d'icônes-ancres.
  if (state.board === "__ancres" && window.CT) {
    board.classList.remove("columns", "is-home"); board.classList.add("anchors");
    CT.renderAnchors(board);
    return;
  }
  board.classList.remove("anchors");
  // SOCLE FIXE : le vocabulaire de base (pronoms, verbes…) est TOUJOURS aux mêmes
  // emplacements, sur toutes les pages -> automatisation des trajets moteurs.
  board.classList.add("columns");
  // Accueil = mise en page « plein écran » (aucun scroll, aucun vide) ; les
  // pages catégorie gardent le flux enveloppant classique.
  board.classList.toggle("is-home", state.board === "home");
  renderSafetyStrip();   // bandeau épinglé au-dessus des colonnes — jamais masqué
  const COLCAP = 13;   // colonnes de l'accueil : 13 mots + « Tout voir » (UNIFORME sur toutes les colonnes)
  const reorderMode = state.board === "home" && state.view === "edit";
  const buildCol = (title, color, all, reorderKey) => {
    const shown = all.length > COLCAP ? all.slice(0, COLCAP) : all;
    const vc = vcolEl(title, color, shown);
    if (all.length > COLCAP) {
      const g = vc.querySelector(".vgrid");
      if (g) g.appendChild(makePlusTile(all.length, all.slice(COLCAP, COLCAP + 3), () => { if (window.CT && CT.openList) CT.openList(title, color, all); }));
    }
    if (reorderMode && reorderKey) addColumnReorder(vc, reorderKey);
    board.appendChild(vc);
  };
  const cols = orderedColumns();
  const _verb = currentVerb();
  cols.forEach(col => {
    const items = adaptColumnItems(col.color, col.items.filter(i => !isHidden(i.id)));
    const adapting = state.settings.verbAdapt && _verb && ADAPT_COLORS.has(col.color) && items.some(i => i._suggestion);
    // Titre d'affichage : renommage sémantique de la colonne rose + titre dynamique quand elle enrichit un verbe.
    let title = col.color === "social" ? "Social · contexte" : col.title;
    if (adapting) title = "Pour « " + _verb + " »";
    buildCol(title, col.color, items, col.title);   // reorderKey reste col.title (stable)
  });
  if (reorderMode) jitTip("cols-reorder", "Range les colonnes comme tu veux : flèches ‹ › (les plus sûres) ou glisse une colonne entière. L'ordre est gardé.", "⇄");
  const homeAdded = (state.overrides.added["home"] || []).filter(i => !isHidden(i.id));
  if (homeAdded.length) buildCol("Mes mots", "nom", homeAdded);

  // ZONE CONTEXTUELLE (position fixe) : catégorie active OU vocabulaire prédit
  // lié aux sélections précédentes (Baker). Toujours au même endroit, page en page.
  const zone = document.createElement("div"); zone.id = "context-zone"; zone.className = "context-zone";
  board.appendChild(zone);
  fillContext();
  state._adaptVerb = currentVerb();   // mémorise le verbe qui gouverne les colonnes adaptatives
  // Fringe adaptatif : « chauffer » la banque de pictos une fois pour que les mots associés
  // (loin, vite, au…) portent leur picto Mulberry plutôt qu'un mot seul, puis re-rendre une fois.
  if (state.settings.verbAdapt && !state._symWarmedForAdapt && window.CT && CT.warmSym) {
    state._symWarmedForAdapt = true;
    CT.warmSym().then(() => { if (state.view === "board" && !isSpiralView()) renderBoard(); }).catch(() => {});
  }
  if (typeof findState !== "undefined" && findState) applyFindHint();   // jeu Chercher : re-poser halo/estompe après reconstruction des cases
}
// Après un changement de message : re-rendre les colonnes SI le verbe gouvernant a changé
// (fringe adaptatif), sinon simplement rafraîchir la zone « En lien » (socle stable, pas de churn).
function refreshBoardContext() {
  if (state.view !== "board") return;
  if (isSpiralView()) { renderBoard(); litRelatedCategories(); return; }
  // Le socle ne bouge plus au changement de verbe : on rafraîchit juste « En lien » (portes + mots
  // liés) et la lumière des catégories. Pas de reconstruction de la grille = stabilité spatiale.
  fillContext();
  litRelatedCategories();
}
// Un « bloc » = en-tête + grille de tuiles sur 2 colonnes (limite le défilement).
// Ordre des colonnes de l'accueil selon le choix de la personne (par titre).
// Les colonnes non listées gardent leur ordre d'origine, à la suite.
function orderedColumns() {
  const base = (state.vocab.columns || []);
  const order = state.settings.columnOrder || [];
  if (!order.length) return base;
  const byTitle = new Map(base.map(c => [c.title, c]));
  const out = [];
  order.forEach(t => { if (byTitle.has(t)) { out.push(byTitle.get(t)); byTitle.delete(t); } });
  base.forEach(c => { if (byTitle.has(c.title)) out.push(c); });   // nouveautés à la suite
  return out;
}
// Écrit l'ordre courant (titres) dans les réglages à partir de l'ordre affiché.
function currentColumnTitles() { return orderedColumns().map(c => c.title); }
// Annonce discrète pour lecteurs d'écran (aria-live).
function announce(msg) {
  let r = document.getElementById("a11y-live");
  if (!r) { r = document.createElement("div"); r.id = "a11y-live"; r.className = "visually-hidden"; r.setAttribute("aria-live", "polite"); r.setAttribute("role", "status"); document.body.appendChild(r); }
  r.textContent = ""; setTimeout(() => { r.textContent = msg; }, 30);   // re-annonce même si texte identique
}
function moveColumn(title, dir) {
  const titles = currentColumnTitles();
  const i = titles.indexOf(title); if (i < 0) return;
  const j = i + dir; if (j < 0 || j >= titles.length) return;
  titles.splice(j, 0, titles.splice(i, 1)[0]);
  state.settings.columnOrder = titles; saveSettings(); renderBoard();
  announce("Colonne « " + title + " » déplacée en position " + (j + 1) + " sur " + titles.length + ".");
}
function dropColumnBefore(dragTitle, targetTitle) {
  if (dragTitle === targetTitle) return;
  const titles = currentColumnTitles();
  const from = titles.indexOf(dragTitle); if (from < 0) return;
  titles.splice(from, 1);
  const to = titles.indexOf(targetTitle);
  const pos = to < 0 ? titles.length : to;
  titles.splice(pos, 0, dragTitle);
  state.settings.columnOrder = titles; saveSettings(); renderBoard();
  announce("Colonne « " + dragTitle + " » déplacée en position " + (pos + 1) + " sur " + titles.length + ".");
}
// Ajoute à une colonne (mode « Modifier » sur l'accueil) une poignée : flèches
// ‹ › (fiables, dyspraxie) + glisser-déposer de la colonne entière. [[tdah-dyspraxie-conception]]
let _colDrag = null;
function addColumnReorder(vc, title) {
  vc.classList.add("vcol-reorderable");
  vc.setAttribute("draggable", "true");
  vc.dataset.colTitle = title;
  const bar = document.createElement("div"); bar.className = "vcol-reorder";
  const mk = (txt, lab, dir) => {
    const b = document.createElement("button"); b.type = "button"; b.className = "vcol-move";
    b.textContent = txt; b.title = b.ariaLabel = lab; b.setAttribute("aria-label", lab);
    b.addEventListener("click", (e) => { e.stopPropagation(); moveColumn(title, dir); });
    return b;
  };
  bar.append(mk("‹", "Déplacer la colonne vers la gauche", -1),
             Object.assign(document.createElement("span"), { className: "vcol-grip", textContent: "⣿", title: "Glisser pour déplacer" }),
             mk("›", "Déplacer la colonne vers la droite", +1));
  vc.insertBefore(bar, vc.firstChild);
  vc.addEventListener("dragstart", (e) => { _colDrag = title; vc.classList.add("col-dragging"); try { e.dataTransfer.setData("text/plain", title); e.dataTransfer.effectAllowed = "move"; } catch {} });
  vc.addEventListener("dragend", () => { _colDrag = null; document.querySelectorAll(".col-dragging,.col-drop-target").forEach(n => n.classList.remove("col-dragging", "col-drop-target")); });
  vc.addEventListener("dragover", (e) => { if (_colDrag && _colDrag !== title) { e.preventDefault(); vc.classList.add("col-drop-target"); } });
  vc.addEventListener("dragleave", () => vc.classList.remove("col-drop-target"));
  vc.addEventListener("drop", (e) => { e.preventDefault(); const from = _colDrag || (e.dataTransfer && e.dataTransfer.getData("text/plain")); if (from) dropColumnBefore(from, title); });
}
function vcolEl(title, color, items, fringe) {
  const c = document.createElement("div"); c.className = "vcol" + (fringe ? " vcol-fringe" : "");
  const h = document.createElement("div"); h.className = "vcol-head"; h.textContent = title || "";
  h.dataset.color = color; if (!title) h.style.visibility = "hidden"; c.appendChild(h);
  const g = document.createElement("div"); g.className = "vgrid";
  items.forEach(it => g.appendChild(tileEl(it))); c.appendChild(g);
  return c;
}
function chunk(arr, n) { const out = []; for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); return out.length ? out : [[]]; }
function openBoard(id) {
  if (id === "home") state.catGroup = null;   // retour à l'accueil = on referme le dossier ouvert (menu = liste des dossiers)
  if (id === "__freq" && state.settings.stablePositions) id = "home";   // Fréquents = spirale, désactivé en positions stables
  // Menu BASCULABLE : re-cliquer sur la catégorie déjà ouverte = on ressort vers l'accueil.
  if (id !== "home" && id === state.board && state.view === "board") id = "home";
  if (id !== "__ancres") state.activeAnchor = null;                     // on quitte le mode ancres
  if (state.settings.popup && window.CT && id !== "home" && id !== "__freq" && id !== "__ancres" && findBoard(id)) {
    return CT.openPopup(id);
  }
  if (id !== state.board) state.ctxPage = 0;   // nouvelle catégorie = page 1
  // FAVORIS AUTOMATIQUES : on compte les catégories ouvertes -> les plus utilisées remontent en tête
  // du menu, toutes seules, SANS badge ni geste (bruit visuel en moins). Vraies catégories seulement.
  if (id !== "home" && !String(id).startsWith("__") && (state.vocab.functions || state.vocab.themes) && findBoard(id)) {
    const cu = (state.overrides.catUsage ||= {}); cu[id] = (cu[id] || 0) + 1; saveOverrides();
  }
  state.board = id; renderTabs(); renderBoard(); renderBreadcrumb();
  syncPromenadeBtn();
  if (id !== "home" && id !== "__ancres" && id !== "__freq")
    jitTip("cat-nav", "Re-touche la catégorie pour revenir à l'accueil. Les flèches ‹ › en bas changent de page.", "🗂️");
}
// Reflète l'état actif du bouton « Promenade » de la barre du haut.
function syncPromenadeBtn() {
  const b = document.getElementById("promenade-toggle"); if (!b) return;
  const on = state.board === "__ancres" && state.view === "board";
  b.classList.toggle("is-on", on);
  b.setAttribute("aria-pressed", String(on));
}
// Fil d'Ariane : Accueil › Catégorie (repérage ; l'Accueil est cliquable pour revenir).
function renderBreadcrumb() {
  const bc = document.getElementById("breadcrumb"); if (!bc) return;
  // Sur la vue tableau, le fil d'Ariane est TOUJOURS présent (Accueil, ou Accueil › Catégorie),
  // pour que sa place soit réservée : la grille ne bouge pas quand on entre/sort d'une catégorie.
  if (state.view !== "board") { bc.hidden = true; bc.innerHTML = ""; return; }
  const cat = (state.board !== "home") ? findBoard(state.board) : null;
  bc.hidden = false; bc.innerHTML = "";
  const home = document.createElement("button"); home.className = "crumb" + (cat ? "" : " here"); home.innerHTML = (typeof CT_ICONS !== "undefined" ? `<span class="ct-ic ct-ic-inline">${CT_ICONS.home}</span> Accueil` : "🏠 Accueil");
  home.addEventListener("click", () => openBoard("home"));
  bc.append(home);
  if (cat) {
    const sep = document.createElement("span"); sep.className = "sep"; sep.textContent = "›";
    const here = document.createElement("span"); here.className = "crumb here"; here.textContent = cat.label || "Catégorie";
    bc.append(sep, here);
  }
}

// GUIDANCE (repérage) : où vit un mot ? (recherche du chemin) + on va le SURLIGNER sur place.
function locateWord(label) {
  const n = norm(label);
  const inList = (arr) => (arr || []).some(it => !isHidden(it.id) && norm(labelFor(it)) === n);
  for (const f of (state.vocab.functions || [])) if (inList(f.items)) return { boardId: f.id, title: f.label };
  for (const f of (state.vocab.themes || [])) if (inList(f.items)) return { boardId: f.id, title: f.label };
  const packs = (typeof enabledPacks === "function") ? enabledPacks() : (state.vocab.packs || []);
  for (const f of packs) if (inList(f.items)) return { boardId: f.id, title: f.label };
  for (const b in (state.overrides.added || {})) if (inList(state.overrides.added[b])) return { boardId: b, title: (findBoard(b) || {}).label || "Accueil" };
  for (const c of (state.vocab.columns || [])) if (inList(c.items)) return { boardId: "home", title: "Accueil" };
  return null;
}
function spotlightWord(boardId, label) {
  const n = norm(label);
  let pg = 0;
  if (boardId !== "home") {
    const f = findBoard(boardId);
    if (f) {
      const items = [...(f.items || []).filter(i => !isHidden(i.id)), ...(state.overrides.added[boardId] || [])];
      const idx = items.findIndex(it => norm(labelFor(it)) === n);
      if (idx >= 0) { const ps = Math.max(24, state.settings.pageSize || 84); pg = Math.floor(idx / ps); }
    }
  }
  state.activeAnchor = null;
  if (state.view !== "board") setView("board");
  state.board = boardId; state.ctxPage = pg; renderTabs(); renderBoard(); renderBreadcrumb();
  setTimeout(() => {
    const t = [...document.querySelectorAll("#board .tile")].find(el => { const l = el.querySelector(".tile-label"); return l && norm(l.textContent) === n; });
    if (t) { t.classList.add("spotlight"); try { t.scrollIntoView({ block: "center", inline: "center" }); } catch (e) {} setTimeout(() => t.classList.remove("spotlight"), 3200); }
  }, 90);
}
function locateAllWord(label) {
  const n = norm(label), out = [], seen = new Set();
  const inList = (arr) => (arr || []).some(it => !isHidden(it.id) && norm(labelFor(it)) === n);
  const push = (boardId, title) => { if (!seen.has(boardId)) { seen.add(boardId); out.push({ boardId, title }); } };
  for (const c of (state.vocab.columns || [])) if (inList(c.items)) { push("home", "Accueil"); break; }
  for (const f of (state.vocab.functions || [])) if (inList(f.items)) push(f.id, f.label);
  for (const f of (state.vocab.themes || [])) if (inList(f.items)) push(f.id, f.label);
  const packs = (typeof enabledPacks === "function") ? enabledPacks() : (state.vocab.packs || []);
  for (const f of packs) if (inList(f.items)) push(f.id, f.label);
  for (const b in (state.overrides.added || {})) if (inList(state.overrides.added[b])) push(b, (findBoard(b) || {}).label || "Accueil");
  return out;
}
window.locateWord = locateWord; window.locateAllWord = locateAllWord; window.spotlightWord = spotlightWord;

// Tous les mots RÉELLEMENT placés sur un tableau (pour choisir un chemin à imprimer).
function placedWords() {
  const out = [], seen = new Set();
  const push = it => { if (!it || !it.id || isHidden(it.id) || seen.has(it.id)) return; seen.add(it.id); out.push(it); };
  (state.vocab.columns || []).forEach(c => (c.items || []).forEach(push));
  (state.vocab.functions || []).forEach(f => (f.items || []).forEach(push));
  (state.vocab.themes || []).forEach(f => (f.items || []).forEach(push));
  (typeof enabledPacks === "function" ? enabledPacks() : (state.vocab.packs || [])).forEach(f => (f.items || []).forEach(push));
  (state.vocab.advocacy || []).forEach(push);
  Object.keys(state.overrides.added || {}).forEach(b => (state.overrides.added[b] || []).forEach(push));
  return out.sort((a, b) => labelFor(a).localeCompare(labelFor(b), "fr"));
}
const FAMILY_HEX = { pronom: "#ffd93b", verbe: "#6fcf6f", descriptif: "#4dabf7", nom: "#ffa94d",
  social: "#f7a7d0", question: "#b197fc", petit: "#e5e5e5", negation: "#ff8787", advocacy: "#ffe066" };
const FAMILY_NAME = { pronom: "Personnes", verbe: "Actions", descriptif: "Descriptifs", nom: "Noms",
  social: "Social", question: "Questions", petit: "Petits mots", negation: "Refus", advocacy: "Ma voix" };
// Chemin moteur d'un mot (par id) : la suite de gestes pour l'atteindre depuis l'accueil.
function motorPathFor(id) {
  const mk = (item, steps, fam) => ({ item, steps, fam });
  for (const c of (state.vocab.columns || [])) { const it = (c.items || []).find(i => i.id === id);
    if (it) return mk(it, [{ ic: "🏠", t: "Ouvrir l'Accueil" }, { color: FAMILY_HEX[c.color], t: "Colonne « " + c.title + " »" }], FAMILY_NAME[c.color]); }
  for (const it of (state.overrides.added.home || [])) if (it.id === id) return mk(it, [{ ic: "🏠", t: "Ouvrir l'Accueil" }, { ic: "➕", t: "Bloc « Mes mots »" }]);
  { const it = (state.vocab.advocacy || []).find(i => i.id === id) || (state.overrides.added.advocacy || []).find(i => i.id === id);
    if (it) return mk(it, [{ ic: "✊", t: "Ouvrir « Ma voix » (barre du bas)" }], "Ma voix"); }
  const cats = [...(state.vocab.functions || []), ...(state.vocab.themes || []), ...(typeof enabledPacks === "function" ? enabledPacks() : (state.vocab.packs || []))];
  for (const f of cats) {
    const items = [...(f.items || []), ...(state.overrides.added[f.id] || [])];
    if (!items.some(i => i.id === id)) continue;
    const vis = items.filter(i => !isHidden(i.id));
    const vidx = vis.findIndex(i => i.id === id); if (vidx < 0) continue;
    const ps = Math.max(24, state.settings.pageSize || 49);
    const pages = Math.max(1, Math.ceil(vis.length / ps)), page = Math.floor(vidx / ps) + 1;
    const steps = [{ ic: "🏠", t: "Ouvrir l'Accueil" }, { thumb: catThumb(f), t: "Catégorie « " + f.label + " »" }];
    if (pages > 1) steps.push({ ic: "▸", t: "Aller à la page " + page + " sur " + pages });
    return mk(vis[vidx], steps, FAMILY_NAME[colorFor(vis[vidx])] || f.label);
  }
  return null;
}
window.placedWords = placedWords; window.motorPathFor = motorPathFor;

// Picto représentatif d'une catégorie : 1er item porteur d'un picto (Mulberry cohérent).
function catThumb(cat) {
  const it = (cat.items || []).find(i => i.sym_path && !isHidden(i.id));
  return it ? it.sym_path : null;
}
// DOSSIERS de catégories (regroupement à 2 niveaux, validé avec Sandrine 2026-08-06). ~19 dossiers
// au lieu de 68 catégories à plat. AUCUN mot retiré — on regroupe pour trouver plus vite. Prototype
// réversible et à TESTER (cf. mémoire preuves-caa-organisation). Ids = vraies catégories functions/themes.
const CAT_GROUPS = [
  { id: "besoins", section: "Parler", label: "Demander & besoins", members: ["fn-demander", "fn-toilettes-soins", "fn-quotidien-corps"] },
  { id: "limites", section: "Parler", label: "Oui, non & mes limites", members: ["fn-refuser-stop", "fn-donner-mon-avis", "fn-mes-limites", "fn-regler-un-souci"] },
  { id: "emo-pense", section: "Parler", label: "Émotions & pensées", members: ["fn-mes-emotions", "fn-penser"] },
  { id: "discuter", section: "Parler", label: "Discuter", members: ["fn-saluer-politesse", "fn-poser-des-questions", "fn-reagir-relancer", "fn-prendre-la-parole", "fn-verifier-reparer", "fn-sujet-fin", "fn-guider-l-autre", "fn-raconter-partager"] },
  { id: "relations", section: "Parler", label: "Relations & vie intime", members: ["fn-relations-amitie", "fn-vie-affective-intime", "fn-la-mort-le-deuil", "fn-citoyennete-politique"] },
  { id: "temps", section: "Parler", label: "Le temps & les liaisons", members: ["fn-quand", "fn-en-meme-temps-gerondif", "fn-petits-mots-d-oral"] },
  { id: "fun", section: "Parler", label: "Expressif & fun", members: ["fn-onomatopees", "fn-blagues-du-moment", "fn-reactions-fortes", "fn-connivence", "fn-gros-mots", "fn-en-ligne-createur"] },
  { id: "gens", section: "Les mots", label: "Les gens", members: ["th-gens-metiers", "th-gens-relations-descriptions", "th-emotions"] },
  { id: "corps", section: "Les mots", label: "Le corps & la santé", members: ["th-corps-parties-du-corps", "th-sante-soins-materiel", "th-hygiene"] },
  { id: "manger", section: "Les mots", label: "Manger & boire", members: ["th-aliments-fruits-legumes", "th-aliments-pains-feculents", "th-aliments-viandes-poissons-laitages", "th-aliments-repas-desserts", "th-cuisine-ustensiles-preparer", "th-boire"] },
  { id: "animaux", section: "Les mots", label: "Animaux", members: ["th-animaux-mammiferes", "th-animaux-oiseaux", "th-animaux-poissons-mer", "th-animaux-reptiles-amphibiens", "th-animaux-insectes-araignees", "th-animaux-crustaces-mollusques"] },
  { id: "vetements", section: "Les mots", label: "Vêtements", members: ["th-vetements", "th-bijoux", "th-vetements-sport"] },
  { id: "lieux", section: "Les mots", label: "Maison & lieux", members: ["th-lieux-batiments", "th-transports"] },
  { id: "loisirs", section: "Les mots", label: "Loisirs & sport", members: ["th-loisirs", "th-sport", "th-musique", "th-art-creer", "th-voyages"] },
  { id: "ecole", section: "Les mots", label: "École & travail", members: ["th-ecole-travail", "th-argent"] },
  { id: "techno", section: "Les mots", label: "Techno, objets & sciences", members: ["th-ecrans-techno", "th-informatique", "th-sciences", "th-outils", "th-communiquer", "th-mes-mots"] },
  { id: "nature", section: "Les mots", label: "Nature", members: ["th-nature-plantes"] },
  { id: "fetes", section: "Les mots", label: "Fêtes & croyances", members: ["th-fetes", "th-croyances"] },
  { id: "drapeaux", section: "Les mots", label: "Drapeaux du monde", members: ["th-drapeaux"] },
  // Mes cultures & appartenances (tranche 1 : parenté élargie, ce que je refuse, émotions) — À COMPLÉTER
  // avec les 9 autres domaines. Les mots vivent DANS LA GRILLE (pas juste le dico). Voir skill caa-conception.
  { id: "cultures", section: "Mes cultures & appartenances", label: "Ma famille élargie", members: ["th-cult-parente"] },
  { id: "cult-langues", section: "Mes cultures & appartenances", label: "Mes langues", members: ["th-cult-langues"] },
  { id: "cult-origines", section: "Mes cultures & appartenances", label: "Mes origines", members: ["th-cult-origines"] },
  { id: "cult-adresse", section: "Mes cultures & appartenances", label: "Mes manières de parler", members: ["th-cult-adresse"] },
  { id: "cult-traditions", section: "Mes cultures & appartenances", label: "Mes traditions", members: ["th-cult-traditions"] },
  { id: "cult-spiritualite", section: "Mes cultures & appartenances", label: "Ma spiritualité", members: ["th-cult-spiritualite"] },
  { id: "cult-nourriture", section: "Mes cultures & appartenances", label: "Ma nourriture", members: ["th-cult-nourriture"] },
  { id: "cult-musique", section: "Mes cultures & appartenances", label: "Ma musique & mes histoires", members: ["th-cult-musique"] },
  { id: "cult-apparence", section: "Mes cultures & appartenances", label: "Mon apparence", members: ["th-cult-apparence"] },
  { id: "cult-territoire", section: "Mes cultures & appartenances", label: "Mon territoire", members: ["th-cult-territoire"] },
  { id: "cult-transmettre", section: "Mes cultures & appartenances", label: "Ce que je transmets", members: ["th-cult-transmettre"] },
  { id: "refuse", section: "Mes cultures & appartenances", label: "Ce que je refuse", members: ["th-cult-refuse"] },
  { id: "ressentis", section: "Mes cultures & appartenances", label: "Émotions & ressentis", members: ["th-cult-emotions"] },
];
function renderTabs() {
  const tabs = document.getElementById("tabs");
  tabs.innerHTML = "";
  // Menu = GRILLE DE PICTOS (pas une liste de texte) : chaque catégorie = une tuile
  // avec sa miniature (picto représentatif) + son label + sa couleur Fitzgerald.
  const mk = (id, label, opts = {}) => {
    const b = document.createElement("button");
    b.className = "tab" + (state.board === id ? " is-active" : "") + (opts.wide ? " tab-wide" : "");
    b.setAttribute("aria-label", label);
    if (opts.color) b.dataset.color = opts.color;
    if (opts.emoji) {
      const e = document.createElement("span"); e.className = "tab-emoji"; e.textContent = opts.emoji; b.appendChild(e);
    } else if (opts.thumb) {
      const im = document.createElement("img"); im.className = "tab-thumb"; im.src = opts.thumb; im.alt = ""; b.appendChild(im);
    }
    const t = document.createElement("span"); t.className = "tab-label"; t.textContent = label; b.appendChild(t);
    b.dataset.cat = id;   // permet d'ÉCLAIRER la tuile selon le verbe courant (litRelatedCategories)
    b.addEventListener("click", opts.onClick || (() => openBoard(id)));
    return b;   // pas de badge « favori » sur les cases : le bruit visuel en moins — les favoris sont AUTOMATIQUES
  };
  tabs.appendChild(mk("home", "Accueil", { emoji: "🏠", wide: true }));
  // VOIE RAPIDE « Mes mots » : en TÊTE, à 1 clic, dès qu'il y a des mots à remonter (reconnaissance, pas écriture).
  if (mesMotsItems(1).length) {
    const mm = findBoard("__mesmots");
    tabs.appendChild(mk("__mesmots", "Mes mots", { thumb: (mm.items[0] && (typeof pictoFor === "function" ? pictoFor(mm.items[0]) : mm.items[0].sym_path)) || undefined, emoji: (mm.items[0] ? undefined : "⚡"), color: "petit", wide: true }));
  }
  // Petit bouton discret « replier » : masque la barre EN PLACE (même disposition).
  // Un onglet-repère reste au même bord ; un clic la fait revenir à l'identique.
  // (On ne bascule PAS en pop-up : garder les repères spatiaux.)
  {
    const s = state.settings;
    const isCol = (s.catMode || "colonne") === "colonne";
    const cbtn = document.createElement("button");
    cbtn.className = "tab-collapse-mini";
    cbtn.type = "button";
    cbtn.setAttribute("aria-label", "Ranger la barre de catégories");
    cbtn.title = "Ranger la barre — un repère « Catégories » la fait revenir au même endroit";
    cbtn.textContent = isCol ? "‹ Ranger la barre" : "⌃ Ranger la barre";
    cbtn.addEventListener("click", () => {
      s.catCollapsed = true; saveSettings(); applyLook();
    });
    tabs.appendChild(cbtn);
  }
  // (« Promenade d'idées » vit maintenant dans la barre du haut, à côté de Contraste/Chercher.)
  if (state.settings.spiral !== "off" && !state.settings.stablePositions)
    tabs.appendChild(mk("__freq", "Fréquents", { emoji: "✷", wide: true }));
  const sep = (txt) => { const s = document.createElement("div"); s.className = "tabs-sep"; s.textContent = txt; tabs.appendChild(s); };
  // Bloc « Régions » + « Moi » — même contenu quel que soit le mode d'affichage du menu.
  function renderPacksAndMoi() {
    const packs = enabledPacks();
    if (packs.length || (state.vocab.synonyms || []).length) sep("Mes mots d'ici");
    packs.forEach(p => tabs.appendChild(mk(p.id, p.label, { thumb: catThumb(p), color: p.color || "nom" })));
    if ((state.vocab.synonyms || []).length)
      tabs.appendChild(mk("__variantes", "Variantes régionales", { thumb: (state.vocab.synonyms[0] || {}).sym_path, color: "nom" }));
    const b = document.createElement("button");
    b.className = "tab tab-wide"; b.setAttribute("aria-label", "Régions & cultures");
    b.innerHTML = '<span class="tab-emoji">🌍</span><span class="tab-label">Régions &amp; cultures</span>';
    b.addEventListener("click", () => { if (window.CT) CT.openPacks(); });
    tabs.appendChild(b);
    sep("Moi");
    const wideBtn = (emoji, label, fn) => {
      const x = document.createElement("button");
      x.className = "tab tab-wide"; x.setAttribute("aria-label", label);
      x.innerHTML = `<span class="tab-emoji">${emoji}</span><span class="tab-label">${label}</span>`;
      x.addEventListener("click", fn); tabs.appendChild(x);
    };
    wideBtn("🪪", "Mon passeport", () => { if (window.CT) CT.openPassport(); });
    wideBtn("📖", "Cahier de vie", () => { if (window.CT) CT.openCahier(); });
    wideBtn("⭐", "Mes mots préférés", () => { if (window.CT) CT.openStats(); });
    wideBtn("🗺️", "Chemins moteurs", () => { if (window.CT) CT.openMotorPaths(); });
    wideBtn("📥", "Bibliothèque de pictos", () => { if (window.CT) CT.openLibrary(); });
    wideBtn("🖨️", "Imprimer ce tableau", () => { if (window.CT) CT.printBoard(); });
  }
  // DOSSIERS (prototype réversible, à tester — cf. mémoire preuves-caa-organisation) : ~19 dossiers à
  // 2 niveaux au lieu de 68 catégories à plat. On ne retire AUCUN mot ; on regroupe pour trouver plus vite.
  const foldersMode = state.settings.catFolders !== false;
  const grp = foldersMode && state.catGroup ? CAT_GROUPS.find(g => g.id === state.catGroup) : null;
  if (foldersMode && grp) {
    const back = document.createElement("button");
    back.className = "tab tab-wide tab-back-folders"; back.setAttribute("aria-label", "Revenir à tous les dossiers");
    back.innerHTML = '<span class="tab-emoji">‹</span><span class="tab-label">Tous les dossiers</span>';
    back.addEventListener("click", () => { state.catGroup = null; renderTabs(); });
    tabs.appendChild(back);
    sep(grp.label);
    (grp.members || []).forEach(id => { const cat = findBoard(id); if (cat) tabs.appendChild(mk(id, cat.label, { thumb: catThumb(cat), color: cat.color || "nom", pin: id })); });
  } else if (foldersMode) {
    renderFavs();
    let lastSection = null;
    CAT_GROUPS.forEach(g => {
      if (g.section !== lastSection) { sep(g.section); lastSection = g.section; }
      const first = findBoard((g.members || [])[0]);
      tabs.appendChild(mk("__grp-" + g.id, g.label, {
        thumb: first ? catThumb(first) : undefined, emoji: first ? undefined : "📂",
        color: g.color || "petit", onClick: () => { state.catGroup = g.id; renderTabs(); }
      }));
    });
    renderPacksAndMoi();
  } else {   // Mode LISTE À PLAT (d'origine) — gardé pour comparer en test
    renderFavs();
    if ((state.vocab.functions || []).length) sep("Communiquer");
    (state.vocab.functions || []).forEach(f => tabs.appendChild(mk(f.id, f.label, { thumb: catThumb(f), color: f.color, pin: f.id })));
    if ((state.vocab.themes || []).length) sep("Thèmes");
    (state.vocab.themes || []).forEach(f => tabs.appendChild(mk(f.id, f.label, { thumb: catThumb(f), color: f.color || "nom", pin: f.id })));
    renderPacksAndMoi();
  }
  litRelatedCategories();   // éclaire d'emblée les catégories liées au verbe déjà présent
  // « Souvent » : les catégories les plus OUVERTES remontent en tête, AUTOMATIQUEMENT (aucun badge, aucun
  // geste). + celles éventuellement épinglées via les réglages (catFav). Recognition, pas d'écriture.
  function renderFavs() {
    const cu = state.overrides.catUsage || {};
    const auto = Object.keys(cu).sort((a, b) => cu[b] - cu[a]).slice(0, 4);
    const ids = [...new Set([...(state.settings.catFav || []), ...auto])];
    const favs = ids.map(id => findBoard(id)).filter(Boolean).slice(0, 6);
    if (!favs.length) return;
    sep("Souvent");
    favs.forEach(cat => tabs.appendChild(mk(cat.id, cat.label, { thumb: catThumb(cat), color: cat.color || "nom" })));
  }
}

/* ---------- Vue "Ma voix" (advocacy) ---------- */
function renderAdvocacy() {
  const board = document.getElementById("board");
  // Grille SIMPLE : on retire la disposition « colonnes » de l'accueil, sinon les phrases
  // sont écrasées en slivers verticaux illisibles (13 px de large).
  board.className = "board";
  board.style.setProperty("--cols", Math.min(state.settings.cols, 4));
  board.innerHTML = "";
  const hero = document.createElement("div"); hero.className = "view-hero";
  const homeBtn = document.createElement("button"); homeBtn.className = "btn-secondary view-home"; homeBtn.type = "button";
  homeBtn.innerHTML = (typeof CT_ICONS !== "undefined" ? `<span class="ct-ic ct-ic-inline">${CT_ICONS.home}</span> Accueil` : "🏠 Accueil");
  homeBtn.setAttribute("aria-label", "Revenir à l'accueil");
  homeBtn.addEventListener("click", () => { const h = document.getElementById("nav-home"); if (h) h.click(); });
  hero.appendChild(homeBtn);
  const heroTxt = document.createElement("div");
  heroTxt.innerHTML = "<h2>✊ Ma voix</h2><p>Mes phrases pour m'affirmer et me faire respecter. Touche une carte pour la dire. Ajoute les tiennes avec « Modifier ».</p>";
  hero.appendChild(heroTxt);
  board.appendChild(hero);
  const items = [...state.vocab.advocacy, ...(state.overrides.added["advocacy"] || [])];
  items.filter(i => !isHidden(i.id)).forEach(item => {
    board.appendChild(tileEl({ ...item, color: item.color || "advocacy", phrase: true }, {}));
  });
}

/* ---------- Vue « Mon état, mes besoins » (soutien, JAMAIS « régulation ») ----------
 * Espace OPT-IN, contrôlé par la personne. Volet PENDANT (surcharge) : chemin le plus
 * court pour DIRE tout de suite un message puissant. Aucun déclenchement automatique,
 * aucun score, rien qui masque « stop / non / laisse-moi ». Voir [[mode-soutien-caa]]. */
// { say, pic } : un pictogramme (ou la bulle de secours) sur CHAQUE carte — jamais de carte texte seule.
const ETAT_SURCHARGE = [
  { say: "Je comprends encore", pic: "comprendre" },
  { say: "Je ne peux plus répondre comme d'habitude", pic: "fatigué" },
  { say: "Une question à la fois", pic: "un" },
  { say: "Attends ma réponse", pic: "attendre" },
  { say: "Parle moins", pic: "silence" },
  { say: "Éloigne-toi", pic: "loin" },
  { say: "Reste avec moi", pic: "ensemble" },
  { say: "Ne me touche pas", pic: "stop" },
  { say: "J'ai besoin de mon autre moyen de communication", pic: "communiquer" },
  { say: "Je veux partir", pic: "partir" },
];
// Carte du corps (autodéclaration : la personne montre OÙ ça fait mal).
const PAIN_ZONES = [
  { say: "J'ai mal à la tête", pic: "tête", cx: 100, cy: 28, r: 18 },
  { say: "J'ai mal aux yeux", pic: "yeux", cx: 100, cy: 32, r: 8 },
  { say: "J'ai mal aux oreilles", pic: "oreille", cx: 120, cy: 30, r: 7 },
  { say: "J'ai mal aux dents", pic: "dent", cx: 100, cy: 44, r: 7 },
  { say: "J'ai mal à la gorge", pic: "gorge", cx: 100, cy: 56, r: 8 },
  { say: "J'ai mal à la poitrine", pic: "poitrine", cx: 100, cy: 92, r: 15 },
  { say: "J'ai mal au ventre", pic: "ventre", cx: 100, cy: 138, r: 18 },
  { say: "J'ai mal au bras", pic: "bras", cx: 51, cy: 108, r: 12 },
  { say: "J'ai mal au bras", pic: "bras", cx: 149, cy: 108, r: 12 },
  { say: "J'ai mal à la main", pic: "main", cx: 51, cy: 176, r: 12 },
  { say: "J'ai mal à la main", pic: "main", cx: 149, cy: 176, r: 12 },
  { say: "J'ai mal à la jambe", pic: "jambe", cx: 88, cy: 238, r: 13 },
  { say: "J'ai mal à la jambe", pic: "jambe", cx: 112, cy: 238, r: 13 },
  { say: "J'ai mal au genou", pic: "genou", cx: 88, cy: 292, r: 11 },
  { say: "J'ai mal au genou", pic: "genou", cx: 112, cy: 292, r: 11 },
  { say: "J'ai mal au pied", pic: "pied", cx: 88, cy: 338, r: 12 },
  { say: "J'ai mal au pied", pic: "pied", cx: 112, cy: 338, r: 12 },
];
// Endroits « spéciaux » : la personne peut aussi ne pas savoir, ou avoir mal partout.
const PAIN_SPECIALS = [
  { lab: "partout", say: "J'ai mal partout" },
  { lab: "plusieurs endroits", say: "J'ai mal à plusieurs endroits" },
  { lab: "je ne sais pas où", say: "J'ai mal, je ne sais pas où" },
];
// Descripteurs (« comment ça fait ») + mot-picto associé (résolu depuis la banque Mulberry).
// pic = mot Mulberry résolu par pictoFor. « ça lance » (élancement/throbbing) n'a AUCUN picto
// Mulberry honnête → laissé sans image (texte seul) plutôt qu'un faux picto trompeur.
const PAIN_DESC = [
  { w: "ça serre", pic: "serrer" }, { w: "ça brûle", pic: "brûler" }, { w: "ça pique", pic: "pointu" },
  { w: "ça lance", pic: "douleur" }, { w: "ça tire", pic: "tirer" }, { w: "ça gratte", pic: "démanger" }, { w: "c'est chaud", pic: "chaud" },
];
// Intensité : niveaux du plus léger au plus fort (échelle reconnue, jamais un « score »).
const PAIN_LEVELS = [
  { w: "presque rien" }, { w: "un petit peu" }, { w: "moyennement" },
  { w: "beaucoup" }, { w: "très fort" }, { w: "insupportable" },
];
// Volet PARTENAIRE (transmission) — uniquement si la personne ne peut PAS répondre elle-même.
// ⚠️ DROITS : l'ESDDA (EPS Barthélemy Durand) est « attribution, PAS de modification, PAS d'usage
// commercial ». On NE reproduit donc PAS ses 6 items, on N'affiche PAS le nom « ESDDA » dans l'appli,
// et on ne se présente pas comme une adaptation. Ce module est un OUTIL ORIGINAL de transmission,
// plus large, dont le seul principe partagé (non protégeable) = le CHANGEMENT par rapport aux
// HABITUDES PROPRES de la personne. Pas de cotation, pas de score, pas de conclusion clinique.
// (Autorisation écrite d'adapter l'ESDDA = à demander à l'EPS Barthélemy Durand avant tout usage du nom.)
const PAIN_TRANSMIT = [
  { key: "changement", label: "Changement récent constaté", ph: "Ex. depuis ce matin, plus agitée que d'habitude" },
  { key: "inhabituel", label: "Ce qui est inhabituel pour cette personne", ph: "Par rapport à son fonctionnement habituel" },
  { key: "contexte", label: "Moment et contexte d'apparition", ph: "Quand ? après quoi ? où ?" },
  { key: "zone", label: "Zone du corps suspectée", ph: "Si repérable, sinon « je ne sais pas »" },
  { key: "variation", label: "Ce qui augmente ou apaise", ph: "Ex. pire allongée, mieux avec une pression" },
  { key: "comm", label: "Message ou moyen de communication utilisé par la personne", ph: "Mots, gestes, pictos, sons…" },
  { key: "action", label: "Action déjà proposée", ph: "Ce qui a déjà été tenté" },
];
// « Décrire » — 4 dimensions (mémoire mode-soutien-caa) : corps / autour / provoque / besoin.
const ETAT_DIMS = [
  { grp: "Dans mon corps", pic: "mon corps", items: ["Ça serre", "Ça brûle", "Mon cœur va vite", "J'ai chaud", "Je tremble", "Ma tête est pleine", "Je ne sens plus bien mon corps"] },
  { grp: "Autour de moi", pic: "autour", items: ["Il y a trop de bruit", "Il y a trop de personnes", "Il y a trop de lumière", "Il y a trop de questions", "Il y a un changement imprévu", "Je ne comprends pas ce qui va arriver"] },
  { grp: "Ce que ça provoque", pic: "penser", items: ["Je perds mes mots", "Je ne peux plus choisir", "Je peux écouter mais pas répondre", "Mes mouvements sont moins précis"] },
  { grp: "Ce dont j'ai besoin", pic: "j'ai besoin", items: ["J'ai besoin d'une pause", "J'ai besoin d'une explication", "J'ai besoin d'un endroit calme", "J'ai besoin de plus de temps", "J'ai besoin d'une personne connue", "J'ai besoin de changer de position", "J'ai besoin de manger", "J'ai besoin de boire", "J'ai besoin d'aller aux toilettes", "Il faut vérifier une douleur"] },
];
// « Je fais le point » — outil ORIGINAL d'aide à la réflexion / communication.
// ⚠️ DROITS & CADRAGE : la SÉQUENCE générale s'inspire de SODA (Stop-Observe-Deliberate-Act,
// M. A. Bock, 2007) — base probante MINCE (étude de cas, 1 élève). On NE recopie PAS les
// questions/supports publiés de l'autrice (formulaires protégés), on N'affiche PAS « SODA »
// comme un outil « validé de régulation ». Ce module aide à FAIRE LE POINT et à COMMUNIQUER,
// pas à produire un comportement « socialement conforme ». Aucune appréciation bon/mauvais choix,
// aucune récompense, aucune lecture d'émotion à partir du visage d'autrui. « Je ne sais pas » partout.
const POINT_STEPS = [
  { id: "pause", icon: "⏸️", pic: "pause", title: "Pause",
    intro: { now: "Qu'est-ce qui t'aiderait ?", prep: "Si ça arrive, qu'est-ce qui t'aiderait ?" },
    items: ["J'ai besoin de temps.", "Je veux faire une pause.", "Je peux continuer.", "Je veux partir.", "J'ai besoin d'aide.", "C'est urgent."] },
  { id: "regarde", icon: "👀", pic: "regarder", title: "Je regarde ce qui se passe",
    intro: { now: "Qu'est-ce qui se passe ?", prep: "Qu'est-ce qui pourrait se passer ?" },
    groups: [
      { g: "Dans mon corps", items: ["Mon corps est tendu.", "Je ressens de l'agitation.", "J'ai besoin de repos."] },
      { g: "Autour de moi", items: ["Il y a trop de bruit.", "Il y a beaucoup de monde.", "Quelque chose a changé."] },
      { g: "Ce que font ou disent les personnes", items: ["On me parle beaucoup.", "On me demande quelque chose.", "Je ne sais pas ce qu'on attend de moi."] },
      { g: "Ce que je comprends", items: ["Je comprends la situation.", "Je ne comprends pas ce qui va se passer."] },
    ],
    always: ["Je n'ai pas assez d'informations.", "Montre-moi.", "Explique autrement."] },
  { id: "choix", icon: "🧭", pic: "choisir", title: "Je choisis",
    intro: { now: "Qu'est-ce que tu veux faire ?", prep: "Qu'est-ce que tu pourrais décider ?" },
    items: ["Voici ce que je veux.", "Voici ce que je peux essayer.", "Voici ce que je refuse.", "Ça pourrait arriver.", "Je veux demander conseil.", "Je veux un indice.", "Je ne veux pas choisir maintenant."] },
  { id: "agir", icon: "✅", pic: "faire", title: "J'agis ou je communique",
    intro: { now: "Qu'est-ce que tu fais, ou tu dis ?", prep: "Qu'est-ce que tu feras ?" },
    items: ["Je veux dire quelque chose.", "Je veux poser une question.", "Je refuse.", "Je demande une adaptation.", "Je choisis une stratégie.", "Je veux attendre.", "Je veux quitter la situation.", "Je demande à quelqu'un d'agir.", "Je garde ce plan pour plus tard."] },
];
// Thermomètre de la douleur (outil reconnu, Pediadol) : jauge verticale qui se remplit,
// visuel pur (pas d'emoji, pas de chiffre). n = niveau, total = nombre de niveaux.
function painThermoIcon(n, total) {
  const COL = { 1: "#22c55e", 2: "#84cc16", 3: "#eab308", 4: "#f97316", 5: "#ef4444", 6: "#b91c1c" };
  const c = COL[n] || "#ef4444";
  const top = 4, bot = 34, h = (bot - top) * (n / total), y = bot - h;
  return "<svg viewBox='0 0 22 46' class='thermo-ico' aria-hidden='true'>"
    + "<rect x='7' y='2' width='8' height='34' rx='4' fill='var(--surface)' stroke='#94a3b8' stroke-width='1.5'/>"
    + "<circle cx='11' cy='39' r='6' fill='" + c + "'/>"
    + "<rect x='9' y='" + y.toFixed(1) + "' width='4' height='" + h.toFixed(1) + "' fill='" + c + "'/>"
    + "</svg>";
}
// Crée un <img> picto Mulberry (priorité pictos, jamais d'emoji) ; si absent, l'<img> se retire.
function pictoImg(word, cls) {
  const img = document.createElement("img"); img.alt = ""; img.className = "ct-ico" + (cls ? " " + cls : ""); img.hidden = true;
  setPictoAsync(img, word, () => { img.remove(); });
  return img;
}
// Charge un picto (banque Mulberry) de façon asynchrone dans un <img> ; si aucun, callback onNone.
function setPictoAsync(img, word, onNone) {
  if (!(window.CT && CT.warmSym && CT.pictoFor)) { if (onNone) onNone(); return; }
  CT.warmSym().then(() => { const sp = CT.pictoFor(word); if (sp) { img.onload = () => { img.hidden = false; }; img.src = sp; } else if (onNone) { onNone(); } })
    .catch(() => { if (onNone) onNone(); });
}
function renderEtat() {
  const board = document.getElementById("board");
  board.className = "board etat-view";
  board.style.removeProperty("--cols");
  board.innerHTML = "";
  const hero = document.createElement("div"); hero.className = "view-hero";
  const homeBtn = document.createElement("button"); homeBtn.className = "btn-secondary view-home"; homeBtn.type = "button";
  homeBtn.innerHTML = (typeof CT_ICONS !== "undefined" ? `<span class="ct-ic ct-ic-inline">${CT_ICONS.home}</span> Accueil` : "🏠 Accueil"); homeBtn.setAttribute("aria-label", "Revenir à l'accueil");
  homeBtn.addEventListener("click", () => { const h = document.getElementById("nav-home"); if (h) h.click(); });
  hero.appendChild(homeBtn);
  const heroTxt = document.createElement("div");
  heroTxt.innerHTML = "<h2>🫂 Mon état, mes besoins</h2><p>C'est toi qui choisis.</p>";
  hero.appendChild(heroTxt);
  board.appendChild(hero);
  // Sous-navigation (l'espace grandira : Messages, J'ai mal, …)
  const sub = document.createElement("div"); sub.className = "etat-sub";
  const SECTIONS = [{ id: "surcharge", lab: "Messages", pic: "aider" }, { id: "douleur", lab: "J'ai mal", pic: "mal" }, { id: "decrire", lab: "Décrire", pic: "parler" }, { id: "point", lab: "Je fais le point", pic: "penser" }];
  const ids = SECTIONS.map(s => s.id);
  const section = ids.includes(state.settings.etatSection) ? state.settings.etatSection : "surcharge";
  const body = document.createElement("div"); body.className = "etat-body";
  SECTIONS.forEach(S => {
    const b = document.createElement("button"); b.type = "button"; b.className = "etat-tab" + (S.id === section ? " on" : "");
    b.append(pictoImg(S.pic, "etat-tab-ico"), Object.assign(document.createElement("span"), { textContent: S.lab }));
    b.addEventListener("click", () => { state.settings.etatSection = S.id; saveSettings(); renderEtat(); });
    sub.appendChild(b);
  });
  board.appendChild(sub); board.appendChild(body);
  if (section === "douleur") renderPainInto(body);
  else if (section === "decrire") renderDecrireInto(body);
  else if (section === "point") renderPointInto(body);
  else renderSurchargeInto(body);
}
function renderSurchargeInto(host) {
  const grid = document.createElement("div"); grid.className = "etat-grid";
  ETAT_SURCHARGE.forEach(m => {
    const b = document.createElement("button"); b.type = "button"; b.className = "etat-card";
    b.append(pictoImg(m.pic, "etat-card-ico"), Object.assign(document.createElement("span"), { className: "etat-card-txt", textContent: m.say }));
    b.setAttribute("aria-label", "Dire : " + m.say);
    b.addEventListener("click", () => speakOut(m.say));
    grid.appendChild(b);
  });
  host.appendChild(grid);
}
function renderPainInto(host) {
  host.innerHTML = "";
  const svgNS = "http://www.w3.org/2000/svg";
  // État de construction de la phrase : plusieurs endroits possibles, OU un « spécial ».
  const sel = { locs: [], special: null, desc: null, level: null };
  const speakEach = () => state.settings.painSpeakEach !== false;   // par défaut : dit à chaque choix

  // --- Barre phrase (toujours visible en haut : aperçu + « Dire ma phrase » + réglage) ---
  function buildPhrase() {
    let loc = "";
    if (sel.special) { const s = PAIN_SPECIALS.find(x => x.lab === sel.special); loc = s ? s.say : ""; }
    else if (sel.locs.length) { loc = "J'ai mal " + [...new Set(sel.locs)].join(" et "); }
    let s = loc;
    if (sel.desc) s += (s ? ", " : "") + sel.desc;
    if (sel.level) s += (s ? ", " : "") + sel.level;
    return s ? s.replace(/[.\s]+$/, "") + "." : "";
  }
  const bar = document.createElement("div"); bar.className = "pain-bar";
  const preview = document.createElement("div"); preview.className = "pain-preview";
  const dire = document.createElement("button"); dire.type = "button"; dire.className = "btn-primary pain-dire"; dire.textContent = "🔊 Dire ma phrase";
  dire.addEventListener("click", () => { const p = buildPhrase(); if (p) speakOut(p); });
  const tog = document.createElement("label"); tog.className = "pain-toggle";
  const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = speakEach();
  cb.addEventListener("change", () => { state.settings.painSpeakEach = cb.checked; saveSettings(); });
  tog.append(cb, document.createTextNode(" Dire à chaque choix"));
  bar.append(preview, dire, tog); host.appendChild(bar);
  function updatePreview() { const p = buildPhrase(); preview.textContent = p || "Montre où, comment, et à quel point."; preview.classList.toggle("empty", !p); }

  // --- Colonnes : corps (gauche) | picto survolé + thermomètre (droite, pour éviter le scroll) ---
  const cols = document.createElement("div"); cols.className = "pain-cols";
  const left = document.createElement("div"); left.className = "pain-left";
  const right = document.createElement("div"); right.className = "pain-right";
  cols.append(left, right); host.appendChild(cols);

  // Picto de la zone survolée (apparaît à côté du corps).
  const hoverBox = document.createElement("div"); hoverBox.className = "pain-hover";
  const hoverImg = document.createElement("img"); hoverImg.alt = ""; hoverImg.className = "pain-hover-img"; hoverImg.hidden = true;
  const hoverLab = document.createElement("div"); hoverLab.className = "pain-hover-lab"; hoverLab.textContent = "Passe sur le corps";
  hoverBox.append(hoverImg, hoverLab); right.appendChild(hoverBox);
  function showHover(z) {
    hoverLab.textContent = z.say.replace(/^J'ai mal /, "");
    hoverImg.hidden = true; hoverImg.removeAttribute("src");
    setPictoAsync(hoverImg, z.pic, () => { hoverImg.hidden = true; });
  }

  // Carte du corps — silhouette PROPRE composée de formes simples (libre de droit, dessin original).
  const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("viewBox", "0 0 200 360"); svg.setAttribute("class", "pain-body"); svg.setAttribute("role", "group"); svg.setAttribute("aria-label", "Carte du corps");
  const FIG = [
    ["circle", { cx: 100, cy: 28, r: 20 }],                                  // tête
    ["rect", { x: 92, y: 46, width: 16, height: 12, rx: 5 }],                // cou
    ["path", { d: "M72 62 Q100 54 128 62 L122 176 Q100 188 78 176 Z" }],     // tronc
    ["rect", { x: 44, y: 66, width: 15, height: 104, rx: 7 }],               // bras gauche
    ["rect", { x: 141, y: 66, width: 15, height: 104, rx: 7 }],              // bras droit
    ["circle", { cx: 51, cy: 176, r: 11 }],                                  // main gauche
    ["circle", { cx: 149, cy: 176, r: 11 }],                                 // main droite
    ["rect", { x: 80, y: 178, width: 16, height: 150, rx: 8 }],              // jambe gauche
    ["rect", { x: 104, y: 178, width: 16, height: 150, rx: 8 }],             // jambe droite
    ["ellipse", { cx: 88, cy: 336, rx: 14, ry: 8 }],                        // pied gauche
    ["ellipse", { cx: 112, cy: 336, rx: 14, ry: 8 }],                       // pied droit
  ];
  FIG.forEach(([tag, attrs]) => { const e = document.createElementNS(svgNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); e.setAttribute("class", "pain-figure"); svg.appendChild(e); });

  const specBtns = [];
  function clearSpecs() { sel.special = null; specBtns.forEach(b => b.classList.remove("on")); }
  function clearAll() {
    svg.querySelectorAll(".pain-zone.on").forEach(n => n.classList.remove("on"));
    sel.locs = []; clearSpecs();
    sel.desc = null; sel.level = null;
    host.querySelectorAll(".pain-desc.on, .pain-level.on").forEach(n => n.classList.remove("on"));
    updatePreview();
  }
  PAIN_ZONES.forEach(z => {
    const c = document.createElementNS(svgNS, "circle"); c.setAttribute("cx", z.cx); c.setAttribute("cy", z.cy); c.setAttribute("r", z.r);
    c.setAttribute("class", "pain-zone"); c.setAttribute("tabindex", "0"); c.setAttribute("role", "button"); c.setAttribute("aria-label", z.say);
    const loc = z.say.replace(/^J'ai mal /, "");
    // BASCULE : re-cliquer retire ; plusieurs endroits possibles. Choisir une zone annule un « spécial ».
    const hit = () => {
      if (sel.special) clearSpecs();
      const wasOn = c.classList.contains("on"); c.classList.toggle("on");
      if (wasOn) { const i = sel.locs.indexOf(loc); if (i >= 0) sel.locs.splice(i, 1); }
      else { sel.locs.push(loc); if (speakEach()) speakOut(z.say); }
      showHover(z); updatePreview();
    };
    c.addEventListener("click", hit);
    c.addEventListener("mouseenter", () => showHover(z));   // survol → picto de la partie
    c.addEventListener("focus", () => showHover(z));
    c.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hit(); } });
    svg.appendChild(c);
  });
  // Cliquer sur le corps ou à l'extérieur (hors zone) = tout effacer.
  svg.addEventListener("click", e => { if (e.target === svg || (e.target.classList && e.target.classList.contains("pain-figure"))) clearAll(); });
  const bodyWrap = document.createElement("div"); bodyWrap.className = "pain-body-wrap"; bodyWrap.appendChild(svg); left.appendChild(bodyWrap);

  // Endroits spéciaux + Effacer (sous le corps).
  const specRow = document.createElement("div"); specRow.className = "pain-spec-row";
  PAIN_SPECIALS.forEach(sp => {
    const b = document.createElement("button"); b.type = "button"; b.className = "pain-spec"; b.textContent = sp.lab; b.setAttribute("aria-label", sp.say);
    b.addEventListener("click", () => {
      const on = sel.special === sp.lab;
      svg.querySelectorAll(".pain-zone.on").forEach(n => n.classList.remove("on")); sel.locs = [];
      clearSpecs();
      if (!on) { sel.special = sp.lab; b.classList.add("on"); if (speakEach()) speakOut(sp.say); }
      updatePreview();
    });
    specBtns.push(b); specRow.appendChild(b);
  });
  const clearBtn = document.createElement("button"); clearBtn.type = "button"; clearBtn.className = "btn-secondary pain-clear"; clearBtn.textContent = "↺ Effacer";
  clearBtn.setAttribute("aria-label", "Tout effacer"); clearBtn.addEventListener("click", clearAll);
  specRow.appendChild(clearBtn); left.appendChild(specRow);

  // Combien — thermomètre + visages, À CÔTÉ du corps (colonne droite) pour éviter le scroll.
  right.appendChild(Object.assign(document.createElement("h3"), { className: "pain-h", textContent: "C'est fort comment ?" }));
  const thermo = document.createElement("div"); thermo.className = "pain-thermo";
  PAIN_LEVELS.slice().reverse().forEach((lv, i) => {   // le plus fort en HAUT
    const lvlNum = PAIN_LEVELS.length - i;
    const b = document.createElement("button"); b.type = "button"; b.className = "pain-level lvl" + lvlNum;
    // Intensité = thermomètre qui se remplit (visuel reconnu, pas d'emoji).
    const face = document.createElement("span"); face.className = "pl-face";
    face.innerHTML = painThermoIcon(lvlNum, PAIN_LEVELS.length);
    b.append(face, Object.assign(document.createElement("span"), { className: "pl-w", textContent: lv.w }));
    b.setAttribute("aria-label", lv.w);
    b.addEventListener("click", () => {
      const on = sel.level === lv.w;
      thermo.querySelectorAll(".pain-level.on").forEach(n => n.classList.remove("on"));
      if (on) { sel.level = null; } else { sel.level = lv.w; b.classList.add("on"); if (speakEach()) speakOut(lv.w); }
      updatePreview();
    });
    thermo.appendChild(b);
  });
  right.appendChild(thermo);

  // Comment ça fait — descripteurs AVEC pictos (sous les colonnes).
  host.appendChild(Object.assign(document.createElement("h3"), { className: "pain-h", textContent: "Comment ça fait ?" }));
  const drow = document.createElement("div"); drow.className = "pain-desc-row";
  PAIN_DESC.forEach(d => {
    const b = document.createElement("button"); b.type = "button"; b.className = "pain-desc"; b.setAttribute("aria-label", d.w);
    const im = document.createElement("img"); im.alt = ""; im.className = "pain-desc-pic"; im.hidden = true;
    setPictoAsync(im, d.pic, () => { im.remove(); });
    b.appendChild(im); b.appendChild(Object.assign(document.createElement("span"), { className: "pain-desc-lab", textContent: d.w }));
    b.addEventListener("click", () => {
      const on = sel.desc === d.w;
      drow.querySelectorAll(".pain-desc.on").forEach(n => n.classList.remove("on"));
      if (on) { sel.desc = null; } else { sel.desc = d.w; b.classList.add("on"); if (speakEach()) speakOut(d.w); }
      updatePreview();
    });
    drow.appendChild(b);
  });
  host.appendChild(drow);

  // Passage FACULTATIF vers « Je fais le point » (étape Je choisis) — sans imposer tout le parcours.
  const bridge = document.createElement("div"); bridge.className = "pain-bridge";
  const bridgeBtn = document.createElement("button"); bridgeBtn.type = "button"; bridgeBtn.className = "btn-secondary";
  bridgeBtn.append(pictoImg("choisir", "etat-tab-ico"), Object.assign(document.createElement("span"), { textContent: "Qu'est-ce que je veux faire ?" }));
  bridgeBtn.addEventListener("click", () => { state.settings.etatSection = "point"; state.settings.pointStep = 2; saveSettings(); renderEtat(); });
  bridge.appendChild(bridgeBtn); host.appendChild(bridge);

  // --- Volet PARTENAIRE (secondaire, replié) : outil de TRANSMISSION, si la personne ne peut pas répondre ---
  const det = document.createElement("details"); det.className = "pain-partner";
  const sum = document.createElement("summary"); sum.className = "pain-partner-sum";
  sum.textContent = "🫂 Observer des changements (pour la personne qui accompagne)";
  det.appendChild(sum);
  det.appendChild(Object.assign(document.createElement("p"), { className: "pain-partner-intro",
    textContent: "À utiliser seulement si la personne ne peut pas dire elle-même. Elle reste la meilleure source, jamais remplacée." }));
  det.appendChild(Object.assign(document.createElement("p"), { className: "pain-partner-base",
    textContent: "Le repère : ce qui change par rapport à ses habitudes." }));
  const form = document.createElement("div"); form.className = "pain-transmit";
  const fields = {};
  PAIN_TRANSMIT.forEach(f => {
    const lab = document.createElement("label"); lab.className = "pain-transmit-item";
    lab.appendChild(Object.assign(document.createElement("span"), { className: "pain-transmit-lab", textContent: f.label }));
    const ta = document.createElement("textarea"); ta.className = "pain-transmit-in"; ta.rows = 1; ta.placeholder = f.ph;
    ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; });
    fields[f.key] = ta; lab.appendChild(ta); form.appendChild(lab);
  });
  const medLab = document.createElement("label"); medLab.className = "pain-transmit-check";
  const medCb = document.createElement("input"); medCb.type = "checkbox";
  medLab.append(medCb, document.createTextNode(" Un avis médical semble nécessaire"));
  form.appendChild(medLab);
  det.appendChild(form);
  // Résumé à transmettre (lecture / dire) — construit à partir de ce qui est rempli.
  const actions = document.createElement("div"); actions.className = "pain-transmit-actions";
  const sumBtn = document.createElement("button"); sumBtn.type = "button"; sumBtn.className = "btn-secondary"; sumBtn.textContent = "📝 Résumé à transmettre";
  const sayBtn = document.createElement("button"); sayBtn.type = "button"; sayBtn.className = "btn-secondary"; sayBtn.textContent = "🔊 Lire le résumé";
  actions.append(sumBtn, sayBtn); det.appendChild(actions);
  const out = document.createElement("p"); out.className = "pain-transmit-out"; out.hidden = true; det.appendChild(out);
  function buildTransmit() {
    const parts = [];
    PAIN_TRANSMIT.forEach(f => { const v = fields[f.key].value.trim(); if (v) parts.push(f.label + " : " + v); });
    if (medCb.checked) parts.push("Un avis médical semble nécessaire.");
    return parts.join("\n");
  }
  sumBtn.addEventListener("click", () => { const t = buildTransmit(); out.hidden = !t; out.textContent = t || ""; });
  sayBtn.addEventListener("click", () => { const t = buildTransmit(); if (t) speakOut(t.replace(/\n/g, ". ")); });
  det.appendChild(Object.assign(document.createElement("p"), { className: "pain-partner-src",
    textContent: "Cette observation complète ma communication. Elle ne permet pas de conclure à elle seule qu'une douleur est présente ou absente." }));
  host.appendChild(det);
  updatePreview();
}
// « Décrire » — 4 dimensions (corps / autour / provoque / besoin). Tap = dit tout de suite.
function renderDecrireInto(host) {
  host.innerHTML = "";
  ETAT_DIMS.forEach(dim => {
    const h = document.createElement("h3"); h.className = "pain-h dim-h"; h.append(pictoImg(dim.pic, "dim-h-ico"), document.createTextNode(dim.grp)); host.appendChild(h);
    const grid = document.createElement("div"); grid.className = "dim-grid";
    dim.items.forEach(msg => {
      const b = document.createElement("button"); b.type = "button"; b.className = "dim-card"; b.textContent = msg; b.setAttribute("aria-label", "Dire : " + msg);
      b.addEventListener("click", () => speakOut(msg));
      grid.appendChild(b);
    });
    host.appendChild(grid);
  });
}
// « Je fais le point » — parcours 4 étapes (Pause / Je regarde / Je choisis / J'agis).
// Constructeur de PLAN : tap = ajoute + dit ; navigation libre ; « Dire maintenant » toujours dispo ;
// deux modes (maintenant / préparer) ; aucun jugement, aucun historique auto ; plans gardés = opt-in.
function renderPointInto(host) {
  host.innerHTML = "";
  const mode = () => state.settings.pointMode === "prep" ? "prep" : "now";
  const sel = {}; POINT_STEPS.forEach(s => sel[s.id] = []);
  let cur = Math.min(Math.max(parseInt(state.settings.pointStep) || 0, 0), POINT_STEPS.length - 1);

  // Deux modes : maintenant / préparer une situation.
  const modeRow = document.createElement("div"); modeRow.className = "point-mode";
  const mkMode = (id, lab, pic) => { const b = document.createElement("button"); b.type = "button"; b.className = "point-mode-btn" + (mode() === id ? " on" : ""); b.append(pictoImg(pic, "pm-ico"), Object.assign(document.createElement("span"), { textContent: lab })); b.addEventListener("click", () => { state.settings.pointMode = id; saveSettings(); modeRow.querySelectorAll(".point-mode-btn").forEach(x => x.classList.remove("on")); b.classList.add("on"); refreshIntros(); }); return b; };
  modeRow.append(mkMode("now", "Maintenant", "maintenant"), mkMode("prep", "Préparer une situation", "calendrier"));
  host.appendChild(modeRow);

  const chips = document.createElement("div"); chips.className = "point-steps";
  const panels = document.createElement("div"); panels.className = "point-panels";
  const stepIntroEls = [];
  function refreshIntros() {
    stepIntroEls.forEach(({ el, step }) => el.textContent = step.intro[mode()]);
  }
  const plan = () => { const out = []; POINT_STEPS.forEach(s => sel[s.id].forEach(t => out.push(t))); return out; };
  const preview = document.createElement("div"); preview.className = "pain-preview";
  function updatePreview() { const p = plan(); preview.textContent = p.length ? p.join(" ") : "Choisis pour commencer."; preview.classList.toggle("empty", !p.length); }
  function toggleCard(stepId, phrase, btn) {
    const arr = sel[stepId]; const i = arr.indexOf(phrase);
    if (i >= 0) { arr.splice(i, 1); btn.classList.remove("on"); }
    else { arr.push(phrase); btn.classList.add("on"); speakOut(phrase); }
    updatePreview();
  }
  POINT_STEPS.forEach((step, i) => {
    const chip = document.createElement("button"); chip.type = "button"; chip.className = "point-step";
    chip.append(pictoImg(step.pic, "ps-ico"), Object.assign(document.createElement("span"), { className: "ps-lab", textContent: step.title }));
    chip.addEventListener("click", () => showStep(i)); chips.appendChild(chip);
    const panel = document.createElement("div"); panel.className = "point-panel";
    const ph = document.createElement("h3"); ph.className = "pain-h dim-h"; ph.append(pictoImg(step.pic, "dim-h-ico"), document.createTextNode(step.title)); panel.appendChild(ph);
    const si = Object.assign(document.createElement("p"), { className: "point-step-intro" }); stepIntroEls.push({ el: si, step }); panel.appendChild(si);
    const addCards = (items, cls) => { const w = document.createElement("div"); w.className = cls || "point-cards"; items.forEach(ph => { const b = document.createElement("button"); b.type = "button"; b.className = "point-card"; b.textContent = ph; b.addEventListener("click", () => toggleCard(step.id, ph, b)); w.appendChild(b); }); panel.appendChild(w); };
    if (step.groups) step.groups.forEach(gr => { panel.appendChild(Object.assign(document.createElement("h4"), { className: "point-grp", textContent: gr.g })); addCards(gr.items); });
    // « Je ne sais pas » à chaque étape + phrases méta, comme les autres cartes (pas différenciées).
    const items = (step.items || []).concat(["Je ne sais pas."]).concat(step.always || []);
    addCards(items);
    panels.appendChild(panel);
  });
  host.append(chips, panels);

  const nav = document.createElement("div"); nav.className = "point-nav";
  const prevBtn = document.createElement("button"); prevBtn.type = "button"; prevBtn.className = "btn-secondary"; prevBtn.textContent = "‹ Précédent"; prevBtn.addEventListener("click", () => showStep(cur - 1));
  const nextBtn = document.createElement("button"); nextBtn.type = "button"; nextBtn.className = "btn-secondary"; nextBtn.textContent = "Suivant ›"; nextBtn.addEventListener("click", () => showStep(cur + 1));
  nav.append(prevBtn, nextBtn); host.appendChild(nav);
  function showStep(i) {
    cur = Math.min(Math.max(i, 0), POINT_STEPS.length - 1); state.settings.pointStep = cur; saveSettings();
    panels.querySelectorAll(".point-panel").forEach((p, idx) => p.hidden = idx !== cur);
    chips.querySelectorAll(".point-step").forEach((c, idx) => c.classList.toggle("on", idx === cur));
    prevBtn.disabled = cur === 0; nextBtn.disabled = cur === POINT_STEPS.length - 1;
  }

  // Barre plan (toujours visible) : aperçu + Dire maintenant + Garder + Effacer.
  const bar = document.createElement("div"); bar.className = "pain-bar point-bar";
  const sayNow = document.createElement("button"); sayNow.type = "button"; sayNow.className = "btn-primary pain-dire"; sayNow.textContent = "🔊 Dire maintenant";
  sayNow.addEventListener("click", () => { const p = plan(); if (p.length) speakOut(p.join(" ")); });
  const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "btn-secondary"; saveBtn.textContent = "💾 Garder ce plan";
  const clr = document.createElement("button"); clr.type = "button"; clr.className = "btn-secondary"; clr.textContent = "↺ Effacer";
  clr.addEventListener("click", () => { POINT_STEPS.forEach(s => sel[s.id] = []); panels.querySelectorAll(".point-card.on").forEach(n => n.classList.remove("on")); updatePreview(); });
  bar.append(preview, sayNow, saveBtn, clr); host.appendChild(bar);

  // Plans gardés (opt-in, pas un historique automatique).
  const saved = document.createElement("div"); saved.className = "point-saved";
  function renderSaved() {
    saved.innerHTML = ""; const list = state.settings.pointPlans || [];
    if (!list.length) { saved.hidden = true; return; } saved.hidden = false;
    saved.appendChild(Object.assign(document.createElement("span"), { className: "point-saved-lab", textContent: "Mes plans gardés :" }));
    list.forEach((pl, idx) => {
      const c = document.createElement("span"); c.className = "point-saved-chip";
      const t = document.createElement("button"); t.type = "button"; t.className = "point-saved-say"; t.textContent = "▶ " + pl.name; t.setAttribute("aria-label", "Dire : " + pl.text); t.addEventListener("click", () => speakOut(pl.text));
      const x = document.createElement("button"); x.type = "button"; x.className = "point-saved-del"; x.textContent = "✕"; x.setAttribute("aria-label", "Supprimer ce plan"); x.addEventListener("click", () => { state.settings.pointPlans.splice(idx, 1); saveSettings(); renderSaved(); });
      c.append(t, x); saved.appendChild(c);
    });
  }
  saveBtn.addEventListener("click", () => {
    const p = plan(); if (!p.length) return; const text = p.join(" ");
    const name = text.length > 26 ? text.slice(0, 26) + "…" : text;
    state.settings.pointPlans = state.settings.pointPlans || [];
    state.settings.pointPlans.unshift({ name, text });
    if (state.settings.pointPlans.length > 12) state.settings.pointPlans.pop();
    saveSettings(); renderSaved();
  });
  host.appendChild(saved);

  refreshIntros(); showStep(cur); updatePreview(); renderSaved();
}

/* ---------- Clavier / littératie ---------- */
// Dispositions de clavier — organisées en RANGÉES (mémoire motrice stable).
// La personne CHOISIT sa disposition (Réglages) ; jamais imposée, jamais changée toute seule.
const KB_LAYOUTS = {
  azerty: ["azertyuiop", "qsdfghjklm", "wxcvbn"],
  qwerty: ["qwertyuiop", "asdfghjkl", "zxcvbnm"],
  qwertz: ["qwertzuiop", "asdfghjkl", "yxcvbnm"],
  abc:    ["abcdefg", "hijklmn", "opqrstu", "vwxyz"],
};
// Clavier PHONOLOGIQUE (littératie émergente) : on tape des SONS / graphèmes fréquents
// du français (touches multi-lettres). Aide à entrer dans l'écrit par les sons.
// Aligné sur le SYSTÈME PHONOLOGIQUE FRANÇAIS (36 phonèmes) :
//  · voyelles orales, · voyelles nasales, · semi-voyelles, · consonnes/digraphes.
const KB_SONS = [
  ["a", "i", "o", "u", "é", "è"],          // voyelles orales de base
  ["ê", "e", "eu", "œu", "ou", "au"],       // + [ø]/[œ]/[ə]/[u]/[o]
  ["an", "en", "on", "in", "ain", "un"],    // voyelles NASALES (an≈en, in≈ain)
  ["oi", "oin", "ui", "ill", "ien", "ion"], // semi-voyelles [w]/[ɥ]/[j] et complexes
  ["ch", "gn", "ph", "gu", "qu", "ç"],      // consonnes / digraphes
];
const ACCENTS = ["é", "è", "ê", "à", "â", "î", "ï", "ô", "û", "ù", "ç", "œ", "-"];  // lettres accentuées FR
const KB_PUNCT = ["'", "?", "!", ",", "."];   // apostrophe + ponctuation expressive, visibles (pas de sous-menu)
const KB_LABELS = { azerty: "AZERTY", qwerty: "QWERTY", qwertz: "QWERTZ", abc: "ABC", sons: "Sons" };
const PRED_WORDS = ["je","tu","il","elle","iel","nous","vous","veux","peux","vais","aime","aller","manger","boire","content","aujourd'hui","bonjour","merci","maison","maman","papa","encore","s'il te plaît","d'accord","pourquoi","comment","beaucoup","travail","copain","musique","dehors","fatigué","école","toilette","tranquille","besoin"];
const KB_VOWELS = new Set("aeiouy".split(""));   // littératie (opt-in) : repère discret commun des voyelles
function mkKey(ch, accent) {
  const b = document.createElement("button");
  b.className = "key" + (accent ? " key-accent" : "");
  // Option littératie voyelles (défaut OFF, à tester) : repère discret sur les voyelles du clavier PRINCIPAL
  // (pas la rangée d'accents). Jamais une couleur par lettre : un seul repère commun.
  if (state.settings.litVowels && !accent && KB_VOWELS.has(norm(ch))) b.classList.add("key-vowel");
  b.dataset.key = ch; b.textContent = ch;
  b.setAttribute("aria-label", ch === "-" ? "trait d'union" : ch === "'" ? "apostrophe" : (accent ? "signe " : "lettre ") + ch);
  b.addEventListener("click", () => {
    typeChar(ch);
    // Profil tout-petit : feedback d'action — la touche réagit (pulsation) et la lettre s'affiche
    // EN GRAND dans la zone de grossissement fixe (auto-gaté : rien hors tout-petit). La couleur/retour
    // relie « j'ai touché » → « cette lettre est sélectionnée » (jamais un code à apprendre par lettre).
    if (ch !== " ") { if (typeof tapPulse === "function") tapPulse(b); if (typeof showZoom === "function") showZoom(null, ch); }
    if (findState && findState.mode === "kb") onFindPossibleHit(ch);   // jeu « chercher une lettre »
  });
  return b;
}
function renderKeyboard() {
  const keys = document.getElementById("keys");
  const kb = document.getElementById("keyboard");
  keys.innerHTML = "";
  if (kb) kb.classList.toggle("kb-big", !!state.settings.kbBig);   // grandes touches (accès moteur/visuel)
  const isSons = state.settings.kbLayout === "sons";
  const layout = isSons ? KB_SONS : (KB_LAYOUTS[state.settings.kbLayout] || KB_LAYOUTS.azerty);
  layout.forEach(rowDef => {
    const row = document.createElement("div"); row.className = "kb-row" + (isSons ? " kb-sons" : "");
    const chars = Array.isArray(rowDef) ? rowDef : rowDef.split("");   // « sons » = touches multi-lettres
    chars.forEach(ch => row.appendChild(mkKey(ch)));
    keys.appendChild(row);
  });
  // Rangée accents + ponctuation FR : toujours visibles, sans sous-menu (é, ç, ', ?, !…).
  const arow = document.createElement("div"); arow.className = "kb-row kb-accents";
  [...ACCENTS, ...KB_PUNCT].forEach(ch => arow.appendChild(mkKey(ch, true)));
  keys.appendChild(arow);
  // Sélecteur de disposition (la personne choisit ; on ne bascule jamais tout seul).
  const sw = document.createElement("div"); sw.className = "kb-row kb-switch";
  Object.keys(KB_LABELS).forEach(id => {
    const b = document.createElement("button");
    b.className = "kb-layout-btn" + (state.settings.kbLayout === id ? " is-active" : "");
    b.textContent = KB_LABELS[id]; b.setAttribute("aria-pressed", String(state.settings.kbLayout === id));
    b.addEventListener("click", () => { state.settings.kbLayout = id; saveSettings(); renderKeyboard(); });
    sw.appendChild(b);
  });
  const big = document.createElement("button");
  big.className = "kb-layout-btn" + (state.settings.kbBig ? " is-active" : "");
  big.textContent = "Grandes touches"; big.setAttribute("aria-pressed", String(!!state.settings.kbBig));
  big.addEventListener("click", () => { state.settings.kbBig = !state.settings.kbBig; saveSettings(); renderKeyboard(); });
  sw.appendChild(big);
  const findBtn = document.createElement("button");
  findBtn.className = "kb-layout-btn"; findBtn.textContent = "🔎 Chercher une lettre";
  findBtn.title = "Jeu : retrouver une lettre avec des indices — jamais un test, on arrête quand on veut";
  findBtn.addEventListener("click", () => startLetterFind());
  sw.appendChild(findBtn);
  keys.appendChild(sw);
  if (window.CT && CT.warmDict) CT.warmDict();   // charge le dictionnaire pour la prédiction
  if (state.settings.pictoAssist !== false && window.CT && CT.warmSym) CT.warmSym().then(() => updatePrediction());   // pictos des suggestions
  updatePrediction();
  if (findState && findState.mode === "kb") applyFindHint();   // re-poser les indices après reconstruction du clavier
}
function typeChar(ch) {
  state.writeBuffer += ch;
  if (ch === " ") commitWord(); else showBufferInMessage();
  updatePrediction();
}
function showBufferInMessage() {
  const box = document.getElementById("message");
  renderMessage();
  if (state.writeBuffer.trim()) {
    const el = document.createElement("span"); el.className = "msg-token"; el.style.opacity = ".6";
    el.textContent = state.writeBuffer; box.appendChild(el); box.scrollLeft = box.scrollWidth;
  }
}
// Un mot TAPÉ au clavier devient un jeton avec SON PICTO (homogénéité avec la grille) — même
// résolveur Mulberry que partout. La personne DÉCIDE : si elle a coupé les pictos (pictoAssist=false),
// le mot reste en texte seul. (Pauline : le picto est un repère visuel, comme un logo d'appli.)
function typedWordToken(w) {
  const tok = { label: w, color: "petit" };
  if (state.settings.pictoAssist !== false && window.CT && CT.pictoFor) {
    const sym = CT.pictoFor(w);
    if (sym) tok.sym_path = sym;
  }
  return tok;
}
function commitWord() {
  const w = state.writeBuffer.trim();
  state.writeBuffer = "";
  if (w) { state.tokens.push(typedWordToken(w)); renderMessage(); }
}
function updatePrediction() {
  const box = document.getElementById("prediction");
  box.innerHTML = "";
  const cap = state.settings.predCount ?? 8;   // 0 = prédiction désactivée (choix de la personne)
  if (!cap) { box.hidden = true; return; }
  box.hidden = false;
  const frag = state.writeBuffer.trim();
  const nf = norm(frag);
  let list;
  if (!frag) {
    list = PRED_WORDS.slice(0, cap);
  } else {
    const core = PRED_WORDS.filter(w => norm(w).startsWith(nf));         // fréquents d'abord
    const dict = (window.CT && CT.predict) ? CT.predict(frag, 14) : [];  // puis tout le dictionnaire (4000+)
    list = [...core, ...dict];
  }
  const seen = new Set(), uniq = [];
  for (const w of list) { const k = norm(w); if (!seen.has(k)) { seen.add(k); uniq.push(w); } if (uniq.length >= cap) break; }
  const withPicto = state.settings.pictoAssist !== false && window.CT && CT.pictoFor;
  uniq.forEach(w => {
    const b = document.createElement("button"); b.className = "pred";
    // Picto à côté du mot tapé (littératie : associer l'écrit et l'image), si activé et connu.
    if (withPicto) { const sp = CT.pictoFor(w); if (sp) { const im = document.createElement("img"); im.className = "pred-pic"; im.src = sp; im.alt = ""; b.appendChild(im); } }
    b.appendChild(Object.assign(document.createElement("span"), { className: "pred-w", textContent: w }));
    b.addEventListener("click", () => {
      state.writeBuffer = "";
      state.tokens.push(typedWordToken(w)); renderMessage();
      if (state.settings.speakOnTap) speak(w);
      updatePrediction();
    });
    box.appendChild(b);
  });
}

/* ---------- Édition (délègue à l'éditeur riche custom.js) ---------- */
function currentBoardId() {
  if (state.view === "advocacy") return "advocacy";
  return state.board;
}
function editItem(item, opts) {
  if (window.CT) return CT.openEditor(item, { boardId: currentBoardId(), isNew: false, opts });
  Promise.resolve(window.ctPrompt ? ctPrompt("Modifier le texte :", labelFor(item)) : null).then(val => {   // secours
    if (val == null) return;
    if (val.trim() === "") state.overrides.hidden.push(item.id);
    else state.overrides.edited[item.id] = { label: val.trim() };
    saveOverrides(); refresh();
  });
}
function addWord() {
  if (window.CT) return CT.openEditor(null, { boardId: currentBoardId(), isNew: true });
}

/* ---------- Réglages ---------- */
function escapeHtml(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
function profileTrashHTML() {
  const r = profilesReg(); const t = r.trash || [];
  if (!t.length) return "";
  return `<div class="trash-box"><div class="trash-head">🗑️ Corbeille — <span class="hint" style="font-weight:400">rien n'est effacé, tout reste restaurable</span></div>` +
    t.map(p => `<div class="prof-row"><span class="prof-name">${escapeHtml(p.name)}</span>` +
      `<button class="btn-secondary" data-action="restore" data-id="${escapeHtml(p.id)}">↺ Restaurer</button></div>`).join("") +
    `</div>`;
}
function profileRowsHTML() {
  const r = profilesReg();
  return r.list.map(p => {
    const active = p.id === r.active;
    return `<div class="prof-row${active ? " on" : ""}">
      <span class="prof-name">${active ? "● " : ""}${escapeHtml(p.name)}${p.pin ? " 🔒" : ""}</span>
      <span class="prof-actions">
        ${active ? '<em class="prof-cur">profil actuel</em>' : `<button class="btn-secondary" data-action="switch" data-id="${escapeHtml(p.id)}">Basculer</button>`}
        <button class="btn-secondary" data-action="rename" data-id="${escapeHtml(p.id)}">Renommer</button>
        <button class="btn-secondary" data-action="pin" data-id="${escapeHtml(p.id)}">${p.pin ? "Retirer le code" : "Code"}</button>
        ${p.id !== "default" ? `<button class="btn-secondary danger" data-action="del" data-id="${escapeHtml(p.id)}">Supprimer</button>` : ""}
      </span></div>`;
  }).join("");
}
function renderSettings() {
  const p = document.getElementById("settings");
  const s = state.settings;
  // Caractéristiques OBSERVABLES (nom + langue/accent), jamais « meilleure/normale/plus humaine ».
  const partnerName = (s.partnerVoice && s.partnerVoice.systemVoiceName) || s.modelVoice || "";
  const modelVoiceOpts = VOICES.map(v => `<option value="${v.name}" ${v.name === partnerName ? "selected" : ""}>${v.name}</option>`).join("");
  const pv = s.partnerVoice || (s.partnerVoice = { systemVoiceName: "", rate: 1, pitch: 1, volume: 1 });
  const _asX = x => (Math.round(x * 10) / 10).toFixed(1).replace(".0", "") + "×";
  const _asPct = x => Math.round(x * 100) + " %";
  p.innerHTML = `
    <h2>⚙️ Réglages</h2>
    <div class="set-mode" role="group" aria-label="Niveau de réglages">
      <button id="set-mode-simple" class="set-seg ${s.settingsExpert?"":"is-on"}" aria-pressed="${s.settingsExpert?"false":"true"}">Simple</button>
      <button id="set-mode-expert" class="set-seg ${s.settingsExpert?"is-on":""}" aria-pressed="${s.settingsExpert?"true":"false"}">Tout afficher</button>
    </div>
    <p class="hint" style="max-width:700px;margin:0 auto 6px">Mode <strong>Simple</strong> : juste l'essentiel. <strong>Tout afficher</strong> : chaque réglage, pour aller plus loin.</p>
    <div class="row" style="border:0"><label>Configuration</label>
      <button class="btn-secondary" id="set-reconfigure">${uiIcon('refresh')} Refaire le réglage de départ</button></div>
    <div class="row" style="border:0"><label>Accompagnement</label>
      <button class="btn-secondary" id="set-partner">${uiIcon('megaphone')} Conseils au partenaire</button></div>
    <div class="row" style="border:0"><label>Modélisation <span class="hint">(montrer les mots)</span></label>
      <button class="btn-secondary" id="set-model">${uiIcon('hand')} Mode modélisation</button></div>
    <div class="row" style="border:0"><label>Prise en main</label>
      <button class="btn-secondary" id="set-coach">${uiIcon('bubble')} Revoir les bulles d'aide</button></div>
    <div class="row" style="border:0"><label>Mes mots</label>
      <button class="btn-secondary" id="set-stats">${uiIcon('chart')} Mes mots les plus utilisés</button></div>
    <div class="row" style="border:0"><label>Vocabulaire francophone</label>
      <button class="btn-secondary" id="set-packs">${uiIcon('globe')} Régions &amp; cultures</button></div>

    <h3>Essentiel</h3>
    <div class="row"><label for="set-font">Taille du texte</label>
      <input id="set-font" type="range" min="0.9" max="1.6" step="0.1" value="${s.fontScale}"></div>
    <div class="row"><label for="set-symscale">Taille des images</label>
      <input id="set-symscale" type="range" min="0.7" max="1.8" step="0.1" value="${s.symbolScale}"></div>
    <div class="row"><label for="set-theme">Couleurs sombres (reposent les yeux)</label>
      <input id="set-theme" type="checkbox" ${s.theme==="dark"?"checked":""}></div>
    <div class="row"><label for="set-calmtint">Intensité des couleurs des cases <span class="hint">(doux ↔ vif)</span></label>
      <input id="set-calmtint" type="range" min="0" max="60" step="5" value="${s.calmTint==null?26:s.calmTint}"></div>
    <p class="hint" style="margin:-2px 0 8px">Vers la gauche&nbsp;: couleurs très douces (reposantes). Vers la droite&nbsp;: couleurs plus vives. Le repère de couleur reste toujours visible (liseré).</p>
    <div class="row"><label for="set-contrast">Plus de contraste (mieux voir)</label>
      <input id="set-contrast" type="checkbox" ${s.contrast==="high"?"checked":""}></div>
    <div class="row"><label for="set-pagesize">Nombre de cases par page <span class="hint">(moins = plus reposant)</span></label>
      <select id="set-pagesize">
        <option value="49" ${s.pageSize==49?"selected":""}>49 — grandes cases, reposant</option>
        <option value="66" ${s.pageSize==66?"selected":""}>66</option>
        <option value="84" ${s.pageSize==84?"selected":""}>84</option>
        <option value="108" ${s.pageSize==108?"selected":""}>108 — beaucoup de mots</option>
      </select></div>
    <p class="hint" style="margin:-2px 0 8px">Change-le quand tu veux, selon ton envie et ta fatigue. Au-delà de la page, la catégorie passe sur plusieurs pages (‹ ›), jamais de scroll sans fin.</p>
    <h3>Voix du partenaire de communication</h3>
    <p class="hint" style="margin:0 0 6px">Voix distincte de celle de la personne : utilisée quand un proche <strong>montre</strong> les mots (modélisation), pour ne pas être confondue avec la voix de la personne.</p>
    <div class="row"><label for="set-modelvoice">Voix du partenaire</label>
      <select id="set-modelvoice"><option value="">(automatique — différente de celle de la personne)</option>${modelVoiceOpts}</select></div>
    <label class="voice-fine-row"><span class="vf-lab">Vitesse</span>
      <input type="range" id="pv-rate" min="0.7" max="1.4" step="0.1" value="${pv.rate}">
      <span class="vf-val" id="pv-rate-val">${_asX(pv.rate)}</span></label>
    <label class="voice-fine-row"><span class="vf-lab">Hauteur</span>
      <input type="range" id="pv-pitch" min="0.7" max="1.4" step="0.1" value="${pv.pitch}">
      <span class="vf-val" id="pv-pitch-val">${_asX(pv.pitch)}</span></label>
    <label class="voice-fine-row"><span class="vf-lab">Volume</span>
      <input type="range" id="pv-vol" min="0.3" max="1" step="0.1" value="${pv.volume}">
      <span class="vf-val" id="pv-vol-val">${_asPct(pv.volume)}</span></label>
    <p class="hint" style="margin:2px 0 6px">Règle la vitesse, la hauteur et le volume pour bien distinguer à l'oreille la voix du partenaire de celle de la personne. Libre — aucun réglage n'est lié au genre.</p>
    <div class="row" style="border:0;flex-wrap:wrap;gap:8px"><span class="hint">Écouter cette voix&nbsp;:</span>
      <button class="btn-secondary" id="set-modelvoice-test">${uiIcon('speaker')} Écouter un aperçu</button>
      <button class="btn-secondary" id="pv-reset">Rétablir la voix du partenaire</button></div>
    <div class="row" style="flex-wrap:wrap;gap:6px"><label for="set-appname">Le nom de mon outil
        <span class="hint">(c'est ta voix — nomme-la comme tu veux)</span></label>
      <input id="set-appname" type="text" maxlength="40" placeholder="Ma communication"
        value="${escapeHtml(s.appName || "")}" style="flex:1;min-width:160px"></div>
    <div class="row" style="border:0;flex-wrap:wrap;gap:8px"><label>Comment tu utilises l'outil</label>
      <div style="display:flex;gap:8px;flex-wrap:wrap">
        <button class="btn-secondary ${!s.scan&&!s.dwell?'is-on':''}" id="acc-touch">${uiIcon('finger')} Au doigt</button>
        <button class="btn-secondary ${s.scan?'is-on':''}" id="acc-switch">${uiIcon('switch')} Contacteur</button>
        <button class="btn-secondary ${s.dwell?'is-on':''}" id="acc-eye">${uiIcon('eye')} Regard</button>
      </div></div>
    <p class="hint" style="margin:-2px 0 8px">Bascule à tout moment : au doigt/à l'écran, avec un <strong>contacteur</strong> (défilement automatique), ou à la <strong>commande oculaire</strong> (cibles agrandies). Réglages fins dans « Tout afficher ».</p>
    <div class="row"><label for="set-catmode">Barre de catégories</label>
      <select id="set-catmode">
        <option value="colonne" ${(s.catMode||"colonne")==="colonne"?"selected":""}>Colonne à gauche</option>
        <option value="bandeau" ${s.catMode==="bandeau"?"selected":""}>Bandeau en haut</option>
        <option value="popup" ${s.catMode==="popup"?"selected":""}>Pop-up (bouton 🗂️ — moins envahissant)</option>
      </select></div>
    <div class="row"><label for="set-catfolders">Catégories en dossiers <span class="hint">(~19 dossiers au lieu de 68 — décoche pour la longue liste)</span></label>
      <input id="set-catfolders" type="checkbox" ${s.catFolders!==false?"checked":""}></div>
    <div class="row"><label for="set-popuppos">Position des fenêtres &amp; pop-up <span class="hint">(selon ton confort)</span></label>
      <select id="set-popuppos">
        <option value="bottom" ${(s.popupPos||"bottom")==="bottom"?"selected":""}>En bas</option>
        <option value="center" ${s.popupPos==="center"?"selected":""}>Au milieu</option>
        <option value="top" ${s.popupPos==="top"?"selected":""}>En haut</option>
        <option value="left" ${s.popupPos==="left"?"selected":""}>À gauche</option>
        <option value="right" ${s.popupPos==="right"?"selected":""}>À droite</option>
      </select></div>
    <p class="hint" style="margin:-2px 0 8px">Le pop-up « Catégories » et toutes les fenêtres apparaissent à l'endroit choisi — pour réduire le déplacement du regard/de la main quand tu es fatiguée.</p>
    <div class="row"><label for="set-tips">Aides et textes de rappel</label>
      <input type="checkbox" id="set-tips" ${!s.tipsOff?"checked":""}></div>
    <div class="row"><label for="set-modelbtn">Bouton « Montrer » <span class="hint">(pour apprendre à utiliser l'outil)</span></label>
      <input type="checkbox" id="set-modelbtn" ${s.showModelBtn!==false?"checked":""}></div>
    <div class="row"><label for="set-encourage">Encouragements <span class="hint">(petits messages positifs)</span></label>
      <input type="checkbox" id="set-encourage" ${s.encourage?"checked":""}></div>
    <div class="row"><label for="set-readalong">Lecture accompagnée <span class="hint">(surligne chaque mot pendant la lecture — voix du système)</span></label>
      <input type="checkbox" id="set-readalong" ${s.readAlong!==false?"checked":""}></div>
    <div class="row"><label for="set-decoder">Décodeur de mots « 🔤 Sons » <span class="hint">(touche le bouton 🔤 en haut, ou un mot de l'aperçu, pour le décomposer)</span></label>
      <input type="checkbox" id="set-decoder" ${s.decoder!==false?"checked":""}></div>
    <div class="row"><label for="set-pictoassist">Pictos à côté des mots <span class="hint">(clavier, phrases des scènes et du cahier de vie)</span></label>
      <input type="checkbox" id="set-pictoassist" ${s.pictoAssist!==false?"checked":""}></div>
    <p class="hint" style="margin:-2px 0 8px">Les petites bulles d'aide et les bandeaux de rappel. Tu peux aussi les couper directement en touchant « Ne plus afficher » sur une bulle.</p>

    <h3>Voix</h3>
    <p class="hint" style="margin:0 0 8px">Choisis la voix, écoute un aperçu, installe une voix locale ou supprime ses fichiers. Par défaut : la voix du système (hors-ligne).</p>
    <div id="voice-manager" class="voice-manager"></div>

    <h3>Prononciation <span class="hint">(identité vocale)</span></h3>
    <p class="hint" style="margin:0 0 8px">Corrige la façon de dire un prénom, un lieu, un sigle (CAA), une marque, un mot créole ou anglais. La <strong>voix du système</strong> suit le « texte prononcé » (ré-écriture) ; les <strong>phonèmes</strong> ne servent qu'avec la voix locale Piper.</p>
    <div id="pron-manager" class="pron-manager"></div>

    <h3 class="set-adv">Voix &amp; façon de parler</h3>
    <div class="row set-adv"><label for="set-genre">Quand je parle de moi</label>
      <select id="set-genre">
        <option value="neutre" ${s.genreLocuteur==="neutre"?"selected":""}>Neutre (par défaut)</option>
        <option value="feminin" ${s.genreLocuteur==="feminin"?"selected":""}>Au féminin (je suis contente…)</option>
        <option value="masculin" ${s.genreLocuteur==="masculin"?"selected":""}>Au masculin (je suis content…)</option>
      </select></div>
    <div class="row set-adv"><label for="set-registre">Façon de dire «&nbsp;non&nbsp;»</label>
      <select id="set-registre">
        <option value="ecrit" ${s.registre!=="oral"?"selected":""}>Soignée : « je ne veux pas »</option>
        <option value="oral" ${s.registre==="oral"?"selected":""}>Comme à l'oral : « je veux pas »</option>
      </select></div>
    <div class="row set-adv"><label for="set-expgrammar">Grammaire avancée <span class="hint">(expérimental — mieux gérer « je le lui donne » ; se charge à la 1re activation)</span></label>
      <input id="set-expgrammar" type="checkbox" ${s.expGrammar?"checked":""}></div>
    <h3 class="set-adv">Apparence &amp; diversité</h3>
    <div class="row set-adv"><label for="set-pictostyle">Style des pictos maison</label>
      <select id="set-pictostyle">
        <option value="vif" ${s.pictoStyle!=="sepia"?"selected":""}>Vif (couleurs vives)</option>
        <option value="sepia" ${s.pictoStyle==="sepia"?"selected":""}>Vintage (sépia)</option>
      </select></div>
    <p class="hint set-adv" style="margin:-2px 0 8px">Change l'allure des pictogrammes créés pour l'appli (les autres pictos ne changent pas). Choix personnel, réversible.</p>
    <div class="row set-adv"><label for="set-skin">Teinte de peau des personnages</label>
      <select id="set-skin">
        ${Object.keys(SKIN_LABELS).map(k=>`<option value="${k}" ${s.skinTone===k?"selected":""}>${SKIN_LABELS[k]}</option>`).join("")}
      </select></div>
    <div class="row set-adv"><label for="set-hair">Couleur des cheveux des personnages</label>
      <select id="set-hair">
        ${Object.keys(HAIR_LABELS).map(k=>`<option value="${k}" ${s.hairTone===k?"selected":""}>${HAIR_LABELS[k]}</option>`).join("")}
      </select></div>
    <p class="hint set-adv" style="margin:-2px 0 8px">Peau et cheveux se règlent aussi <strong>case par case</strong> dans «&nbsp;Modifier&nbsp;».</p>
    <div class="row set-adv"><label for="set-lang2">Ajouter une 2ᵉ langue</label>
      <select id="set-lang2">
        ${LANG2.map(([c,l])=>`<option value="${c}" ${s.lang2===c?"selected":""}>${l}</option>`).join("")}
      </select></div>
    <p class="hint set-adv" style="margin:-2px 0 8px">S'affiche et se dit aussi dans la 2ᵉ langue. Se complète <strong>case par case</strong> dans «&nbsp;Modifier&nbsp;».</p>

    <h3 class="set-adv">Aides à la construction de phrases</h3>
    <div class="row set-adv"><label for="set-stable">Garder chaque mot toujours à la même place</label>
      <input id="set-stable" type="checkbox" ${s.stablePositions?"checked":""}></div>
    <p class="hint set-adv" style="margin:-2px 0 8px">Recommandé&nbsp;: rien ne bouge, la main retrouve les mots les yeux fermés.</p>
    <p class="hint set-adv" style="margin:-2px 0 8px">Après un verbe (ex. «&nbsp;aller&nbsp;»), la colonne «&nbsp;En lien&nbsp;» fait apparaître en tête des <strong>portes</strong>&nbsp;: des raccourcis vers les catégories utiles pour poursuivre la phrase (Lieux, Transports…). La barre de catégories s'éclaire aussi. <strong>Aucune case ne bouge&nbsp;</strong>: les portes s'ajoutent, elles ne remplacent rien.</p>
    <div class="row set-adv"><label for="set-anchorlearn">Aide dans « Promenade d'idées » <span class="hint">(une idée ouvre des chemins vers d'autres)</span></label>
      <input type="checkbox" id="set-anchorlearn" ${s.anchorLearn?"checked":""}></div>

    <details class="adv set-adv"><summary>Réglages avancés</summary>

      <p class="hint">Le choix de la voix, la vitesse, la hauteur et le volume se règlent dans la section <strong>« Voix »</strong> plus haut (une carte par voix).</p>
      <div class="row"><label for="set-moodauto">Adapter le ton à l'émotion posée</label>
        <input id="set-moodauto" type="checkbox" ${s.moodAuto?"checked":""}></div>
      <div class="row"><label for="set-tbmode">Barre de ton</label>
        <select id="set-tbmode">
          <option value="auto" ${s.toneBarMode!=="always"&&s.toneBarMode!=="never"?"selected":""}>Auto (apparaît avec une émotion)</option>
          <option value="always" ${s.toneBarMode==="always"?"selected":""}>Toujours affichée</option>
          <option value="never" ${s.toneBarMode==="never"?"selected":""}>Jamais</option>
        </select></div>
      <div class="row"><label for="set-gbmode">Barre grammaire (temps · féminin · pluriel)</label>
        <select id="set-gbmode">
          <option value="auto" ${s.grammarBarMode!=="always"&&s.grammarBarMode!=="never"?"selected":""}>Apparaît quand c'est utile</option>
          <option value="always" ${s.grammarBarMode==="always"?"selected":""}>Toujours visible (même sur l'accueil)</option>
          <option value="never" ${s.grammarBarMode==="never"?"selected":""}>Jamais l'afficher (libère la place)</option>
        </select></div>

      <h3>Grille &amp; catégories</h3>
      <div class="row"><label for="set-cols">Niveau de grille</label>
        <select id="set-cols">
          <option value="7" ${s.cols==7?"selected":""}>Confort — 7 colonnes (49 cases mini)</option>
          <option value="9" ${s.cols==9?"selected":""}>Standard — 9 colonnes</option>
          <option value="12" ${s.cols==12?"selected":""}>Dense — 12 colonnes</option>
        </select></div>
      <div class="row"><label for="set-catlayout">Disposition des catégories</label>
        <select id="set-catlayout">
          <option value="vertical" ${s.categoryLayout!=="horizontal"?"selected":""}>Verticale — colonne à gauche</option>
          <option value="horizontal" ${s.categoryLayout==="horizontal"?"selected":""}>Horizontale — bandeau en haut</option>
        </select></div>
      <div class="row"><label for="set-kbdhome">Clavier accessible depuis l'accueil</label>
        <input id="set-kbdhome" type="checkbox" ${s.keyboardOnHome?"checked":""}></div>
      <div class="row"><label for="set-kblayout">Disposition du clavier</label>
        <select id="set-kblayout">
          <option value="azerty" ${s.kbLayout==="azerty"?"selected":""}>AZERTY (français)</option>
          <option value="qwerty" ${s.kbLayout==="qwerty"?"selected":""}>QWERTY</option>
          <option value="qwertz" ${s.kbLayout==="qwertz"?"selected":""}>QWERTZ</option>
          <option value="abc" ${s.kbLayout==="abc"?"selected":""}>ABC (ordre alphabétique)</option>
          <option value="sons" ${s.kbLayout==="sons"?"selected":""}>Sons (littératie : ch, ou, on…)</option>
        </select></div>
      <div class="row"><label for="set-kbbig">Clavier à grandes touches</label>
        <input id="set-kbbig" type="checkbox" ${s.kbBig?"checked":""}></div>
      <div class="row"><label for="set-litvowels">Repère discret sur les voyelles <span class="hint">(littératie, à essayer — pas un réglage par défaut)</span></label>
        <input id="set-litvowels" type="checkbox" ${s.litVowels?"checked":""}></div>
      <div class="row"><label for="set-predcount">Prédiction de mots</label>
        <select id="set-predcount">
          <option value="0" ${s.predCount===0?"selected":""}>Aucune</option>
          <option value="3" ${s.predCount===3?"selected":""}>3 mots</option>
          <option value="5" ${s.predCount===5?"selected":""}>5 mots</option>
          <option value="8" ${s.predCount===8||s.predCount==null?"selected":""}>8 mots</option>
        </select></div>
      <div class="row"><label for="set-popup">Catégories en pop-up (surcouche)</label>
        <input id="set-popup" type="checkbox" ${s.popup?"checked":""}></div>
      <div class="row"><label for="set-spiral">Disposition spirale</label>
        <select id="set-spiral">
          <option value="off" ${s.spiral==="off"?"selected":""}>Désactivée</option>
          <option value="supplement" ${s.spiral==="supplement"?"selected":""}>Surface « ✷ Fréquents » en plus</option>
          <option value="main" ${s.spiral==="main"?"selected":""}>Accueil en spirale adaptative</option>
        </select></div>

      <h3>Affichage &amp; accès</h3>
      <div class="row"><label for="set-showpicto">Afficher les pictogrammes</label>
        <input id="set-showpicto" type="checkbox" ${s.showPicto?"checked":""}></div>
      <div class="row"><label for="set-showtext">Afficher le texte</label>
        <input id="set-showtext" type="checkbox" ${s.showText?"checked":""}></div>
      <div class="row"><label for="set-color">Code couleur des mots (Fitzgerald)</label>
        <input id="set-color" type="checkbox" ${s.colorCoding?"checked":""}></div>
      <div class="row"><label for="set-tap">Dire chaque mot touché</label>
        <input id="set-tap" type="checkbox" ${s.speakOnTap?"checked":""}></div>
      <div class="row"><label for="set-toutpetit">Profil d'accès « découverte / tout-petit » <span class="hint">(pulsation douce au toucher, focus renforcé, grossissement du mot)</span></label>
        <input id="set-toutpetit" type="checkbox" ${s.toutPetit?"checked":""}></div>
      <div class="row"><label for="set-zoom">Grossir le mot sélectionné <span class="hint">(s'affiche en grand un instant, sans bouger la grille)</span></label>
        <input id="set-zoom" type="checkbox" ${s.zoomOnSelect!==false?"checked":""}></div>
      <p class="hint">Une <strong>couche d'accès</strong> sur la même grille, selon les <strong>capacités</strong> (motrices, visuelles, attention), pas selon l'âge. On adapte la <strong>perception</strong> et le retour au toucher, <strong>jamais le nombre de mots</strong>. L'enfant apprend en voyant l'adulte se servir de l'outil, encore et encore (modélisation). On ne teste jamais l'enfant. Un jeu « chercher un mot » (dans <em>Scènes</em>) aide à repérer les mots, avec des indices et sans sanction.</p>
      <div class="row"><label for="set-scan">Défilement automatique <span class="hint">(sélection par un bouton / contacteur)</span></label>
        <input id="set-scan" type="checkbox" ${s.scan?"checked":""}></div>
      <div class="row"><label for="set-scanspeed">Vitesse du défilement</label>
        <input id="set-scanspeed" type="range" min="600" max="2500" step="100" value="${s.scanSpeed}"></div>
      <div class="row"><label for="set-scancycles">Pause après plusieurs tours <span class="hint">(anti-fatigue ; reprise au contacteur)</span></label>
        <select id="set-scancycles">
          <option value="0" ${(s.scanCycles|0)===0?"selected":""}>Jamais (défilement continu)</option>
          <option value="3" ${(s.scanCycles|0)===3?"selected":""}>Après 3 tours</option>
          <option value="5" ${(s.scanCycles|0)===5?"selected":""}>Après 5 tours</option>
          <option value="8" ${(s.scanCycles|0)===8?"selected":""}>Après 8 tours</option>
        </select></div>
      <div class="row"><label for="set-dwell">Commande oculaire (fixation du regard)</label>
        <input id="set-dwell" type="checkbox" ${s.dwell?"checked":""}></div>
      <div class="row"><label for="set-hoverclick">Activation au survol de la souris (sans clic)</label>
        <input id="set-hoverclick" type="checkbox" ${s.hoverClick?"checked":""}></div>
      <div class="row"><label for="set-dwellms">Durée avant activation</label>
        <input id="set-dwellms" type="range" min="600" max="3000" step="100" value="${s.dwellMs}"></div>
      <p class="hint">Sélectionner sans cliquer, pour qui ne peut pas cliquer : par le <strong>regard</strong> (oculomètre) ou en <strong>posant le curseur</strong> sur une case (survol).</p>

      <h3>Comptes <span class="hint" style="font-weight:400">— locaux, illimités, aucun compte en ligne</span></h3>
      <p class="hint">Plusieurs personnes peuvent utiliser cet outil sur le même appareil, chacune avec <strong>son</strong> vocabulaire, ses scènes et ses voix — tout reste local, aucun compte en ligne. Compte actuel : <strong>${escapeHtml(activeProfileName())}</strong>.</p>
      <div id="profiles-list" class="profiles-list">${profileRowsHTML()}${profileTrashHTML()}</div>
      <div class="row" style="border:0"><button class="btn-secondary" id="prof-new">＋ Nouveau compte</button></div>
      <p class="hint">Pour <strong>partager</strong> un profil vers un autre appareil : « Tout sauvegarder » (chiffrable), puis « Tout restaurer » sur l'autre appareil.</p>

      <h3>Vos données</h3>
      <p class="hint">Tout est stocké sur cet appareil uniquement. « Tout sauvegarder » crée <strong>un seul fichier</strong> avec vos réglages, votre vocabulaire, vos scènes et vos enregistrements de voix. Rangez ce fichier dans votre cloud ou sur une clé : ainsi tout revient même si vous changez d'appareil.</p>
      <p class="hint" id="storage-info" style="margin-top:-2px">Espace de stockage : calcul en cours…</p>
      <div class="row" style="border:0">
        <button class="btn-secondary" id="export">${uiIcon('save')} Tout sauvegarder</button>
        <button class="btn-secondary" id="import">${uiIcon('restore')} Tout restaurer</button>
        <button class="btn-secondary danger" id="reset">${uiIcon('refresh')} Réinitialiser</button>
      </div>
      <input id="importfile" type="file" accept="application/json" aria-label="Choisir un fichier de sauvegarde" hidden>
    </details>

    <h3>Installer &amp; partager cet outil</h3>
    <p class="hint"><strong>Outil libre et gratuit.</strong> Vous pouvez l'installer, le copier et le partager
    librement, sur autant d'appareils que vous voulez, sans rien payer et sans demander d'autorisation.
    Il fonctionne <strong>entièrement sur l'appareil</strong>, hors-ligne, sans compte ni traceur : aucune donnée ne quitte votre matériel.</p>
    <div class="row" style="border:0;flex-wrap:wrap;gap:8px;justify-content:flex-start">
      <button class="btn-secondary" id="pwa-install" type="button">${uiIcon('install')} Installer l'application</button>
      <a class="btn-secondary" id="dl-kit" href="comtogether-caa-kit.zip" download="comtogether-caa-kit.zip" role="button">${uiIcon('download')} Télécharger le kit d'installation</a>
      <a class="btn-secondary" id="open-manual" href="mode-emploi.html" target="_blank" rel="noopener" role="button">${uiIcon('book')} Mode d'emploi</a>
    </div>
    <p class="hint">Le bouton <strong>Installer</strong> ajoute l'appli à l'écran d'accueil (mobile / tablette) ou l'installe comme un logiciel (ordinateur).
    Le <strong>kit</strong> (fichier .zip, avec son mode d'emploi) sert à l'installer <strong>en interne</strong> — sur l'intranet d'une école, d'un cabinet ou d'un établissement, ou pour l'archiver hors-ligne.</p>
    <p class="hint">Une remarque, un bug, une idée&nbsp;? Écrivez-nous&nbsp;:
    <a href="mailto:contact@comtogetherlearning.org?subject=Retour%20sur%20l%27outil%20de%20communication">contact@comtogetherlearning.org</a></p>

    <p class="hint" style="margin-top:20px"><strong>Licence libre.</strong> Code sous <strong>AGPL-3.0-only</strong>. Pictogrammes <strong>Mulberry Symbols</strong> (CC BY-SA 2.0).
    Voix : voix du système par défaut ; moteur Piper (MIT), modèles sous leurs licences propres (voir VOICES.md).
    Vocabulaire fondé sur la recherche ; robustesse d'après Van Tatenhove &amp; Baker, Zangari ; littératie d'après Erickson, Musselwhite, Farrell.</p>
    <p class="hint" style="text-align:center;margin-top:6px">Version ${window.BUILD} · mise à jour automatique</p>
  `;
  p.classList.toggle("simple", !s.settingsExpert);   // mode Simple = masque les réglages avancés
  renderVoiceManager();   // liste générique des voix (async)
  renderPronManager();    // lexique de corrections de prononciation
  storageInfo().then(si => {   // espace utilisé + stockage persistant (illimité côté appli)
    const el = document.getElementById("storage-info"); if (!el) return;
    if (!si) { el.textContent = "Aucune limite imposée par l'appli ; le plafond dépend de l'appareil."; return; }
    el.textContent = `Espace utilisé : ${si.usedMo} Mo${si.quotaMo ? ` (sur ~${si.quotaMo} Mo disponibles sur l'appareil)` : ""}. `
      + (si.persisted ? "Stockage persistant activé : vos données ne sont jamais effacées automatiquement. L'appli n'impose aucune limite." : "L'appli n'impose aucune limite.");
  });
  const on = (id, ev, fn) => { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); };
  on("set-mode-simple","click",()=>{ s.settingsExpert=false; saveSettings(); renderSettings(); });
  on("set-mode-expert","click",()=>{ s.settingsExpert=true; saveSettings(); renderSettings(); });
  on("set-genre","change",e=>{s.genreLocuteur=e.target.value; saveSettings(); renderMessage(); const ex=s.genreLocuteur==="feminin"?"Je suis contente.":"Je suis content."; speakOut(ex);});
  on("set-registre","change",e=>{s.registre=e.target.value; saveSettings(); renderMessage(); speakOut(s.registre==="oral"?"je sais pas":"je ne sais pas");});
  on("set-expgrammar","change",e=>{ s.expGrammar=e.target.checked; saveSettings();
    if (s.expGrammar) { if (window.toast) toast("Grammaire avancée : chargement…", "info"); loadJsRealB().then(j=>{ renderMessage(); if (window.toast) toast(j ? "Grammaire avancée prête (repli automatique sinon)." : "Grammaire avancée indisponible ici — le moteur habituel reste actif.", j?null:"info"); }); }
    else renderMessage();
  });
  on("set-pictostyle","change",e=>{s.pictoStyle=e.target.value; saveSettings(); if(state.view==="board")renderBoard(); renderMessage();});
  on("set-skin","change",e=>{s.skinTone=e.target.value; saveSettings(); refresh(); renderMessage();});
  on("set-hair","change",e=>{s.hairTone=e.target.value; saveSettings(); refresh(); renderMessage();});
  on("set-lang2","change",e=>{s.lang2=e.target.value; saveSettings(); refresh(); renderMessage();});
  on("set-pagesize","change",e=>{s.pageSize=parseInt(e.target.value,10)||84; state.ctxPage=0; saveSettings(); refresh();});
  on("set-calmtint","input",e=>{s.calmTint=parseInt(e.target.value,10); document.documentElement.style.setProperty("--calm-tint",s.calmTint+"%"); saveSettings();});
  on("set-anchorlearn","change",e=>{s.anchorLearn=e.target.checked; saveSettings(); if(state.board==="__ancres") renderBoard();});
  on("set-tips","change",e=>{s.tipsOff=!e.target.checked; saveSettings(); if(s.tipsOff) document.querySelectorAll(".anchor-learn, #jit-tip").forEach(n=>n.remove()); if(state.board==="__ancres"&&state.view==="board") renderBoard();});
  on("set-modelbtn","change",e=>{s.showModelBtn=e.target.checked; saveSettings(); applyLook();});
  on("set-encourage","change",e=>{s.encourage=e.target.checked; saveSettings();});
  on("set-readalong","change",e=>{s.readAlong=e.target.checked; saveSettings(); if(!s.readAlong) clearReadAlong();});
  on("set-decoder","change",e=>{s.decoder=e.target.checked; saveSettings(); applyLook();});
  on("set-pictoassist","change",e=>{s.pictoAssist=e.target.checked; saveSettings(); if(typeof updatePrediction==="function") updatePrediction();});
  on("set-modelvoice","change",e=>{ const pvz = s.partnerVoice || (s.partnerVoice={systemVoiceName:"",rate:1,pitch:1,volume:1}); pvz.systemVoiceName = e.target.value; s.modelVoice = e.target.value; saveSettings(); speak("Je montre les mots.", "fr-FR", {modeling:true});});
  on("set-modelvoice-test","click",()=>speak("Je te montre comment dire les mots.", "fr-FR", {modeling:true}));
  const pvz = () => s.partnerVoice || (s.partnerVoice = { systemVoiceName:"", rate:1, pitch:1, volume:1 });
  const pvX = x => (Math.round(x*10)/10).toFixed(1).replace(".0","")+"×";
  const pvPct = x => Math.round(x*100)+" %";
  on("pv-rate","input",e=>{ const v=parseFloat(e.target.value); pvz().rate=v; const o=document.getElementById("pv-rate-val"); if(o)o.textContent=pvX(v); saveSettings(); });
  on("pv-pitch","input",e=>{ const v=parseFloat(e.target.value); pvz().pitch=v; const o=document.getElementById("pv-pitch-val"); if(o)o.textContent=pvX(v); saveSettings(); });
  on("pv-vol","input",e=>{ const v=parseFloat(e.target.value); pvz().volume=v; const o=document.getElementById("pv-vol-val"); if(o)o.textContent=pvPct(v); saveSettings(); });
  on("pv-reset","click",()=>{ const q=pvz(); q.rate=1; q.pitch=1; q.volume=1; saveSettings(); renderSettings(); speak("Je te montre comment dire les mots.", "fr-FR", {modeling:true}); });
  const applyAccess=(mode)=>{ s.scan=(mode==="switch"); s.dwell=(mode==="eye"); saveSettings(); applyLook(); refresh(); if(typeof toggleScan==="function")toggleScan(); if(typeof toggleDwell==="function")toggleDwell(); renderSettings(); };
  on("acc-touch","click",()=>applyAccess("touch"));
  on("acc-switch","click",()=>applyAccess("switch"));
  on("acc-eye","click",()=>applyAccess("eye"));
  on("set-moodauto","change",e=>{s.moodAuto=e.target.checked; saveSettings();});
  on("set-reconfigure","click",()=>reconfigure());
  on("set-partner","click",()=>{ if(window.CT) CT.openPartnerTips(); });
  on("set-model","click",()=>setModeling(true));
  on("set-coach","click",()=>{ s.jitSeen={}; saveSettings(); setView("board"); startCoach(true); });
  on("set-stats","click",()=>{ if(window.CT) CT.openStats(); });
  on("set-packs","click",()=>{ if(window.CT) CT.openPacks(); });
  on("set-stable","change",e=>{s.stablePositions=e.target.checked; saveSettings(); if(state.board==="__freq")state.board="home"; renderTabs(); renderBoard();});
  on("set-gbmode","change",e=>{s.grammarBarMode=e.target.value; saveSettings(); updateBars();});
  on("set-tbmode","change",e=>{s.toneBarMode=e.target.value; saveSettings(); updateBars();});
  on("set-cols","change",e=>{s.cols=+e.target.value; saveSettings(); applyLook();});
  on("set-font","input",e=>{s.fontScale=+e.target.value; saveSettings(); applyLook();});
  on("set-theme","change",e=>{s.theme=e.target.checked?"dark":"light"; saveSettings(); applyLook();});
  on("set-contrast","change",e=>{s.contrast=e.target.checked?"high":"normal"; saveSettings(); applyLook();});
  on("set-tap","change",e=>{s.speakOnTap=e.target.checked; saveSettings();});
  on("set-toutpetit","change",e=>{
    s.toutPetit=e.target.checked;
    if (s.toutPetit) s.speakOnTap = true;   // contingence : le retour vocal fait partie du profil (réactivable/désactivable)
    saveSettings(); applyLook(); refresh(); renderSettings();
  });
  on("set-zoom","change",e=>{ s.zoomOnSelect=e.target.checked; saveSettings(); });
  on("set-scan","change",e=>{s.scan=e.target.checked; saveSettings(); toggleScan();});
  on("set-scanspeed","input",e=>{s.scanSpeed=+e.target.value; saveSettings(); if(s.scan && typeof scanStartTimer==="function") scanStartTimer();});
  on("set-scancycles","change",e=>{s.scanCycles=+e.target.value|0; saveSettings(); scanLoops=0;});
  on("set-appname","input",e=>{ s.appName=e.target.value.slice(0,40); saveSettings(); applyAppName(); });
  on("set-dwell","change",e=>{s.dwell=e.target.checked; saveSettings(); applyLook(); refresh(); toggleDwell();});
  on("set-hoverclick","change",e=>{s.hoverClick=e.target.checked; saveSettings(); toggleDwell();});
  on("set-dwellms","input",e=>{s.dwellMs=+e.target.value; saveSettings();});
  on("set-symscale","input",e=>{s.symbolScale=+e.target.value; saveSettings(); applyLook();});
  on("set-showpicto","change",e=>{s.showPicto=e.target.checked; saveSettings(); applyLook();});
  on("set-showtext","change",e=>{s.showText=e.target.checked; saveSettings(); applyLook();});
  on("set-color","change",e=>{s.colorCoding=e.target.checked; saveSettings(); applyLook();});
  on("set-popup","change",e=>{s.popup=e.target.checked; saveSettings();});
  on("set-kbdhome","change",e=>{s.keyboardOnHome=e.target.checked; saveSettings();});
  on("set-kblayout","change",e=>{s.kbLayout=e.target.value; saveSettings(); renderKeyboard();});
  on("set-kbbig","change",e=>{s.kbBig=e.target.checked; saveSettings(); renderKeyboard();});
  on("set-litvowels","change",e=>{s.litVowels=e.target.checked; saveSettings(); renderKeyboard();});
  on("set-predcount","change",e=>{s.predCount=+e.target.value; saveSettings(); updatePrediction();});
  on("set-catlayout","change",e=>{s.categoryLayout=e.target.value; s.catMode=(e.target.value==="horizontal")?"bandeau":"colonne"; s.catCollapsed=false; saveSettings(); applyLook();});
  on("set-spiral","change",e=>{s.spiral=e.target.value; saveSettings(); if(state.board==="__freq")state.board="home"; renderTabs(); renderBoard();});
  on("export","click",exportBackup);
  on("import","click",()=>document.getElementById("importfile").click());
  on("importfile","change",importBackup);
  on("reset","click",resetAll);
  on("pwa-install","click",async()=>{
    if (deferredInstallPrompt) {
      deferredInstallPrompt.prompt();
      try { await deferredInstallPrompt.userChoice; } catch {}
      deferredInstallPrompt = null;
    } else {
      ctAlert(pwaInstallHelp(), "Installer l'application");
    }
  });
  // Profils : bascule / renommer / code / supprimer (délégation, ids dynamiques).
  const plist = document.getElementById("profiles-list");
  if (plist) plist.addEventListener("click", async (ev) => {
    const b = ev.target.closest("button[data-action]"); if (!b) return;
    const id = b.getAttribute("data-id"), act = b.getAttribute("data-action");
    if (act === "switch") switchProfile(id);
    else if (act === "rename") { const n = await ctPrompt("Nouveau nom du profil :"); if (n) renameProfile(id, n); }
    else if (act === "pin") setProfilePin(id);
    else if (act === "del") deleteProfile(id);
    else if (act === "restore") restoreProfile(id);
  });
  on("prof-new", "click", async () => { const n = await ctPrompt("Nom du nouveau compte :"); if (n !== null) createProfile(n); });
}
// Cœur canonique d'une sauvegarde pour la somme de contrôle : réglages + personnalisations +
// nombre de médias + date. Déterministe (mêmes champs, même ordre) pour comparer export/import.
function backupCanon(d) {
  return JSON.stringify([d.settings || null, d.overrides || null, Number(d.mediaCount) || 0, d.createdAt || ""]);
}
async function exportBackup() {
  const btn = document.getElementById("export");
  if (btn) { btn.disabled = true; btn.textContent = "Préparation…"; }
  try {
    // Sauvegarde COMPLÈTE : réglages + vocabulaire + TOUS les médias (scènes, enregistrements de voix).
    let media = [];
    try { if (window.CT && CT.exportAllMedia) media = await CT.exportAllMedia(); } catch (e) { media = []; }
    const data = {
      backupFormat: "comtogether", schemaVersion: 2, appVersion: window.BUILD || "",
      createdAt: new Date().toISOString(), profileName: activeProfileName(),
      mediaCount: media.length, settings: state.settings, overrides: state.overrides, media
    };
    // Somme de contrôle (audit §11.5) : empreinte du cœur de la sauvegarde -> détecte une
    // corruption/troncature à la restauration. Best-effort (ignorée si le hachage n'est pas dispo).
    try { const h = await sha256Hex(backupCanon(data)); if (h && !h.startsWith("plain:")) data.integrity = "sha256:" + h; } catch (e) {}
    let payload = JSON.stringify(data), protectedBk = false, fname = "comtogether-sauvegarde-complete.json";
    // Option : PROTÉGER par mot de passe (chiffrement local). Vide/annulé = sauvegarde simple.
    const pw = await ctPrompt("Protéger cette sauvegarde par un mot de passe ? (utile si elle contient des photos/voix)\n\nLaisse VIDE pour une sauvegarde simple.\n⚠️ Sans ce mot de passe, une sauvegarde protégée NE POURRA PAS être restaurée.");
    if (pw) {
      if (!cryptoOk()) { ctAlert("Le chiffrement n'est pas disponible ici (contexte non sécurisé). Une sauvegarde simple a été créée."); }
      else {
        const enc = await encryptText(payload, pw);
        payload = JSON.stringify({ backupFormat: "comtogether", encrypted: true, schemaVersion: 2, appVersion: window.BUILD || "", createdAt: data.createdAt, mediaCount: media.length, ...enc });
        protectedBk = true; fname = "comtogether-sauvegarde-protegee.json";
      }
    }
    const blob = new Blob([payload], { type: "application/json" });
    const a = document.createElement("a"); a.href = URL.createObjectURL(blob);
    a.download = fname; a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 4000);
    ctAlert((protectedBk ? "Sauvegarde PROTÉGÉE créée" : "Sauvegarde complète créée") + " (" + media.length + " média" + (media.length > 1 ? "s" : "") + " inclus)." + (protectedBk ? "\nGarde bien le mot de passe : sans lui, elle est irrécupérable." : "") + "\nRange ce fichier dans ton cloud ou sur une clé pour le retrouver même en changeant d'appareil.");
  } finally {
    if (btn) { btn.disabled = false; btn.innerHTML = uiIcon('save') + " Tout sauvegarder"; }
  }
}
// Un sym_path n'est accepté que s'il pointe vers un asset LOCAL ou une image data:.
// Bloque javascript:, les URL externes (exfiltration/traçage) et data:text/html (XSS).
function isSafeSymPath(sp) {
  if (typeof sp !== "string") return false;
  const s = sp.trim();
  if (/^data:image\//i.test(s)) return true;                 // bulles gen_text / emoji SVG
  if (/^(javascript|data|blob|http|https|vbscript|file):/i.test(s)) return false;
  if (s.includes("//")) return false;                        // pas de protocole relatif
  return true;                                                // chemin relatif local (assets/…)
}
// Nettoie un objet d'overrides importé : ne garde que la structure attendue et purge les sym_path douteux.
function sanitizeOverrides(o) {
  const clean = { hidden: [], edited: {}, added: {} };
  if (!o || typeof o !== "object") return clean;
  if (Array.isArray(o.hidden)) clean.hidden = o.hidden.filter(x => typeof x === "string").slice(0, 5000);
  const scrub = (item) => {
    if (!item || typeof item !== "object") return null;
    if ("sym_path" in item && !isSafeSymPath(item.sym_path)) delete item.sym_path;   // retombera sur une bulle
    if ("file" in item && !isSafeSymPath(item.file)) delete item.file;
    return item;
  };
  ["edited", "added"].forEach(k => {
    const src = o[k]; if (!src || typeof src !== "object") return;
    Object.keys(src).slice(0, 20000).forEach(id => { const v = scrub(src[id]); if (v) clean[k][id] = v; });
  });
  // Conserver les autres clés connues d'overrides (usage, bigrams, pron, phonVoices…) telles quelles,
  // en purgeant les sym_path éventuels qu'elles contiendraient.
  Object.keys(o).forEach(k => {
    if (["hidden", "edited", "added"].includes(k)) return;
    clean[k] = o[k];
  });
  return clean;
}
function importBackup(e) {
  const f = e.target.files[0]; if (!f) return;
  if (f.size > 300 * 1024 * 1024) { ctAlert("Fichier trop volumineux (plus de 300 Mo)."); e.target.value = ""; return; }
  const r = new FileReader();
  r.onload = async () => {
    let d;
    try { d = JSON.parse(r.result); } catch { ctAlert("Fichier illisible ou endommagé. Rien n'a été modifié."); e.target.value = ""; return; }
    // Sauvegarde PROTÉGÉE : demander le mot de passe et déchiffrer avant tout.
    if (d && d.encrypted) {
      if (!cryptoOk()) { ctAlert("Cette sauvegarde est protégée, et le déchiffrement n'est pas disponible ici (contexte non sécurisé)."); e.target.value = ""; return; }
      const pw = await ctPrompt("Cette sauvegarde est protégée. Entre le mot de passe :");
      if (pw == null) { e.target.value = ""; return; }
      try { d = JSON.parse(await decryptText(d, pw)); }
      catch { ctAlert("Mot de passe incorrect ou fichier endommagé. Rien n'a été modifié."); e.target.value = ""; return; }
    }
    if (!d || typeof d !== "object" || (typeof d.settings !== "object" && typeof d.overrides !== "object")) {
      ctAlert("Ce fichier n'est pas une sauvegarde Com'Together valide. Rien n'a été modifié."); e.target.value = ""; return;
    }
    // Format : on accepte v1 (héritée, sans empreintes) et v2 ; on REFUSE proprement un format plus récent.
    const sv = Number(d.schemaVersion || 1);
    if (sv > 2) { ctAlert("Cette sauvegarde vient d'une version plus récente de l'application. Mets d'abord l'application à jour, puis réessaie. Rien n'a été modifié."); e.target.value = ""; return; }
    // Somme de contrôle (audit §11.5) : si le fichier porte une empreinte, on la revérifie.
    // Empreinte absente = ancienne sauvegarde -> on n'alerte pas. Empreinte différente = fichier
    // modifié/endommagé -> on prévient et on laisse CHOISIR (jamais de blocage silencieux).
    if (d.integrity && typeof d.integrity === "string") {
      try {
        const h = await sha256Hex(backupCanon(d));
        if (h && !h.startsWith("plain:") && ("sha256:" + h) !== d.integrity) {
          if (!await ctConfirm("L'empreinte de ce fichier ne correspond pas à son contenu : il a peut-être été modifié ou partiellement endommagé.\n\nRestaurer quand même ?", "", { ok: "Restaurer quand même", cancel: "Annuler" })) { e.target.value = ""; return; }
        }
      } catch (err) {}
    }
    // Résumé du contenu AVANT toute écriture (validation d'abord, écriture ensuite).
    const media = Array.isArray(d.media) ? d.media : [];
    let img = 0, snd = 0, vid = 0, bytes = 0;
    media.forEach(m => { const t = (m && m.type) || ""; bytes += (m && m.size) || 0; if (/^audio\//.test(t)) snd++; else if (/^video\//.test(t)) vid++; else img++; });
    const parts = []; if (img) parts.push(img + " image" + (img > 1 ? "s" : "")); if (snd) parts.push(snd + " enregistrement" + (snd > 1 ? "s" : "")); if (vid) parts.push(vid + " vidéo" + (vid > 1 ? "s" : ""));
    const when = d.createdAt ? (" (créée le " + d.createdAt.slice(0, 10) + ")") : "";
    if (!await ctConfirm("Cette sauvegarde" + when + " contient : réglages + vocabulaire" + (parts.length ? ", " + parts.join(", ") : "") + ".\n\nVos données actuelles seront d'abord conservées dans une sauvegarde de secours.\n\nRestaurer maintenant ?", "", { ok: "Restaurer", cancel: "Annuler" })) { e.target.value = ""; return; }
    // Vérification best-effort de l'espace disponible sur l'appareil.
    try {
      if (navigator.storage && navigator.storage.estimate && bytes) {
        const est = await navigator.storage.estimate(); const free = (est.quota || 0) - (est.usage || 0);
        if (free && bytes > free * 0.9) { ctAlert("Espace insuffisant sur l'appareil pour cette sauvegarde (~" + Math.round(bytes / 1048576) + " Mo). Libère de la place puis réessaie. Rien n'a été modifié."); e.target.value = ""; return; }
      }
    } catch {}
    // Sauvegarde de SECOURS avant d'écraser (récupérable si l'import est mauvais).
    try {
      localStorage.setItem(LS.settings + ".bak", localStorage.getItem(LS.settings) || "");
      localStorage.setItem(LS.overrides + ".bak", localStorage.getItem(LS.overrides) || "");
    } catch {}
    try {
      if (d.settings && typeof d.settings === "object") {
        Object.assign(state.settings, d.settings);
        if (!(state.settings.cols >= 7)) state.settings.cols = 7;   // jamais moins de 49 cases
        saveSettings();
      }
      if (d.overrides && typeof d.overrides === "object") { state.overrides = sanitizeOverrides(d.overrides); saveOverrides(); }
      // Médias : chaque élément est vérifié (empreinte) ; les abîmés sont ignorés et LISTÉS.
      let rep = null;
      if (media.length && window.CT && CT.importAllMedia) rep = await CT.importAllMedia(media);
      applyLook(); refresh();
      if (rep && rep.corrupted.length) {
        const list = rep.corrupted.slice(0, 8).map(k => "• " + k).join("\n") + (rep.corrupted.length > 8 ? "\n…" : "");
        ctAlert(rep.restored + " élément(s) restauré(s). " + rep.corrupted.length + " endommagé(s) et NON restauré(s) :\n" + list + "\n\nVos données présentes sur l'appareil sont restées intactes.");
      } else {
        ctAlert("Sauvegarde restaurée." + (rep ? " " + rep.restored + " média(s)." : ""));
      }
    } catch {
      // Retour à l'état d'avant si quelque chose casse pendant l'application.
      try {
        const sb = localStorage.getItem(LS.settings + ".bak"), ob = localStorage.getItem(LS.overrides + ".bak");
        if (sb) localStorage.setItem(LS.settings, sb);
        if (ob) localStorage.setItem(LS.overrides, ob);
      } catch {}
      ctAlert("La restauration a échoué : vos réglages précédents ont été conservés.");
    } finally { e.target.value = ""; }
  };
  r.readAsText(f);
}
async function resetAll() {
  if (!await ctConfirm("Remettre les réglages et personnalisations à zéro ? Le vocabulaire d'origine sera rétabli.\n\nRien n'est perdu : vous pourrez ANNULER juste après.", "", { ok: "Réinitialiser", cancel: "Annuler" })) return;
  // Instantané AVANT effacement -> annulable (les médias ne sont pas touchés par la réinitialisation).
  const snap = { settings: JSON.parse(JSON.stringify(state.settings)), overrides: JSON.parse(JSON.stringify(state.overrides)) };
  try { localStorage.setItem(LS.undo, JSON.stringify({ type: "reset", snap, ts: Date.now() })); } catch {}
  localStorage.removeItem(LS.settings); localStorage.removeItem(LS.overrides);
  state.settings = { ...DEFAULT_SETTINGS }; state.overrides = { hidden: [], edited: {}, added: {} };
  applyLook(); refresh();
  toast("Réglages et personnalisations réinitialisés.", null, "🧹", { label: "↺ Annuler", fn: () => {
    state.settings = snap.settings; state.overrides = snap.overrides;
    saveSettings(); saveOverrides(); try { localStorage.removeItem(LS.undo); } catch {}
    applyLook(); refresh(); renderSettings(); toast("Réinitialisation annulée — tout est revenu.", null, "↺");
  } });
}

/* ---------- Balayage (single-switch auto-scan) ---------- */
/* ---------- Balayage / contacteur (autoscan à 1 contacteur) ----------
 * Balayage à DEUX niveaux : d'abord les GROUPES (colonnes/rangées), puis les
 * cases du groupe. Chaque case se sélectionne avec DEUX activations du
 * contacteur (une pour le groupe, une pour la case) — le NOMBRE d'étapes de
 * balayage, lui, dépend de la position et de la vitesse.
 * SORTIE du 2ᵉ niveau (anti-piège) : une case « ↩ Retour » est proposée EN
 * PREMIER dans chaque groupe, ET retour automatique aux groupes après quelques
 * tours sans sélection. Tout est accessible au contacteur (rien à toucher).
 * Reconstruit à chaque changement d'écran (self-healing). Pause anti-fatigue
 * configurable (Réglages), reprise au contacteur (Espace/Entrée) ou au bouton. */
const SCAN_ITEM_CYCLES = 3;   // tours d'un groupe sans sélection avant retour auto aux groupes
let scanTimer = null, scanGroups = [], scanItems = [], scanG = -1, scanI = -1, scanPhase = "group",
    scanLoops = 0, scanItemLoops = 0, scanPaused = false, scanSig = "";
// Couche modale ouverte (dialogue in-app, pop-up « Tout voir », visionneuse/éditeur) : quand il y en a
// une, le balayage s'y LIMITE (elle est au-dessus et masque le reste). Priorité au z-index le plus haut.
// Visible même en position:fixed (offsetParent y est null) : on teste les rectangles rendus.
function scanVisible(el) { return !!(el && !el.disabled && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); }
function scanOverlay() {
  const vis = el => el && !el.hidden && (el.offsetWidth || el.offsetHeight || el.getClientRects().length);
  const dlg = document.getElementById("ct-dialog"); if (vis(dlg)) return dlg;
  const modal = document.getElementById("ct-modal"); if (vis(modal)) return modal;
  const pop = document.getElementById("ct-popup"); if (vis(pop)) return pop;
  return null;
}
function scanScannable() {
  const ov = scanOverlay();
  if (ov) return [...ov.querySelectorAll(".alpha-btn, .tile, button, input, .btn-primary, .btn-secondary")]
    .filter(scanVisible);
  return [...document.querySelectorAll(
    ".safety-strip .safety-btn, .msgbar-actions .icon-btn, #grammarbar button, " +
    ".keyboard:not([hidden]) .pred, .board .tile, #tabs .tab, .toolbar .tool, " +
    ".keyboard:not([hidden]) .key, .keyboard:not([hidden]) .kb-layout-btn")]
    .filter(e => e.offsetParent !== null && !e.disabled);
}
function scanGroupRows(els) {
  const a = els.map(e => { const r = e.getBoundingClientRect(); return { e, top: Math.round(r.top), left: Math.round(r.left) }; });
  a.sort((x, y) => (x.top - y.top) || (x.left - y.left));   // ordre de lecture
  const rows = []; let cur = null, last = null; const TOL = 14;
  a.forEach(o => { if (!cur || Math.abs(o.top - last) > TOL) { cur = []; rows.push(cur); last = o.top; } cur.push(o.e); });
  return rows;
}
// Groupes de balayage SÉMANTIQUES : prédictions, puis COLONNES entières (accueil),
// sinon rangées visuelles (catégorie en grille), puis rangées du clavier.
// Balayer 6 colonnes est bien plus rapide/fiable que 42 mini-rangées.
function scanBuildGroups() {
  const vis = e => e.offsetParent !== null && !e.disabled;
  const grab = sel => [...document.querySelectorAll(sel)].filter(vis);
  const groups = [];
  // 0) COUCHE MODALE ouverte (dialogue / pop-up « Tout voir » / visionneuse) : on s'y LIMITE.
  //    Ordre : abécédaire A–Z → rangées de cases → boutons & champs (fermer, valider, filtre).
  const ov = scanOverlay();
  if (ov) {
    const inOv = sel => [...ov.querySelectorAll(sel)].filter(scanVisible);
    const alpha = inOv(".alpha-btn"); if (alpha.length) groups.push(alpha);
    const tiles = inOv(".tile"); if (tiles.length) scanGroupRows(tiles).forEach(r => groups.push(r));
    const ctrls = inOv("button, input, .btn-primary, .btn-secondary").filter(b => !b.classList.contains("alpha-btn") && !b.classList.contains("tile"));
    if (ctrls.length) groups.push(ctrls);
    return groups.length ? groups : [inOv("button, input")];
  }
  // 1) MESSAGES ESSENTIELS d'abord (oui/non/stop/aide/j'ai mal/je veux partir) : refus & protection
  //    doivent être atteignables au contacteur — c'est vital (autodétermination, sécurité).
  const safety = grab(".safety-strip .safety-btn"); if (safety.length) groups.push(safety);
  // 2) ACTIONS de la barre de message : Dire 🔊 (parler !), Effacer, Vider, Chercher, Clavier…
  const acts = grab(".msgbar-actions .icon-btn"); if (acts.length) groups.push(acts);
  // 3) PRÉDICTION du clavier (si ouvert)
  const preds = grab(".keyboard:not([hidden]) .pred"); if (preds.length) groups.push(preds);
  // 4) « EN LIEN » (mots probables + raccourcis de phrases) : accélérateur de débit -> tôt.
  const cz = grab("#context-zone .tile"); if (cz.length) groups.push(cz);
  // 5) COLONNES du socle (hors « En lien »)
  const vcols = [...document.querySelectorAll(".board .vcol, .board .vcol-fringe")].filter(v => !v.closest("#context-zone"));
  if (vcols.length) {
    vcols.forEach(vc => { const t = [...vc.querySelectorAll(".tile")].filter(vis); if (t.length) groups.push(t); });
  } else {
    scanGroupRows(grab(".board .tile")).forEach(r => groups.push(r));
  }
  // 6) GRAMMAIRE (temps / accords) si visible
  const gram = grab("#grammarbar button"); if (gram.length) groups.push(gram);
  // 7) CATÉGORIES (onglets) : indispensable pour naviguer au contacteur
  const tabs = grab("#tabs .tab"); if (tabs.length) groups.push(tabs);
  // 8) MENU du bas (Ma voix, Mon état, Réglages…)
  const tools = grab(".toolbar .tool"); if (tools.length) groups.push(tools);
  // 9) TOUCHES du clavier (si ouvert)
  const keys = grab(".keyboard:not([hidden]) .key, .keyboard:not([hidden]) .kb-layout-btn");
  if (keys.length) scanGroupRows(keys).forEach(r => groups.push(r));
  return groups;
}
function scanClear() { document.querySelectorAll(".scan-hl, .scan-grp-hl").forEach(e => e.classList.remove("scan-hl", "scan-grp-hl")); }
// Case « Retour » (sortie du 2ᵉ niveau), balayable comme les autres.
function scanBackEl() {
  let b = document.getElementById("scan-back");
  if (!b) { b = document.createElement("button"); b.id = "scan-back"; b.type = "button"; b.textContent = "↩ Retour"; b.setAttribute("aria-label", "Revenir au choix des groupes"); b.addEventListener("click", scanToGroup); document.body.appendChild(b); }
  return b;
}
function scanHideBack() { const b = document.getElementById("scan-back"); if (b) b.hidden = true; }
// Reconstruit les groupes si l'ensemble scannable a changé (self-healing).
// Sur reconstruction pendant le 2ᵉ niveau, on remonte aux groupes (les cases
// pointées ont pu disparaître -> pas de sélection incohérente).
function scanSync(force) {
  const live = scanScannable();
  const sig = live.length + "@" + (live[0] ? Math.round(live[0].getBoundingClientRect().top) : 0);
  if (force || sig !== scanSig) {
    scanSig = sig; scanGroups = scanBuildGroups();
    if (scanG >= scanGroups.length) scanG = 0;
    if (scanPhase === "item") { scanPhase = "group"; scanItems = []; scanHideBack(); }
    return true;
  }
  return false;
}
function scanPaint() {
  scanClear();
  const g = scanGroups[scanG];
  if (scanPhase === "group") { if (g) g.forEach(e => e.classList.add("scan-grp-hl")); return; }
  if (g) g.forEach(e => e.classList.add("scan-grp-hl"));   // le groupe reste teinté
  const it = scanItems[scanI];
  if (it) { it.classList.remove("scan-grp-hl"); it.classList.add("scan-hl"); }
}
function scanTick() {
  scanSync(false);
  if (!scanGroups.length) { scanClear(); return; }
  if (scanPhase === "group") {
    scanG = (scanG + 1) % scanGroups.length;
    const cyc = state.settings.scanCycles | 0;   // 0 = jamais de pause
    if (scanG === 0 && cyc > 0 && ++scanLoops >= cyc) { scanPause(); return; }
  } else {
    if (!scanItems.length) { scanToGroup(); return; }
    scanI = (scanI + 1) % scanItems.length;
    if (scanI === 0 && ++scanItemLoops >= SCAN_ITEM_CYCLES) { scanToGroup(); return; }   // sortie auto
  }
  scanPaint();
}
function scanStartTimer() { clearInterval(scanTimer); scanTimer = setInterval(scanTick, state.settings.scanSpeed || 1500); }
function scanToGroup() { scanPhase = "group"; scanG = -1; scanI = -1; scanLoops = 0; scanItems = []; scanHideBack(); scanSync(true); scanTick(); scanStartTimer(); }
function scanEnterGroup() {
  scanPhase = "item"; scanItemLoops = 0; scanI = -1;
  const back = scanBackEl(); back.hidden = false;
  scanItems = [back, ...(scanGroups[scanG] || [])];   // « Retour » proposé EN PREMIER
  scanTick(); scanStartTimer();
}
function toggleScan() {
  clearInterval(scanTimer); scanTimer = null; scanClear(); scanHideBack();
  if (!state.settings.scan) { scanPaused = false; scanHideResume(); return; }
  scanPaused = false; scanHideResume(); scanToGroup();
}
function scanSelect() {
  if (!state.settings.scan) return false;
  if (scanPaused) { scanResume(); return true; }
  scanSync(false);
  if (!scanGroups.length) return false;
  if (scanPhase === "group") {
    const g = scanGroups[scanG]; if (!g || !g.length) return false;
    if (g.length === 1) { scanActivate(g[0]); return true; }   // groupe à 1 case : activation directe
    scanEnterGroup(); return true;
  }
  const it = scanItems[scanI];
  if (!it) { scanToGroup(); return true; }
  if (it.id === "scan-back") { scanToGroup(); return true; }   // sortie explicite du 2ᵉ niveau
  scanActivate(it);
  return true;
}
function scanActivate(el) {
  clearInterval(scanTimer); scanTimer = null; scanClear(); scanHideBack();
  try { el.click(); } catch {}
  // l'écran a pu changer (nouvelle grille, prédictions) : on repart en haut.
  setTimeout(() => { if (state.settings.scan && !scanPaused) scanToGroup(); }, 70);
}
function scanPause() { clearInterval(scanTimer); scanTimer = null; scanClear(); scanHideBack(); scanPaused = true; scanShowResume(); }
function scanResume() { scanPaused = false; scanHideResume(); scanToGroup(); }
function scanShowResume() {
  let b = document.getElementById("scan-resume");
  if (!b) { b = document.createElement("button"); b.id = "scan-resume"; b.type = "button"; b.textContent = "▶ Reprendre le balayage"; b.addEventListener("click", scanResume); document.body.appendChild(b); }
  b.hidden = false;
  try { b.focus(); } catch {}   // focus déplacé sur « Reprendre » (lecteurs d'écran / focus-switch)
}
function scanHideResume() { const b = document.getElementById("scan-resume"); if (b) b.hidden = true; }
document.addEventListener("keydown", e => {
  if ((e.code === "Space" || e.code === "Enter") && state.settings.scan) {
    if (document.activeElement && document.activeElement.tagName === "INPUT") return;
    e.preventDefault(); scanSelect();   // le contacteur reprend AUSSI depuis la pause
  }
});
document.addEventListener("visibilitychange", () => {
  if (document.hidden) { clearInterval(scanTimer); scanTimer = null; }
  else if (state.settings.scan && !scanPaused) scanToGroup();
});

/* ---------- Couloir bas mesuré : aucun bouton flottant ne recouvre une case ----------
 * On mesure la hauteur RÉELLE occupée par les boutons flottants du bas (« Menu »,
 * repère « Catégories ») et on réserve exactement cet espace sous le tableau.
 * S'adapte au zoom, à l'écran étroit, au clavier virtuel, aux zones sûres iOS. */
function updateFloatingGutters() {
  // On réserve, SOUS la zone de travail, la profondeur dont les boutons flottants
  // remontent DANS cette zone. Mesuré depuis le BAS de la zone de travail (pas du
  // viewport) : quand la barre du bas est visible, elle offre déjà du dégagement,
  // donc on ne sur-réserve pas. Rétrécir la zone (et non rembourrer son contenu)
  // fait que la liste qui défile s'arrête AU-DESSUS du bouton — plus aucun
  // recouvrement en cours de défilement (ex. « Ma voix »).
  const wa = document.querySelector(".workarea");
  const base = wa ? wa.getBoundingClientRect().bottom : (window.innerHeight || 0);
  let gap = 0;
  const consider = (el) => {
    if (!el || el.hidden) return;
    const cs = getComputedStyle(el);
    if (cs.display === "none" || cs.visibility === "hidden") return;
    if (el.classList.contains("as-handle")) return;   // repère latéral → géré par padding-left
    const r = el.getBoundingClientRect();
    if (r.height && base) gap = Math.max(gap, base - r.top);   // profondeur d'intrusion sous la zone
  };
  consider(document.getElementById("menu-toggle"));
  consider(document.getElementById("cat-launcher"));
  const val = Math.max(40, Math.ceil(gap) + 14);   // marge de sécurité + petit plancher
  document.documentElement.style.setProperty("--fc-bottom", val + "px");
}
window.addEventListener("resize", () => { try { updateFloatingGutters(); } catch {} });
window.addEventListener("orientationchange", () => setTimeout(() => { try { updateFloatingGutters(); } catch {} }, 200));

/* ---------- Commande oculaire : sélection par FIXATION (dwell) ----------
 * La plupart des oculomètres déplacent un curseur au regard. Ici on active
 * l'élément regardé après une fixation de `dwellMs`, avec une jauge circulaire.
 * Aucun geste requis (pas de clic/double-clic/glisser). Compatible aussi
 * tête-souris et webcam-gaze.  */
const DWELL_SEL = ".safety-btn,.tile,.tab,.key,.kb-layout-btn,.icon-btn,.tense-btn,.tense-toggle,.morph-btn,.tone-btn,.btn-primary,.btn-secondary,.profile-btn,.pred,.tool,.linklike,.alpha-btn,#gaze-rest,#preview";
let dwellTimer = null, dwellRing = null, dwellX = 0, dwellY = 0, dwellCur = null, dwellStart = 0, dwellArmed = true;
function ensureDwellRing() {
  if (!dwellRing) { dwellRing = document.createElement("div"); dwellRing.id = "dwell-ring"; dwellRing.hidden = true; document.body.appendChild(dwellRing); }
  return dwellRing;
}
function dwellMove(e) { dwellX = e.clientX; dwellY = e.clientY; }
function dwellTargetAt(x, y) {
  const el = document.elementFromPoint(x, y);
  if (!el || el.id === "dwell-ring") return null;
  const t = el.closest(DWELL_SEL);
  if (!t || t.disabled || t.hidden || t.closest("[hidden]")) return null;
  return t;
}
let dwellPaused = false;
function ensureGazeRest() {
  let b = document.getElementById("gaze-rest");
  if (!b) {
    b = document.createElement("button"); b.id = "gaze-rest"; b.type = "button";
    b.setAttribute("aria-label", "Pause du regard");
    b.addEventListener("click", () => {
      dwellPaused = !dwellPaused;
      b.classList.toggle("is-paused", dwellPaused);
      b.textContent = dwellPaused ? "👁️ Reprendre" : "👁️ Pause";
    });
    b.textContent = "👁️ Pause";
    document.body.appendChild(b);
  }
  return b;
}
function dwellTick() {
  const ring = ensureDwellRing();
  const t = dwellTargetAt(dwellX, dwellY);
  // PAUSE DU REGARD : plus rien ne s'active, SAUF le bouton « Reprendre ».
  if (dwellPaused && (!t || t.id !== "gaze-rest")) { ring.hidden = true; dwellCur = t; return; }
  if (t !== dwellCur) {                       // nouvelle cible (ou plus de cible)
    dwellCur = t; dwellStart = performance.now(); dwellArmed = true;
    if (!t) { ring.hidden = true; return; }
    const r = t.getBoundingClientRect(), d = Math.max(28, Math.min(r.width, r.height) * 0.9);
    ring.style.width = ring.style.height = d + "px";
    ring.style.left = (r.left + r.width / 2 - d / 2) + "px";
    ring.style.top = (r.top + r.height / 2 - d / 2) + "px";
    ring.style.setProperty("--p", 0); ring.hidden = false;
    return;
  }
  if (!t || !dwellArmed) { if (!t) ring.hidden = true; return; }
  const p = Math.min(1, (performance.now() - dwellStart) / (state.settings.dwellMs || 1200));
  ring.style.setProperty("--p", p.toFixed(3));
  if (p >= 1) {                               // fixation atteinte -> on active
    dwellArmed = false; ring.hidden = true;
    const el = t; setTimeout(() => { try { el.click(); } catch {} }, 0);
  }
}
function toggleDwell() {
  clearInterval(dwellTimer); dwellTimer = null;
  document.removeEventListener("pointermove", dwellMove);
  const ring = ensureDwellRing(); ring.hidden = true;
  const active = !!(state.settings.dwell || state.settings.hoverClick);   // regard OU survol souris
  document.body.classList.toggle("dwelling", active);
  // Bouton « pause du regard » visible uniquement en commande oculaire (pas en survol souris).
  const rest = ensureGazeRest();
  rest.hidden = !state.settings.dwell;
  if (!active) { dwellPaused = false; return; }
  dwellCur = null; dwellArmed = true;
  document.addEventListener("pointermove", dwellMove);
  dwellTimer = setInterval(dwellTick, 60);
}

/* ---------- Vues ---------- */
function setView(view) {
  state.view = view;
  document.body.setAttribute("data-view", view);   // permet au CSS de cibler la vue (clavier plein écran, etc.)
  document.querySelectorAll(".tool").forEach(t => t.classList.toggle("is-active", t.dataset.view === view));
  const show = (id, on) => document.getElementById(id).hidden = !on;
  show("settings", view === "settings");
  show("editpanel", false);
  const kbdVisible = view === "write" || (view === "board" && state.settings.keyboardOnHome);
  show("keyboard", kbdVisible);
  const kt = document.getElementById("kbd-toggle");
  if (kt) { kt.setAttribute("aria-pressed", String(state.settings.keyboardOnHome)); kt.classList.toggle("is-on", state.settings.keyboardOnHome); }
  document.getElementById("tabs").hidden = !(view === "board");
  { const ss = document.getElementById("safety-strip"); if (ss) ss.hidden = (view !== "board"); }
  updateBars();
  document.body.classList.toggle("editing", view === "edit");
  state.editing = view === "edit";

  const isScenes = view === "scenes";
  document.getElementById("board").hidden = isScenes;
  const sc = document.getElementById("scenes");
  if (sc) sc.hidden = !isScenes;

  if (view === "settings") { renderSettings(); }
  else if (view === "etat") { renderEtat(); }
  else if (view === "advocacy") { renderAdvocacy(); }
  else if (view === "write") { renderKeyboard(); renderBoard(); }
  else if (view === "scenes") { if (window.CT) CT.renderScenes(); }
  else if (view === "edit") { openBoard(state.board); }
  else { openBoard(state.board); }
  if (kbdVisible && view !== "write") renderKeyboard();   // clavier ancré sur l'accueil
  renderBreadcrumb();
  // Astuces « juste-à-temps » : une seule fois, quand on découvre une vue
  if (view === "write") jitTip("v-write", "Tape des lettres : la phrase se construit en haut. Touche 🔊 Dire pour l'entendre.", "⌨️");
  else if (view === "advocacy") jitTip("v-advocacy", "Tes phrases pour t'affirmer et choisir. Touche une carte pour la dire.", "💪");
  else if (view === "scenes") jitTip("v-scenes", "Ajoute une photo d'un vrai moment, puis touche dessus pour créer des zones qui parlent.", "📷");
  else if (view === "edit") jitTip("v-edit", "Touche une case pour la changer, ou « ＋ Ajouter une case ». Re-touche « Modifier » pour sortir.", "🎨");
  if (view !== "board") { const p = document.getElementById("ctx-pager"); if (p) p.hidden = true; }
  updateAddButton();
  syncPromenadeBtn();
  requestAnimationFrame(() => { try { updateFloatingGutters(); } catch {} });
  toggleScan(); toggleDwell();
}
// Bouton flottant "+ Ajouter" visible en mode Modifier
function updateAddButton() {
  let b = document.getElementById("addfab");
  if (!b) {
    b = document.createElement("button");
    b.id = "addfab"; b.textContent = "＋ Ajouter une case";
    b.style.cssText = "position:fixed;right:16px;bottom:76px;z-index:30;background:var(--accent);color:#fff;border:0;border-radius:999px;padding:14px 20px;font-weight:800;font-size:1rem;box-shadow:0 4px 12px rgba(0,0,0,.25)";
    b.addEventListener("click", () => { if (state.view === "scenes" && window.CT) CT.addScene(); else addWord(); });
    document.body.appendChild(b);
  }
  b.hidden = !state.editing;
  // Bouton compagnon : ajouter une LISTE de mots (rangée dans la grille). Pas en mode Scènes.
  let bl = document.getElementById("addlistfab");
  if (!bl) {
    bl = document.createElement("button");
    bl.id = "addlistfab"; bl.textContent = "＋ Une liste de mots";
    bl.style.cssText = "position:fixed;right:16px;bottom:130px;z-index:30;background:var(--surface);color:var(--accent);border:2px solid var(--accent);border-radius:999px;padding:12px 18px;font-weight:800;font-size:.95rem;box-shadow:0 4px 12px rgba(0,0,0,.18)";
    bl.addEventListener("click", () => { if (window.CT && CT.openBulkAdd) CT.openBulkAdd(); });
    document.body.appendChild(bl);
  }
  bl.hidden = !(state.editing && state.view !== "scenes");
}
function showEditHelp() {
  const p = document.getElementById("editpanel");
  // barre d'aide légère en haut du board via alert-free banner
}

/* ---------- Apparence ---------- */
// Nom PERSONNEL de l'outil (autodétermination : la personne nomme SA voix). Vide = « Ma communication ».
function appTitle() { return ((state.settings && state.settings.appName) || "").trim() || "Ma communication"; }
function applyAppName() {
  const n = appTitle();
  try { document.title = n; } catch (e) {}
  const h1 = document.querySelector("h1.visually-hidden"); if (h1) h1.textContent = n;
  document.querySelectorAll("[data-app-name]").forEach(el => { el.textContent = n; });
}
function applyLook() {
  const r = document.documentElement, s = state.settings;
  applyAppName();
  r.dataset.theme = s.theme;
  r.dataset.contrast = s.contrast;
  r.style.setProperty("--font-scale", s.fontScale);
  r.style.setProperty("--symbol-scale", s.symbolScale);
  r.style.setProperty("--calm-tint", (s.calmTint == null ? 26 : s.calmTint) + "%");   // intensité des couleurs des cases (choix de la personne)
  document.body.classList.toggle("no-picto", !s.showPicto);
  document.body.classList.toggle("no-text", !s.showText);
  document.body.classList.toggle("no-color", !s.colorCoding);
  // Bouton d'accueil : THÈME sombre/clair (le contraste, besoin permanent, reste
  // dans les Réglages — on ne le change qu'une fois).
  const tt = document.getElementById("theme-toggle");
  if (tt) {
    const dark = s.theme === "dark";
    tt.classList.toggle("is-on", dark);
    tt.setAttribute("aria-pressed", String(dark));
    const ic = tt.querySelector(".th-ico"), lb = tt.querySelector(".th-lab");
    if (ic) { ic.innerHTML = (typeof CT_ICONS !== "undefined") ? CT_ICONS[dark ? "sun" : "moon"] : (dark ? "☀️" : "🌙"); ic.classList.add("ct-ic"); }
    if (lb) lb.textContent = dark ? "Clair" : "Sombre";
    tt.title = dark ? "Revenir au thème clair" : "Passer au thème sombre";
    tt.setAttribute("aria-label", dark ? "Passer au thème clair" : "Passer au thème sombre");
  }
  // Bouton « Montrer » (modélisation) : masquable — inutile à qui n'apprend pas l'outil.
  const mtg = document.getElementById("model-toggle");
  if (mtg) {
    mtg.hidden = !s.showModelBtn;
    if (!s.showModelBtn && state.modeling && typeof setModeling === "function") setModeling(false);
  }
  // Bouton « Sons » (décodeur littératie) : visible seulement si le décodeur est actif.
  const dbt = document.getElementById("decode-btn");
  if (dbt) dbt.hidden = (s.decoder === false);
  // Barre de catégories : colonne (gauche) | bandeau (haut) | popup (masquée, bouton 🗂️)
  const cm = s.catMode || (s.categoryLayout === "horizontal" ? "bandeau" : "colonne");
  document.body.classList.toggle("cat-vertical", cm === "colonne");
  document.body.classList.toggle("cat-horizontal", cm === "bandeau");
  document.body.classList.toggle("cat-popup", cm === "popup");
  // Repli EN PLACE : la barre garde sa disposition (colonne/bandeau) mais se range ;
  // un onglet-repère au même bord la ramène à l'identique. (≠ mode pop-up.)
  const collapsed = !!s.catCollapsed && cm !== "popup";
  document.body.classList.toggle("cat-collapsed", collapsed);
  document.body.classList.toggle("cat-collapsed-side", collapsed && cm === "colonne");
  document.body.classList.toggle("cat-collapsed-top", collapsed && cm === "bandeau");
  // #cat-launcher sert de repère : en pop-up il ouvre la fenêtre ; replié il ramène la barre.
  const cl = document.getElementById("cat-launcher");
  if (cl) {
    const showLauncher = (cm === "popup") || (collapsed && state.view === "board");
    cl.hidden = !showLauncher;
    cl.classList.toggle("as-handle", collapsed && cm !== "popup");
    const lab = cl.querySelector("span:last-child");
    if (lab) lab.textContent = "Catégories";
  }
  const cc = document.getElementById("cat-collapse"); if (cc) cc.hidden = (cm === "popup" || state.view !== "board");
  document.body.dataset.popupPos = s.popupPos || "center";               // position des pop-up/fenêtres
  document.body.classList.toggle("eyegaze", !!(s.dwell || s.eyegaze));   // cibles larges + espacement
  document.body.classList.toggle("tout-petit", !!s.toutPetit);           // couche d'accès/contingence sur la grille STABLE (n'agrandit PLUS les cases : évite le débordement)
  document.body.classList.toggle("menu-hidden", !!s.menuHidden);         // barre du bas masquée
  const mt = document.getElementById("menu-toggle");
  if (mt) {
    mt.querySelector(".mt-ico").textContent = s.menuHidden ? "☰" : "⌄";
    mt.querySelector(".mt-txt").textContent = "Menu";
    mt.title = mt.ariaLabel = s.menuHidden ? "Afficher le menu" : "Masquer le menu";
    mt.setAttribute("aria-label", s.menuHidden ? "Afficher le menu" : "Masquer le menu");
  }
  document.getElementById("board").style.setProperty("--cols", s.cols);
  // Recalcule le couloir bas après application du look, puis à nouveau une fois
  // la transition de position du bouton terminée (il glisse quand on masque le menu).
  requestAnimationFrame(() => { try { updateFloatingGutters(); } catch {} });
  setTimeout(() => { try { updateFloatingGutters(); } catch {} }, 420);
}

/* ---------- Catégories en POP-UP (mode « moins envahissant ») ---------- */
function openCategoriesPopup() {
  let host = document.getElementById("ct-popup");
  if (!host) { host = document.createElement("div"); host.className = "popup"; host.id = "ct-popup"; document.body.appendChild(host); }
  host.hidden = false; host.innerHTML = "";
  const inner = document.createElement("div"); inner.className = "popup-inner";
  const head = document.createElement("div"); head.className = "popup-head";
  head.appendChild(Object.assign(document.createElement("strong"), { textContent: "Catégories" }));
  const headBtns = document.createElement("div"); headBtns.style.cssText = "display:flex;gap:8px";
  const pin = Object.assign(document.createElement("button"), { className: "btn-secondary", textContent: "📌 Réafficher la barre" });
  pin.title = "Remettre la barre de catégories affichée en permanence";
  pin.onclick = () => { state.settings.catMode = state.settings.catModeShown || "colonne"; saveSettings(); applyLook(); renderTabs(); host.hidden = true; };
  const x = Object.assign(document.createElement("button"), { className: "btn-secondary", textContent: "Fermer" });
  x.onclick = () => host.hidden = true;
  headBtns.append(pin, x); head.appendChild(headBtns); inner.appendChild(head);
  const grid = document.createElement("div"); grid.className = "popup-grid";
  grid.style.setProperty("--cols", Math.min(state.settings.cols || 7, 6));
  const tile = (id, label, thumb, color, emoji) => {
    const b = document.createElement("button"); b.className = "tile"; if (color) b.dataset.color = color;
    if (emoji) b.appendChild(Object.assign(document.createElement("span"), { className: "tab-emoji", textContent: emoji }));
    else if (thumb) { const im = document.createElement("img"); im.src = thumb; im.alt = ""; b.appendChild(im); }
    b.appendChild(Object.assign(document.createElement("span"), { className: "tile-label", textContent: label }));
    b.onclick = () => { host.hidden = true; openBoard(id); };
    return b;
  };
  grid.appendChild(tile("home", "Accueil", null, "pronom", "🏠"));
  const cats = [...(state.vocab.functions || []), ...(state.vocab.themes || []),
                ...(typeof enabledPacks === "function" ? enabledPacks() : (state.vocab.packs || []))];
  cats.forEach(f => grid.appendChild(tile(f.id, f.label, catThumb(f), f.color || "nom")));
  inner.appendChild(grid); host.appendChild(inner);
  host.onclick = e => { if (e.target === host) host.hidden = true; };
}
window.openCategoriesPopup = openCategoriesPopup;

/* ---------- Rafraîchissement global ---------- */
function refresh() {
  renderTabs();
  if (state.view === "advocacy") renderAdvocacy();
  else renderBoard();
  renderMessage();
}

/* ---------- Câblage (défensif : chaque écouteur est indépendant) ---------- */
function on(id, ev, fn) { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); }
function wire() {
  const s = state.settings;   // alias utilisé par plusieurs écouteurs ci-dessous
  on("speak", "click", () => { recordPhrase(); speakOut(sentence()); });
  on("search", "click", () => { if (window.CT) CT.openSearch(); });
  on("menu-toggle", "click", () => { state.settings.menuHidden = !state.settings.menuHidden; saveSettings(); applyLook(); });
  on("model-toggle", "click", () => setModeling(!state.modeling));
  on("tone-toggle", "click", () => {
    // Clic manuel = bascule EXPLICITE (prime sur l'auto-émotion, dans les deux sens).
    if ((s.toneBarMode || "auto") !== "auto") s.toneBarMode = "auto";
    const tb = document.getElementById("tonebar");
    // La barre garde toujours sa place (stabilité) : sa VISIBILITÉ réelle = absence de « tb-resting »,
    // PAS l'attribut hidden (toujours faux en vue). Bug corrigé : sinon le bouton croyait la barre
    // déjà visible et la re-masquait aussitôt.
    const shown = tb && !tb.classList.contains("tb-resting");
    state.toneForce = shown ? false : true;   // visible → on masque ; masquée → on affiche
    saveSettings(); updateBars(); renderToneBar();
  });
  on("decode-btn", "click", async () => {
    if (s.decoder === false || !(window.CT && CT.openDecoder)) return;
    const pv = document.getElementById("preview");
    const spans = pv ? [...pv.querySelectorAll(".pw")] : [];
    if (spans.length) { CT.openDecoder(spans[spans.length - 1].textContent, { words: spans.map(x => x.textContent), index: spans.length - 1 }); return; }
    // Pas de phrase en cours : on demande directement un mot (activation simple, sans étape).
    const w = (await ctPrompt("Quel mot veux-tu décomposer en sons ?") || "").trim();
    if (w) CT.openDecoder(w, { words: w.split(/\s+/), index: w.split(/\s+/).length - 1 });
  });
  on("promenade-toggle", "click", () => {
    // Bascule : sur la Promenade → on revient à l'organisation linguistique (grille).
    if (state.board === "__ancres" && state.view === "board") { openBoard("home"); return; }
    state.board = "home";                         // neutralise le toggle interne de setView
    if (state.view !== "board") setView("board");
    openBoard("__ancres");
  });
  on("cat-launcher", "click", () => {
    // Replié en place → on ramène la barre au même endroit (repère spatial).
    // Sinon (mode pop-up choisi dans les réglages) → on ouvre la fenêtre.
    if (s.catCollapsed && (s.catMode || "colonne") !== "popup") {
      s.catCollapsed = false; saveSettings(); applyLook();
    } else {
      openCategoriesPopup();
    }
  });
  on("cat-collapse", "click", () => { s.catModeShown = (s.catMode && s.catMode !== "popup") ? s.catMode : "colonne"; s.catMode = "popup"; saveSettings(); applyLook(); renderTabs(); });
  on("set-catmode","change",e=>{ s.catMode=e.target.value; s.catCollapsed=false; if(e.target.value!=="popup") s.categoryLayout=(e.target.value==="bandeau")?"horizontal":"vertical"; saveSettings(); applyLook(); renderTabs(); });
  on("set-catfolders","change",e=>{ s.catFolders=e.target.checked; state.catGroup=null; saveSettings(); renderTabs(); });
  on("set-popuppos","change",e=>{ s.popupPos=e.target.value; saveSettings(); applyLook(); });
  on("kbd-toggle", "click", () => {
    state.settings.keyboardOnHome = !state.settings.keyboardOnHome; saveSettings();
    if (state.view !== "board" && state.view !== "write") setView("board"); else setView(state.view);
  });
  on("theme-toggle", "click", () => {
    state.settings.theme = state.settings.theme === "dark" ? "light" : "dark";
    saveSettings(); applyLook();
  });
  on("backspace", "click", () => { state.tokens.pop(); renderMessage(); refreshBoardContext(); });
  on("gb-close", "click", () => {
    state.grammarForce = false; updateBars();   // masquée pour cette phrase (place gardée = grille stable)
    toast("Barre grammaire masquée pour cette phrase.", null, "🕐",
      { label: "Rétablir", fn: () => { state.grammarForce = null; updateBars(); } });
  });
  on("clear", "click", () => {
    state.tokens = []; state.writeBuffer = "";
    state.toneForce = null;   // phrase vidée → la barre de ton revient en mode auto
    state.grammarForce = null;   // phrase vidée → la barre grammaire revient en mode auto
    if (state.settings.moodAuto && state.settings.mood !== "neutre") setMood("neutre", false);
    renderMessage(); refreshBoardContext();
  });
  // « Temps » : bouton dépliable (déploie la liste des temps au clic)
  const tenseToggle = document.getElementById("tense-toggle");
  const tenseList = document.getElementById("tense-list");
  function setTenseListOpen(open) {
    if (!tenseList || !tenseToggle) return;
    tenseList.hidden = !open;
    tenseToggle.setAttribute("aria-expanded", String(open));
    tenseToggle.classList.toggle("is-open", open);
  }
  if (tenseToggle) tenseToggle.addEventListener("click", () => setTenseListOpen(tenseList.hidden));
  document.addEventListener("click", (e) => {   // referme si on clique ailleurs
    if (tenseList && !tenseList.hidden && !e.target.closest("#grammarbar")) setTenseListOpen(false);
  });
  // Applique un temps OU une forme du verbe (source unique, pour que le menu déroulant et le
  // bouton rapide « Infinitif » restent toujours synchronisés). Passage conjugué ↔ forme = 1 clic.
  function setTense(t, opts) {
    const prev = state.tense;               // pour l'annulation (avant→après réversible)
    const before = sentence();
    state.tense = t;
    document.querySelectorAll(".tense-btn").forEach(x => {
      const on = x.dataset.tense === t; x.classList.toggle("is-active", on); x.setAttribute("aria-pressed", String(on));
    });
    const cur = document.getElementById("tense-current");
    if (cur) cur.textContent = (Grammar.labels && Grammar.labels[t]) || t;
    const inf = document.getElementById("infinitive-btn");
    if (inf) { const on = t === "infinitif"; inf.classList.toggle("is-active", on); inf.setAttribute("aria-pressed", String(on)); }
    setTenseListOpen(false);   // on replie après le choix
    renderMessage();
    if ((!opts || !opts.silent) && prev !== t) {
      const label = (Grammar.labels && Grammar.labels[t]) || t;
      announceTransform(before, label, () => setTense(prev, { silent: true }));
      // profil tout-petit : montrer la NOUVELLE forme du verbe en grand (ex. « irai », « manger »)
      let vtok = null; for (let i = state.tokens.length - 1; i >= 0; i--) { if (state.tokens[i].verb) { vtok = state.tokens[i]; break; } }
      if (vtok) showFormZoom(vtok);
    }
  }
  document.querySelectorAll(".tense-btn").forEach(b => b.addEventListener("click", () => setTense(b.dataset.tense)));
  // Bouton rapide « Infinitif » : bascule immédiate infinitif ↔ présent (retour au conjugué).
  on("infinitive-btn", "click", () => setTense(state.tense === "infinitif" ? "present" : "infinitif"));
  on("plural-btn", "click", () => agreeLastNoun("plural"));
  on("fem-btn", "click", () => agreeLastNoun("fem"));
  document.querySelectorAll(".tool").forEach(t => t.addEventListener("click", () => {
    const v = t.dataset.view;
    // Tous les onglets sont BASCULABLES : re-cliquer sur l'onglet déjà actif = retour à l'accueil.
    // (Ma voix, Scènes, Écrire, Modifier, Réglages ; l'Accueil lui-même n'a rien à quitter.)
    if (v !== "board" && state.view === v) { setView("board"); return; }
    setView(v);
  }));
  on("phonics", "click", () => {
    const c = state.writeBuffer.slice(-1); if (!c) return;
    // « 🔉 son » : la voix du système déforme un son isolé → on dit le MOT-REPÈRE réel
    // (« b » → « ballon »), fiable. Le son isolé exact viendra avec Piper (phonèmes eSpeak).
    const seg = (window.CT && CT.segmentFr) ? CT.segmentFr(c)[0] : null;
    const w = (seg && seg.ex) ? seg.ex : c;
    if (window.speakP) window.speakP(w, { lang: "fr-FR", rate: 1.05, pitch: 1.15 }); else speak(w);
  });
  on("kb-speak", "click", () => speakOut(sentence()));
  on("privacy-ok", "click", () => { document.getElementById("privacy").hidden = true; localStorage.setItem(LS.seen, "1"); maybeShowProfile(); maybeCoach(); });
  on("message", "dblclick", () => { if (state.editing) addWord(); });
  // Toucher la phrase (aperçu) ou le fond de la barre de message = la dire
  // Aperçu : un simple TAP sur un mot le décompose (littératie, quand le décodeur est actif) ;
  // sinon (ou en dehors d'un mot) = dire la phrase. Aucun appui long.
  (function () {
    const pv = document.getElementById("preview");
    if (!pv) return;
    pv.addEventListener("click", (e) => {
      const span = (state.settings.decoder !== false && e.target && e.target.closest) ? e.target.closest(".pw") : null;
      if (span && window.CT && CT.openDecoder) {
        const spans = [...pv.querySelectorAll(".pw")];
        CT.openDecoder(span.textContent, { words: spans.map(s => s.textContent), index: spans.indexOf(span) });
        return;
      }
      speakOut(sentence());
    });
    pv.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); speakOut(sentence()); } });
  })();
  on("message", "click", (e) => { if (e.target === document.getElementById("message")) speakOut(sentence()); });
  // Accueil : aucun tri en « profils ». On commence, ou on ouvre les Réglages.
  on("profile-start", "click", finishProfile);
  on("profile-settings", "click", () => {
    const p = document.getElementById("profile"); if (p) p.hidden = true;
    localStorage.setItem(LS.profiled, "1"); setView("settings");
  });
  on("profile-skip", "click", finishProfile);   // rétro-compat si l'ancien bouton existe
}

/* ---------- Bulles d'aide (visite guidée, 1er lancement) ---------- */
const COACH_STEPS = [
  { sel: "#tabs", ic: "🗂️", t: "Tes catégories", d: "Ici, tes catégories de mots. Touche une image pour l'ouvrir, re-touche pour revenir." },
  { sel: "#board .tile", ic: "👆", t: "Composer", d: "Touche une case : le mot rejoint ta phrase, tout en haut." },
  { sel: "#message", ic: "💬", t: "Ta phrase", d: "Ta phrase se construit ici. Touche-la pour l'entendre." },
  { sel: "#speak", ic: "🔊", t: "Parler", d: "Touche le haut-parleur pour dire ta phrase à voix haute." },
  { sel: "#search", ic: "🔎", t: "Retrouver un mot", d: "Cherche un mot : l'outil montre dans quelle catégorie il est rangé, et le surligne." },
  { sel: "#nav-edit", ic: "🎨", t: "Personnaliser", d: "« Modifier » : ajoute tes mots, tes photos, tes couleurs, tes bruitages." },
  { sel: ".toolbar", ic: "🧭", t: "Se déplacer", d: "En bas : Ma voix, Scènes, Écrire, Réglages. Re-touche un onglet pour revenir à l'accueil." },
];
/* Petit bouton « écouter » (TTS) partagé par toutes les bulles d'aide : le guidage
   ne doit pas passer QUE par l'écrit -> on peut toujours l'entendre. */
function guideSpeakBtn(getText) {
  const b = document.createElement("button");
  b.className = "guide-hear"; b.type = "button";
  b.setAttribute("aria-label", "Écouter cette aide");
  b.innerHTML = "🔊 Écouter";
  b.onclick = (e) => { e.stopPropagation(); if (typeof speakOut === "function") speakOut(getText()); };
  return b;
}
function maybeCoach() {
  if (state.settings.coachDone) return;
  const pv = document.getElementById("privacy"), pf = document.getElementById("profile");
  if ((pv && !pv.hidden) || (pf && !pf.hidden)) return;   // attendre la fermeture des dialogues d'accueil
  if (state.view !== "board") return;
  setTimeout(() => startCoach(false), 450);
}
function startCoach(force) {
  if (!force && state.settings.coachDone) return;
  if (document.getElementById("coach-ov")) return;         // déjà ouvert
  state.settings.coachDone = true; saveSettings();
  if (state.view !== "board") setView("board");
  if (state.board !== "home") openBoard("home");
  let i = 0;
  const ov = document.createElement("div"); ov.className = "coach-ov"; ov.id = "coach-ov";
  const hole = document.createElement("div"); hole.className = "coach-hole";
  const bub = document.createElement("div"); bub.className = "coach-bubble";
  ov.append(hole, bub); document.body.appendChild(ov);
  const end = () => { ov.remove(); document.removeEventListener("keydown", onKey); };
  const next = () => { i++; render(); };
  function onKey(e) { if (e.key === "Escape") end(); else if (e.key === "Enter") next(); }
  document.addEventListener("keydown", onKey);
  function render() {
    let step = null, target = null;
    while (i < COACH_STEPS.length) { step = COACH_STEPS[i]; target = document.querySelector(step.sel); if (target) break; i++; }
    if (i >= COACH_STEPS.length || !target) { end(); return; }
    const r = target.getBoundingClientRect(), pad = 8;
    hole.style.left = (r.left - pad) + "px"; hole.style.top = (r.top - pad) + "px";
    hole.style.width = (r.width + pad * 2) + "px"; hole.style.height = (r.height + pad * 2) + "px";
    bub.innerHTML = "";
    const title = document.createElement("div"); title.className = "cb-title";
    if (step.ic) title.append(Object.assign(document.createElement("span"), { className: "cb-ic", textContent: step.ic }));
    title.append(document.createTextNode(step.t));
    bub.append(
      title,
      Object.assign(document.createElement("div"), { className: "cb-text", textContent: step.d }),
      guideSpeakBtn(() => step.t + ". " + step.d),
    );
    const row = document.createElement("div"); row.className = "cb-row";
    const dots = Object.assign(document.createElement("span"), { className: "cb-dots", textContent: (i + 1) + " / " + COACH_STEPS.length });
    const skip = Object.assign(document.createElement("button"), { className: "cb-skip", textContent: "Passer" }); skip.onclick = end;
    const nx = Object.assign(document.createElement("button"), { className: "cb-next", textContent: i === COACH_STEPS.length - 1 ? "Terminé ✓" : "Suivant ›" }); nx.onclick = next;
    row.append(dots, skip, nx); bub.appendChild(row);
    const bw = Math.min(330, window.innerWidth - 24); bub.style.width = bw + "px";
    bub.style.visibility = "hidden"; bub.style.left = "0"; bub.style.top = "0";
    requestAnimationFrame(() => {
      const bh = bub.offsetHeight;
      let top = r.bottom + 14, left = Math.min(Math.max(12, r.left), window.innerWidth - bw - 12);
      if (top + bh > window.innerHeight - 12) top = Math.max(12, r.top - bh - 14);
      bub.style.left = left + "px"; bub.style.top = top + "px"; bub.style.visibility = "visible";
    });
  }
  render();
}
window.startCoach = startCoach;

/* ---------- Message (info / erreur) bref, clair et ACTIONNABLE (Nielsen n°9) ---------- */
function toast(text, kind, icon, action) {
  const old = document.getElementById("ct-toast"); if (old) old.remove();
  const host = document.createElement("div"); host.id = "ct-toast";
  host.className = "jit-tip" + (kind === "error" ? " err" : "");
  host.setAttribute("role", kind === "error" ? "alert" : "status");
  if (icon) host.append(Object.assign(document.createElement("span"), { className: "jit-ic", textContent: icon }));
  host.append(Object.assign(document.createElement("span"), { className: "jit-txt", textContent: text }), guideSpeakBtn(() => text));
  // Action optionnelle (ex. « Annuler ») : rien n'est jamais perdu sans possibilité de revenir en arrière.
  if (action && action.label && typeof action.fn === "function") {
    const ab = document.createElement("button"); ab.className = "jit-x jit-annuler"; ab.textContent = action.label;
    ab.onclick = () => { try { action.fn(); } catch (e) {} host.remove(); };
    host.append(ab);
  }
  host.append((() => { const b = document.createElement("button"); b.className = "jit-x"; b.textContent = "OK"; b.onclick = () => host.remove(); return b; })());
  document.body.appendChild(host);
  const ttl = action ? 15000 : (kind === "error" ? 12000 : 8000);   // laisser le temps d'annuler
  setTimeout(() => { if (host.parentNode) { host.classList.add("out"); setTimeout(() => host.remove(), 400); } }, ttl);
}
window.toast = toast;

/* ---------- Astuces « juste-à-temps » : un indice bref, une seule fois, au bon moment ---------- */
function jitTip(key, text, icon) {
  const s = state.settings; s.jitSeen = s.jitSeen || {};
  if (s.tipsOff) return;                                       // aides coupées par la personne
  if (s.jitSeen[key]) return;                                  // déjà vue
  if (document.getElementById("coach-ov")) return;             // pas pendant la visite guidée
  s.jitSeen[key] = true; saveSettings();
  const old = document.getElementById("jit-tip"); if (old) old.remove();
  const host = document.createElement("div"); host.id = "jit-tip"; host.className = "jit-tip"; host.setAttribute("role", "status");
  if (icon) host.append(Object.assign(document.createElement("span"), { className: "jit-ic", textContent: icon }));
  host.append(
    Object.assign(document.createElement("span"), { className: "jit-txt", textContent: text }),
    guideSpeakBtn(() => text),
    (() => { const b = document.createElement("button"); b.className = "jit-x"; b.textContent = "Compris ✓"; b.onclick = () => host.remove(); return b; })(),
    disableTipsBtn(host)   // couper d'ici toutes les aides + textes de rappel
  );
  document.body.appendChild(host);
  setTimeout(() => { if (host.parentNode) { host.classList.add("out"); setTimeout(() => host.remove(), 400); } }, 8000);
}
// Bouton « ne plus afficher » : coupe aides ET textes de rappel, directement,
// sans passer par les Réglages (réactivable là-bas). [[autonomie-moins-permissions]]
function disableTipsBtn(host) {
  const b = document.createElement("button");
  b.className = "jit-x jit-off";
  b.textContent = "Ne plus afficher";
  b.title = "Couper les aides et les textes de rappel (réactivable dans Réglages)";
  b.onclick = () => {
    state.settings.tipsOff = true; saveSettings();
    if (host && host.remove) host.remove();
    document.querySelectorAll(".anchor-learn, #jit-tip").forEach(n => n.remove());
    if (state.board === "__ancres" && state.view === "board") renderBoard();
  };
  return b;
}
window.disableTipsBtn = disableTipsBtn;

/* ---------- Mode MODÉLISATION (partenaire) : « je montre les mots » ----------
 * Stimulation du langage assisté (Ahern/Musselwhite/Erickson) : le partenaire pointe et
 * DIT les mots pour modéliser, SANS que cela devienne la phrase de la personne. */
function modelWord(item) {
  const say = item.phrase ? labelFor(item) : (sayFor(item) || labelFor(item));
  const lang = (typeof langFor === "function") ? langFor(item) : item.lang;
  speak(say, lang || "fr-FR", { modeling: true });   // voix distincte du partenaire (jamais Piper ici)
  const n = norm(labelFor(item));
  const t = [...document.querySelectorAll("#board .tile")].find(el => { const l = el.querySelector(".tile-label"); return l && norm(l.textContent) === n; });
  if (t) { t.classList.add("spotlight"); setTimeout(() => t.classList.remove("spotlight"), 1200); }
  showModelPopup(item);
}
// Pop-up « Montrer » : affiche EN GRAND le mot montré (+ son picto), sans rien ajouter.
function showModelPopup(item) {
  let p = document.getElementById("model-pop");
  if (!p) { p = document.createElement("div"); p.id = "model-pop"; p.className = "model-pop"; document.body.appendChild(p); }
  const key = norm(labelFor(item));
  // Reclic sur le MÊME mot alors que le pop-up est ouvert -> il disparaît (bascule).
  if (!p.hidden && p.dataset.word === key) { p.hidden = true; return; }
  p.hidden = false; p.dataset.word = key; p.innerHTML = "";
  const card = document.createElement("div"); card.className = "model-pop-card";
  card.addEventListener("click", () => { p.hidden = true; });   // clic SUR le pop-up -> il disparaît
  if (item.sym_path) { const im = document.createElement("img"); im.src = item.sym_path; im.alt = ""; card.appendChild(im); if (typeof applySkin === "function") applySkin(im); }
  card.appendChild(Object.assign(document.createElement("div"), { className: "model-pop-word", textContent: labelFor(item) }));
  p.appendChild(card);
}
// ZONE DE GROSSISSEMENT FIXE (profil tout-petit) : à la sélection, le mot s'affiche EN GRAND
// (picto + mot écrit) dans un calque fixe centré, SANS déplacer la grille (pointer-events:none :
// on peut déjà toucher le mot suivant). Réutilise le style .model-pop. Auto-effacement configurable.
let _zoomTimer = null;
// Cœur : affiche EN GRAND (picto + texte donné) dans le calque fixe. Réutilisé par la sélection
// ET par les transformations (féminin/pluriel/infinitif/temps) pour montrer la NOUVELLE forme.
function showZoom(sym, labelText, skinItem) {
  if (!state.settings.toutPetit || state.settings.zoomOnSelect === false) return;
  let p = document.getElementById("tp-zoom");
  if (!p) { p = document.createElement("div"); p.id = "tp-zoom"; p.className = "model-pop tp-zoom"; p.setAttribute("aria-hidden", "true"); document.body.appendChild(p); }
  p.hidden = false; p.innerHTML = "";
  const card = document.createElement("div"); card.className = "model-pop-card";
  card.addEventListener("click", () => { p.hidden = true; });   // toucher le grossissement le referme
  if (sym) { const im = document.createElement("img"); im.src = sym; im.alt = ""; card.appendChild(im); if (skinItem && typeof applySkin === "function") applySkin(im, skinItem); }
  card.appendChild(Object.assign(document.createElement("div"), { className: "model-pop-word", textContent: labelText }));
  p.appendChild(card);
  if (_zoomTimer) clearTimeout(_zoomTimer);
  const ms = (typeof state.settings.zoomMs === "number") ? state.settings.zoomMs : 1000;
  _zoomTimer = setTimeout(() => { if (p) p.hidden = true; }, ms);
}
function showSelectZoom(item) {
  const sym = (typeof pictoFor === "function") ? pictoFor(item) : item.sym_path;
  showZoom(sym, labelFor(item), item);
}
// Après une transformation : montre en grand la NOUVELLE forme du jeton concerné (profil tout-petit).
function showFormZoom(tok) {
  if (!tok) return;
  const idx = state.tokens.indexOf(tok);
  const label = (typeof tokenDisplay === "function") ? tokenDisplay(tok, idx >= 0 ? idx : state.tokens.length - 1) : (tok.label || "");
  const sym = tok.sym_path || ((typeof pictoFor === "function") ? pictoFor(tok) : null);
  showZoom(sym, label, tok);
}
/* ---------- Jeu « Chercher » (apprendre la carte — JAMAIS un préalable à la parole) ----------
   Une cible dans la grille STABLE. Indices PROGRESSIFS (halo → estompe les autres), aucune sanction
   en cas d'erreur, aucun mot verrouillé, arrêt à tout moment. Niveau « en situation » : aucune bonne
   réponse unique. On ne COMPTE pas les réussites (on ne teste pas l'enfant) ; le mot touché est aussi
   dit et ajouté à la phrase → c'est de la vraie communication, pas un exercice. */
const FIND_WORDS = ["encore", "fini", "manger", "boire", "aimer", "aider", "regarder", "donner", "chaud", "froid", "dormir", "jouer", "non", "oui", "content", "aussi", "vouloir"];
let findState = null;
function findTileFor(label) {
  const n = norm(label);
  return [...document.querySelectorAll("#board .tile:not(.cat):not(.back)")].find(el => { const l = el.querySelector(".tile-label"); return l && norm(l.textContent) === n; }) || null;
}
function findKeyFor(ch) {   // touche-lettre correspondante (clavier)
  const n = norm(ch);
  return [...document.querySelectorAll("#keys .key")].find(k => norm(k.dataset.key || k.textContent) === n) || null;
}
function clearFindHints() {
  document.querySelectorAll(".tile.ct-dim, .tile.ct-spot, .tile.ct-found, .key.ct-dim, .key.ct-spot, .key.ct-found").forEach(t => t.classList.remove("ct-dim", "ct-spot", "ct-found"));
  document.querySelectorAll(".kb-row.row-dim").forEach(r => r.classList.remove("row-dim"));
}
function applyFindHint() {
  clearFindHints();
  if (!findState || findState.situation) return;
  if (findState.mode === "kb") {   // CLAVIER : indice RÉGION (ligne) puis HALO sur la lettre — emplacements inchangés
    const key = findKeyFor(findState.target), row = key ? key.closest(".kb-row") : null;
    if (findState.level >= 1 && row) document.querySelectorAll("#keys .kb-row").forEach(r => { if (r !== row) r.classList.add("row-dim"); });
    if (findState.level >= 2 && key) key.classList.add("ct-spot");
    return;
  }
  const tile = findTileFor(findState.target);
  if (findState.level >= 1 && tile) tile.classList.add("ct-spot");                 // niveau 1 : halo sur la cible
  if (findState.level >= 2) document.querySelectorAll("#board .tile:not(.cat):not(.back)").forEach(t => { if (t !== tile) t.classList.add("ct-dim"); });  // niveau 2 : estompe les autres
}
function pickFindTarget() {
  if (findState && findState.mode === "kb") {
    const keys = [...document.querySelectorAll("#keys .key")].map(k => k.dataset.key).filter(c => c && /^[a-zà-ÿ]$/i.test(c));
    const pool = keys.length ? keys : ["a", "e", "i", "o", "u"];
    findState = { target: pool[Math.floor(Math.random() * pool.length)], level: 0, situation: false, mode: "kb" };
  } else {
    const present = FIND_WORDS.filter(findTileFor);
    const pool = present.length ? present : FIND_WORDS;
    findState = { target: pool[Math.floor(Math.random() * pool.length)], level: 0, situation: false, mode: "board" };
  }
  applyFindHint(); renderFindBar();
}
function onFindPossibleHit(itemOrCh) {
  if (!findState || findState.situation) return;
  const isKb = findState.mode === "kb";
  const val = isKb ? norm(itemOrCh) : norm(labelFor(itemOrCh));
  if (val !== norm(findState.target)) return;   // pas la cible : AUCUNE sanction (la lettre/le mot est quand même écrit)
  clearFindHints();
  const el = isKb ? findKeyFor(findState.target) : findTileFor(findState.target);
  if (el) { el.classList.add("ct-found"); setTimeout(() => el.classList.remove("ct-found"), 1400); }
  const q = document.querySelector("#find-bar .find-q"); if (q) q.textContent = "Voilà « " + findState.target + " » !";
}
function startFindGame() {
  if (state.view !== "board") setView("board");
  document.body.classList.add("finding"); findState = { mode: "board" }; pickFindTarget();
}
function startLetterFind() {
  state.settings.keyboardOnHome = true; saveSettings();
  if (state.view !== "board" && state.view !== "write") setView("board"); else setView(state.view);
  const kb = document.getElementById("keyboard"); if (kb) kb.hidden = false;
  document.body.classList.add("finding"); findState = { mode: "kb" }; pickFindTarget();
}
function stopFindGame() {
  document.body.classList.remove("finding"); clearFindHints(); findState = null;
  const b = document.getElementById("find-bar"); if (b) b.hidden = true;
}
window.startFindGame = startFindGame; window.startLetterFind = startLetterFind;
function renderFindBar() {
  let b = document.getElementById("find-bar");
  if (!b) { b = document.createElement("div"); b.id = "find-bar"; b.setAttribute("role", "group"); b.setAttribute("aria-label", "Jeu : chercher"); document.body.appendChild(b); }
  b.hidden = false; b.innerHTML = "";
  const isKb = findState.mode === "kb";
  const mk = (txt, cls, fn) => { const x = document.createElement("button"); x.type = "button"; x.textContent = txt; if (cls) x.className = cls; x.onclick = fn; return x; };
  const q = document.createElement("span"); q.className = "find-q";
  q.textContent = findState.situation ? "La bulle s'arrête… qu'est-ce que tu veux dire ?" : ((isKb ? "Où est la lettre « " : "Où est « ") + findState.target + " » ?");
  b.appendChild(q);
  if (!findState.situation) b.appendChild(mk("Un indice", "", () => { findState.level = Math.min(2, findState.level + 1); applyFindHint(); }));
  b.appendChild(mk(isKb ? "Une autre lettre" : "Un autre mot", "", () => { findState.situation = false; pickFindTarget(); }));
  if (!isKb) b.appendChild(mk(findState.situation ? "Chercher un mot" : "En situation", "", () => { findState.situation = !findState.situation; findState.level = 0; clearFindHints(); renderFindBar(); }));
  b.appendChild(mk("Arrêter", "primary", stopFindGame));
}
function renderModelBanner() {
  const mt = document.getElementById("model-toggle");
  if (mt) { mt.classList.toggle("is-on", !!state.modeling); mt.setAttribute("aria-pressed", String(!!state.modeling));
            mt.setAttribute("aria-label", state.modeling ? "Désactiver le mode modélisation" : "Activer le mode modélisation"); }
  const b = document.getElementById("model-banner"); if (!b) return;
  b.hidden = !state.modeling;
  document.body.classList.toggle("modeling", state.modeling);
  if (!state.modeling) { b.innerHTML = ""; const mp = document.getElementById("model-pop"); if (mp) mp.hidden = true; return; }
  b.innerHTML = "";
  b.append(
    Object.assign(document.createElement("span"), { className: "mb-txt", innerHTML: "🖐️ <strong>Je montre les mots.</strong> Rien n'est ajouté à la phrase de la personne." }),
    (() => { const x = document.createElement("button"); x.className = "mb-end"; x.textContent = "Rendre la parole ✓"; x.onclick = () => setModeling(false); return x; })()
  );
}
function setModeling(on) {
  state.modeling = !!on;
  if (on) {
    if (state.view !== "board") setView("board");
    if (state.board !== "home") openBoard("home");
    jitTip("modeling", "Touche les mots pour les MONTRER (ils se disent, se surlignent) sans les ajouter. Touche « Rendre la parole » pour redonner la main.", "🖐️");
  }
  renderModelBanner();
}
window.setModeling = setModeling;

/* ---------- Démarrage ---------- */
async function boot() {
  load();
  migrateVoiceSettingsOnce();   // anciens réglages globaux -> voiceParams.system (une fois)
  ensurePron();                 // lexique de prononciation : amorce l'exemple (CAA) la 1re fois
  try {
    const res = await fetch("vocab.default.json?v=" + (window.BUILD || "13"));
    state.vocab = await res.json();
  } catch (e) {
    document.body.innerHTML = "<p style='padding:2rem'>Impossible de charger le vocabulaire. Lancez l'appli via un serveur local (voir LISEZMOI).</p>";
    return;
  }
  // 1er lancement : activer les packs « par défaut » (socle francophone)
  if (state.settings.packs == null) {
    state.settings.packs = (state.vocab.packs || []).filter(p => p.default).map(p => p.id);
    saveSettings();
  }
  applyLook();
  wire();
  if (state.settings.expGrammar) loadJsRealB().then(() => renderMessage());   // moteur avancé déjà activé : recharger à la demande
  applyChromeIcons();
  renderTabs(); renderBoard(); renderMessage(); renderToneBar();
  if (!localStorage.getItem(LS.seen)) document.getElementById("privacy").hidden = false;
  else maybeShowProfile();
  maybeCoach();
  setupBackButton();
  requestPersistentStorage();
  if (state.settings.engine === "piper") warmPiper();
  if ("serviceWorker" in navigator) {
    // Mise à jour AUTOMATIQUE : quand une nouvelle version est activée, on recharge une fois
    // (fini les « Ctrl+Maj+R » : l'appli se met à jour toute seule).
    let _refreshing = false;
    navigator.serviceWorker.addEventListener("controllerchange", () => { if (_refreshing) return; _refreshing = true; location.reload(); });
    navigator.serviceWorker.register("service-worker.js").then(reg => {
      if (reg.waiting) reg.waiting.postMessage("skip");   // une MAJ attend déjà
      reg.addEventListener("updatefound", () => {
        const nw = reg.installing;
        if (nw) nw.addEventListener("statechange", () => { if (nw.state === "installed" && navigator.serviceWorker.controller) nw.postMessage("skip"); });
      });
      setInterval(() => reg.update().catch(() => {}), 60000);   // vérifie une MAJ toutes les minutes
    }).catch(() => {});
  }
}
// --- Installation PWA -------------------------------------------------------
// On mémorise l'invite native d'installation (Chrome/Edge Android + ordinateur)
// pour la déclencher depuis notre bouton « Installer ». Ailleurs (iOS Safari,
// navigateurs sans invite), on affiche des instructions par plateforme.
let deferredInstallPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstallPrompt = e; });
window.addEventListener("appinstalled", () => { deferredInstallPrompt = null; });
function pwaInstallHelp() {
  const ua = navigator.userAgent || "";
  if (/iPhone|iPad|iPod/i.test(ua) || (/(Macintosh)/.test(ua) && "ontouchend" in document))
    return "Sur iPhone / iPad : touchez le bouton Partager (le carré avec une flèche vers le haut) en bas de Safari, puis « Sur l'écran d'accueil ». L'appli s'ajoute comme une application.";
  if (/Android/i.test(ua))
    return "Sur Android : ouvrez le menu ⋮ du navigateur, puis « Installer l'application » ou « Ajouter à l'écran d'accueil ».";
  return "Sur ordinateur (Chrome ou Edge) : cliquez sur l'icône d'installation à droite de la barre d'adresse, ou menu ⋮ → « Installer Com'Together ». L'appli s'ouvre ensuite comme un vrai logiciel, même hors-ligne.";
}

// --- Bouton « précédent » (matériel/navigateur) : jamais quitter par surprise ---
// Livre blanc : « Back est fondamental. » Le retour ferme d'abord un pop-up, sinon
// revient à l'accueil (vue ou catégorie), et ne laisse quitter que DEPUIS l'accueil.
function ctInAppBack() {
  // 1) recherche / pop-up / modale ouverte -> fermer
  const search = document.getElementById("path-search");
  if (document.body.classList.contains("path-open") || (search && search.offsetParent !== null)) {
    (document.querySelector("#path-search .btn-close") || document.querySelector(".btn-close"))?.click(); return true;
  }
  const ov = document.querySelector("#coach-ov, .ct-modal, .viewer-back, .popup-back, .ct-backdrop, .popup, .modal");
  if (ov && ov.offsetParent !== null) { const cb = ov.querySelector(".btn-close") || document.querySelector(".btn-close"); if (cb) cb.click(); else ov.remove(); return true; }
  // 2) vue secondaire (Ma voix, Réglages…) -> accueil
  if (typeof state !== "undefined" && state.view && state.view !== "board") { setView("board"); return true; }
  // 3) catégorie ouverte -> accueil
  if (typeof state !== "undefined" && state.board && state.board !== "home") { openBoard("home"); return true; }
  return false;
}
// Stockage PERSISTANT : demande au navigateur de NE JAMAIS évincer les données de l'appli
// (comptes, vocabulaire, scènes, voix). L'appli n'impose aucune limite ; le plafond reste celui
// du navigateur/appareil. Voir aussi storageInfo() affiché dans les Réglages.
async function requestPersistentStorage() {
  try {
    if (navigator.storage && navigator.storage.persist) {
      const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
      if (!already) await navigator.storage.persist();
    }
  } catch (e) {}
}
async function storageInfo() {
  try {
    const est = navigator.storage && navigator.storage.estimate ? await navigator.storage.estimate() : null;
    const persisted = navigator.storage && navigator.storage.persisted ? await navigator.storage.persisted() : false;
    if (!est) return null;
    const mo = (b) => Math.round((b || 0) / 1e6);
    return { usedMo: mo(est.usage), quotaMo: mo(est.quota), persisted };
  } catch (e) { return null; }
}
function setupBackButton() {
  if (window.__ctBackReady) return; window.__ctBackReady = true;
  window.addEventListener("popstate", () => {
    const did = ctInAppBack();
    if (did) { try { history.pushState({ ct: 1 }, ""); } catch {} }   // garder un tampon pour le prochain retour
  });
  try { history.pushState({ ct: 1 }, ""); } catch {}   // tampon initial
}

// --- Jeu d'ICÔNES SVG cohérent (barre d'outils + menu) ------------------------
// Livre blanc : « icônes ≤ une dizaine, cohérentes ». Les emoji s'affichent
// DIFFÉREMMENT selon l'OS ; ces SVG au trait rendent PAREIL partout et suivent le
// thème (stroke:currentColor -> hérite de la couleur du bouton, estompée ou héros).
const _svgIc = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${inner}</svg>`;
const CT_ICONS = {
  search: _svgIc('<circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>'),
  keyboard: _svgIc('<rect x="2" y="6" width="20" height="12" rx="2.5"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h.01M18 14h.01M9.5 14h5"/>'),
  moon: _svgIc('<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>'),
  sun: _svgIc('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
  walk: _svgIc('<circle cx="5.5" cy="18.5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18.5" cy="5.5" r="1.4"/><path d="M6.8 17.2 10.6 13.4M13.4 10.6 17.2 6.8" stroke-dasharray="0.2 3.2"/>'),
  hand: _svgIc('<path d="M8 12V6a1.6 1.6 0 0 1 3.2 0v4M11.2 10V5a1.6 1.6 0 0 1 3.2 0v5M14.4 10.5a1.6 1.6 0 0 1 3.2 0V15a6 6 0 0 1-6 6 6 6 0 0 1-5-2.7l-2-3a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
  face: _svgIc('<circle cx="12" cy="12" r="9"/><path d="M8.5 14.5s1.4 1.8 3.5 1.8 3.5-1.8 3.5-1.8"/><path d="M9 9.5h.01M15 9.5h.01"/>'),
  wave: _svgIc('<path d="M3 12h1.4M7.4 8.5v7M12 5v14M16.6 8.5v7M20.6 12H22"/>'),
  speaker: _svgIc('<path d="M4 9.5v5h3.5L13 19V5L7.5 9.5H4z"/><path d="M16.5 8.8a4.5 4.5 0 0 1 0 6.4M19 6.5a8 8 0 0 1 0 11"/>'),
  backspace: _svgIc('<path d="M20 6H9.4a2 2 0 0 0-1.5.7L3 12l4.9 5.3a2 2 0 0 0 1.5.7H20a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1z"/><path d="M15 10l-4 4M11 10l4 4"/>'),
  trash: _svgIc('<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6.5 7l.8 12a2 2 0 0 0 2 1.9h5.4a2 2 0 0 0 2-1.9L17.5 7"/><path d="M10 11v6M14 11v6"/>'),
  home: _svgIc('<path d="M3.5 11.5 12 4.5l8.5 7"/><path d="M5.5 10v9a1 1 0 0 0 1 1H10v-5.5h4V20h3.5a1 1 0 0 0 1-1v-9"/>'),
  megaphone: _svgIc('<path d="M4 10v4a1 1 0 0 0 1 1h2l8 4V5L7 9H5a1 1 0 0 0-1 1z"/><path d="M8 15.3V18a1.5 1.5 0 0 0 3 0v-1.6"/><path d="M18.5 9.5a4 4 0 0 1 0 5"/>'),
  heart: _svgIc('<path d="M12 20.5S4 15.5 4 9.8A4 4 0 0 1 12 8a4 4 0 0 1 8 1.8c0 5.7-8 10.7-8 10.7z"/>'),
  image: _svgIc('<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="8.5" cy="10" r="1.6"/><path d="M4 17l5-5 3.5 3.5L16 12l4 5"/>'),
  pencil: _svgIc('<path d="M4 20h4L19 9a2 2 0 0 0 0-3l-1-1a2 2 0 0 0-3 0L4 16v4z"/><path d="M14.5 6.5l3 3"/>'),
  gear: _svgIc('<circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v2.3M12 19.2v2.3M4.6 4.6l1.6 1.6M17.8 17.8l1.6 1.6M2.5 12h2.3M19.2 12h2.3M4.6 19.4l1.6-1.6M17.8 6.2l1.6-1.6"/>'),
  clock: _svgIc('<circle cx="12" cy="12" r="9"/><path d="M12 7.5V12l3 2"/>'),
  folder: _svgIc('<path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
  sparkle: _svgIc('<path d="M12 4l1.5 4.9L18.5 10l-5 1.5L12 16.5l-1.5-5L5.5 10l5-1.1z"/>'),
  pin: _svgIc('<path d="M12 21v-6"/><path d="M8.5 4.5h7l-1 3.2 2.2 3.3a1 1 0 0 1-.8 1.5H7.1a1 1 0 0 1-.8-1.5l2.2-3.3z"/>'),
  bubble: _svgIc('<path d="M20 4H4a1.5 1.5 0 0 0-1.5 1.5v8A1.5 1.5 0 0 0 4 15h3v4l4.5-4H20a1.5 1.5 0 0 0 1.5-1.5v-8A1.5 1.5 0 0 0 20 4z"/><path d="M8 9.5h.01M12 9.5h.01M16 9.5h.01"/>'),
  // Ajouts pour les boutons des Réglages (mêmes règles : trait, currentColor, suit le thème).
  save: _svgIc('<path d="M4 14v5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5"/><path d="M12 4v11M8 11l4 4 4-4"/>'),
  restore: _svgIc('<path d="M4 14v5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5"/><path d="M12 15V4M8 8l4-4 4 4"/>'),
  refresh: _svgIc('<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4h-4"/>'),
  install: _svgIc('<rect x="6" y="3" width="12" height="18" rx="2"/><path d="M12 8v6M9.5 11.5 12 14l2.5-2.5"/>'),
  download: _svgIc('<path d="M12 4v10M8 10l4 4 4-4"/><path d="M5 20h14"/>'),
  book: _svgIc('<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H11v16H6.5A2.5 2.5 0 0 0 4 21.5z"/><path d="M20 5.5A2.5 2.5 0 0 0 17.5 3H13v16h4.5A2.5 2.5 0 0 1 20 21.5z"/>'),
  chart: _svgIc('<path d="M4 20V4M4 20h16"/><path d="M7.5 17v-4M12 17V9M16.5 17v-6"/>'),
  globe: _svgIc('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>'),
  finger: _svgIc('<path d="M9 11.5V5.5a1.5 1.5 0 0 1 3 0V11"/><path d="M12 8.5a1.5 1.5 0 0 1 3 0V11M15 10a1.5 1.5 0 0 1 3 0v4.5a5 5 0 0 1-5 5h-1a4 4 0 0 1-3-1.4L6.2 15.6a1.5 1.5 0 0 1 2.3-1.9L9.5 15"/>'),
  switch: _svgIc('<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.4"/>'),
  eye: _svgIc('<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.6"/>'),
};
// Icône inline pour les libellés de boutons (chaînes gabarits des Réglages) : SVG cohérent au lieu d'un emoji.
function uiIcon(name) { return '<span class="ct-ic ct-ic-btn">' + ((typeof CT_ICONS !== "undefined" && CT_ICONS[name]) || "") + '</span>'; }
window.uiIcon = uiIcon;
// Visages d'émotion (barre de ton) — même style au trait, cohérent partout.
const MOOD_ICONS = {
  neutre:  _svgIc('<circle cx="12" cy="12" r="9"/><path d="M9 10h.01M15 10h.01M9 15h6"/>'),
  joyeux:  _svgIc('<circle cx="12" cy="12" r="9"/><path d="M9 10h.01M15 10h.01"/><path d="M8.5 14s1.4 2 3.5 2 3.5-2 3.5-2"/>'),
  triste:  _svgIc('<circle cx="12" cy="12" r="9"/><path d="M9 10h.01M15 10h.01"/><path d="M8.5 16s1.4-2 3.5-2 3.5 2 3.5 2"/>'),
  colere:  _svgIc('<circle cx="12" cy="12" r="9"/><path d="M8 9.2l2 .9M16 9.2l-2 .9"/><path d="M9 11.2h.01M15 11.2h.01"/><path d="M8.5 16s1.4-1.6 3.5-1.6 3.5 1.6 3.5 1.6"/>'),
  calme:   _svgIc('<circle cx="12" cy="12" r="9"/><path d="M8.3 10.6c.5-.7 1.7-.7 2.2 0M13.5 10.6c.5-.7 1.7-.7 2.2 0"/><path d="M9 14.6s1.2 1 3 1 3-1 3-1"/>'),
  peur:    _svgIc('<circle cx="12" cy="12" r="9"/><circle cx="9.3" cy="10.3" r="1.1"/><circle cx="14.7" cy="10.3" r="1.1"/><circle cx="12" cy="16" r="1.7"/>'),
  excite:  _svgIc('<circle cx="12" cy="12" r="9"/><path d="M8.2 10.6l1.6-1.1 1.6 1.1M12.6 10.6l1.6-1.1 1.6 1.1"/><path d="M8 13.6a4 4 0 0 0 8 0z"/>'),
  fatigue: _svgIc('<circle cx="12" cy="12" r="9"/><path d="M8.3 10.4h2.4M13.3 10.4h2.4"/><path d="M9.5 15.5h5"/>'),
};
function applyChromeIcons() {
  const set = (sel, name, spanSel) => {
    const btn = document.querySelector(sel); if (!btn || !CT_ICONS[name]) return;
    const span = spanSel ? btn.querySelector(spanSel) : btn.querySelector('span[aria-hidden="true"]');
    if (span) { span.innerHTML = CT_ICONS[name]; span.classList.add("ct-ic"); }
  };
  // barre d'outils du haut
  set("#search", "search"); set("#kbd-toggle", "keyboard"); set("#promenade-toggle", "walk");
  set("#model-toggle", "hand"); set("#tone-toggle", "face"); set("#decode-btn", "wave");
  set("#speak", "speaker"); set("#backspace", "backspace"); set("#clear", "trash");
  set("#theme-toggle", ((state && state.settings && state.settings.theme === "dark") ? "sun" : "moon"), ".th-ico");
  // menu du bas
  set("#nav-home", "home", ".tool-ico"); set("#nav-advocacy", "megaphone", ".tool-ico");
  set("#nav-etat", "heart", ".tool-ico"); set("#nav-scenes", "image", ".tool-ico");
  set("#nav-write", "keyboard", ".tool-ico"); set("#nav-edit", "pencil", ".tool-ico"); set("#nav-settings", "gear", ".tool-ico");
  set("#cat-launcher", "folder");
  // barre de grammaire : l'horloge « 🕐 Temps : »
  const tt = document.getElementById("tense-toggle");
  if (tt && !tt.querySelector(".ct-ic")) tt.innerHTML = tt.innerHTML.replace("🕐", `<span class="ct-ic ct-ic-inline">${CT_ICONS.clock}</span>`);
}

document.addEventListener("DOMContentLoaded", boot);
