/* 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. */
/* ============================================================================
 * custom.js — Personnalisation avancée (100% local, RGPD)
 *   • Médias dans les cellules : photo / GIF locaux, lien YouTube, vidéo, lien
 *   • Éditeur de cellule (texte + son + couleur + média), le picto reste
 *   • Pop-up de catégorie (surcouche qui se referme après sélection)
 *   • Scènes visuelles (VSD) : image + zones tactiles parlantes
 * Les fichiers médias sont stockés dans IndexedDB, sur l'appareil uniquement.
 * Aucune donnée n'est envoyée sur Internet (YouTube : embed « sans cookie »,
 * chargé seulement quand la personne le demande).
 * ==========================================================================*/
(function () {
  "use strict";

  /* ---------- IndexedDB (médias) — UNE base par PROFIL (isolation locale) ---------- */
  // Profil « default » = base historique « ct_media » (aucune migration pour l'existant).
  // Autres profils = « ct_media__<id> ». Le profil actif vient de window.CT_PROFILE (posé par app.js).
  const STORE = "media";
  function dbName() { const p = window.CT_PROFILE || "default"; return p === "default" ? "ct_media" : "ct_media__" + p; }
  let dbP = null, dbPName = null;
  function db() {
    const name = dbName();
    if (dbP && dbPName === name) return dbP;
    dbPName = name;
    dbP = new Promise((res, rej) => {
      const r = indexedDB.open(name, 1);
      r.onupgradeneeded = () => r.result.createObjectStore(STORE);
      r.onsuccess = () => res(r.result);
      r.onerror = () => rej(r.error);
    });
    return dbP;
  }
  async function putMedia(key, blob) {
    const d = await db();
    return new Promise((res, rej) => {
      const t = d.transaction(STORE, "readwrite");
      t.objectStore(STORE).put(blob, key);
      t.oncomplete = res; t.onerror = () => rej(t.error);
    });
  }
  // SOFT-DELETE : le blob n'est JAMAIS détruit — on le déplace en corbeille (« __trash__ »),
  // d'où il reste restaurable (aucune action irréversible). La corbeille est exclue des sauvegardes.
  async function delMedia(key) {
    const d = await db();
    return new Promise((res) => {
      const t = d.transaction(STORE, "readwrite");
      const st = t.objectStore(STORE);
      const g = st.get(key);
      g.onsuccess = () => { const b = g.result; if (b !== undefined) { try { st.put(b, "__trash__" + key); st.delete(key); } catch (e) {} } };
      t.oncomplete = res; t.onerror = res;   // best-effort : on n'échoue jamais pour un blob orphelin
    });
  }
  async function restoreMedia(key) {
    const d = await db();
    return new Promise((res) => {
      const t = d.transaction(STORE, "readwrite");
      const st = t.objectStore(STORE);
      const g = st.get("__trash__" + key);
      g.onsuccess = () => { const b = g.result; if (b !== undefined) { try { st.put(b, key); st.delete("__trash__" + key); } catch (e) {} } };
      t.oncomplete = res; t.onerror = res;
    });
  }
  async function getMedia(key) {
    const d = await db();
    return new Promise((res, rej) => {
      const t = d.transaction(STORE, "readonly");
      const rq = t.objectStore(STORE).get(key);
      rq.onsuccess = () => res(rq.result || null); rq.onerror = () => rej(rq.error);
    });
  }
  const urlCache = new Map();
  async function urlFor(key) {
    if (urlCache.has(key)) return urlCache.get(key);
    const b = await getMedia(key); if (!b) return null;
    const u = URL.createObjectURL(b); urlCache.set(key, u); return u;
  }
  // Bascule de profil : oublier le cache d'URL et forcer la reconnexion à la base du nouveau profil.
  function onProfileSwitch() {
    for (const u of urlCache.values()) { try { URL.revokeObjectURL(u); } catch (_) { } }
    urlCache.clear(); dbP = null; dbPName = null;
  }
  // ---- Sauvegarde COMPLÈTE : exporter / réimporter TOUS les médias (scènes, enregistrements de voix) ----
  async function allKeys() {
    const d = await db();
    return new Promise((res, rej) => {
      const t = d.transaction(STORE, "readonly");
      const rq = t.objectStore(STORE).getAllKeys();
      rq.onsuccess = () => res((rq.result || []).filter(k => typeof k !== "string" || !k.startsWith("__trash__"))); rq.onerror = () => rej(rq.error);
    });
  }
  function blobToDataURL(blob) {
    return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => rej(r.error); r.readAsDataURL(blob); });
  }
  // Reconstruit un Blob depuis une data-URL SANS fetch (interdit par la CSP connect-src 'self').
  function dataURLToBlob(u) {
    const i = u.indexOf(","); if (i < 0) return null;
    const meta = u.slice(5, i); const mime = (meta.split(";")[0]) || "application/octet-stream";
    const body = u.slice(i + 1);
    if (/;base64/i.test(meta)) {
      const bin = atob(body); const arr = new Uint8Array(bin.length);
      for (let j = 0; j < bin.length; j++) arr[j] = bin.charCodeAt(j);
      return new Blob([arr], { type: mime });
    }
    return new Blob([decodeURIComponent(body)], { type: mime });
  }
  // Empreinte SHA-256 (hex) d'un ArrayBuffer, pour détecter les médias corrompus.
  // crypto.subtle exige un contexte sécurisé (localhost/https OK) ; sinon on renvoie null (pas de vérif).
  async function sha256Hex(buf) {
    if (!(self.crypto && self.crypto.subtle)) return null;
    const h = await self.crypto.subtle.digest("SHA-256", buf);
    return Array.from(new Uint8Array(h)).map(x => x.toString(16).padStart(2, "0")).join("");
  }
  async function exportAllMedia() {
    const keys = await allKeys(); const out = [];
    for (const k of keys) {
      if (typeof k !== "string") continue;
      const b = await getMedia(k);
      if (b instanceof Blob) {
        try {
          const buf = await b.arrayBuffer();
          out.push({ key: k, type: b.type || "application/octet-stream", size: b.size, sha256: await sha256Hex(buf), data: await blobToDataURL(b) });
        } catch (e) { /* on saute un blob illisible */ }
      }
    }
    return out;
  }
  // Réimporte les médias en VÉRIFIANT l'empreinte de chacun. Renvoie un rapport précis :
  // { restored, corrupted:[clés], skipped, total }. N'efface JAMAIS les médias présents.
  async function importAllMedia(entries) {
    const rep = { restored: 0, corrupted: [], skipped: 0, total: (entries || []).length };
    for (const e of (entries || [])) {
      if (!e || typeof e.key !== "string" || typeof e.data !== "string" || e.data.indexOf("data:") !== 0) { rep.skipped++; continue; }
      const b = dataURLToBlob(e.data);
      if (!b) { rep.corrupted.push(e.key); continue; }
      if (e.sha256) {   // vérification d'intégrité si l'empreinte est présente
        try { const h = await sha256Hex(await b.arrayBuffer()); if (h && h !== e.sha256) { rep.corrupted.push(e.key); continue; } }
        catch (_) { rep.corrupted.push(e.key); continue; }
      }
      try { await putMedia(e.key, b); urlCache.delete(e.key); rep.restored++; }
      catch (_) { rep.corrupted.push(e.key); }
    }
    return rep;
  }

  /* ---------- Baguette magique : détourage du fond (canvas, 100% local) ---------- */
  // Rend le fond transparent (remplissage-diffusion depuis les bords) puis ajoute un CONTOUR NOIR fin
  // autour du sujet → la couleur de la cellule apparaît autour. Renvoie un Blob PNG.
  async function magicRemoveBg(blob) {
    const bmp = await createImageBitmap(blob);
    const MAX = 512, scale = Math.min(1, MAX / Math.max(bmp.width, bmp.height));
    const w = Math.max(1, Math.round(bmp.width * scale)), h = Math.max(1, Math.round(bmp.height * scale));
    const cv = document.createElement("canvas"); cv.width = w; cv.height = h;
    const cx = cv.getContext("2d"); cx.drawImage(bmp, 0, 0, w, h);
    const id = cx.getImageData(0, 0, w, h), d = id.data;
    // couleur de fond estimée = moyenne des 4 coins
    let cr = 0, cg = 0, cb = 0;
    [[0, 0], [w - 1, 0], [0, h - 1], [w - 1, h - 1]].forEach(([x, y]) => { const i = (y * w + x) * 4; cr += d[i]; cg += d[i + 1]; cb += d[i + 2]; });
    cr /= 4; cg /= 4; cb /= 4;
    const tol2 = 60 * 60 * 3;               // tolérance de couleur (fonds unis / dégradés doux)
    const visited = new Uint8Array(w * h), stack = [];
    const seed = (x, y) => {
      if (x < 0 || y < 0 || x >= w || y >= h) return;
      const p = y * w + x; if (visited[p]) return; visited[p] = 1;
      const i = p * 4, dr = d[i] - cr, dg = d[i + 1] - cg, db = d[i + 2] - cb;
      if (dr * dr + dg * dg + db * db <= tol2) { d[i + 3] = 0; stack.push(p); }
    };
    for (let x = 0; x < w; x++) { seed(x, 0); seed(x, h - 1); }
    for (let y = 0; y < h; y++) { seed(0, y); seed(w - 1, y); }
    while (stack.length) { const p = stack.pop(), x = p % w, y = (p / w) | 0; seed(x + 1, y); seed(x - 1, y); seed(x, y + 1); seed(x, y - 1); }
    cx.putImageData(id, 0, 0);
    // silhouette noire du sujet restant
    const sil = document.createElement("canvas"); sil.width = w; sil.height = h;
    const sc = sil.getContext("2d"); sc.drawImage(cv, 0, 0);
    sc.globalCompositeOperation = "source-in"; sc.fillStyle = "#231f20"; sc.fillRect(0, 0, w, h);
    // contour = silhouette décalée dans 8 directions, sujet par-dessus
    const out = document.createElement("canvas"); out.width = w; out.height = h; const ox = out.getContext("2d");
    const t = Math.max(2, Math.round(w * 0.012));
    for (let a = 0; a < 8; a++) { const ang = a * Math.PI / 4; ox.drawImage(sil, Math.round(Math.cos(ang) * t), Math.round(Math.sin(ang) * t)); }
    ox.drawImage(cv, 0, 0);
    return await new Promise(res => out.toBlob(res, "image/png"));
  }

  /* ---------- Éditeur de pixels : GOMME MAGIQUE manuelle + POT DE REMPLISSAGE ----------
   * On peut nettoyer soi-même le fond d'un picto/photo (clic = efface la zone de même
   * couleur) ou colorier une zone (pot). 100% local, sur un canvas. Renvoie un PNG. */
  function openPixelEditor(src, onDone) {
    const { card, close } = modal("Nettoyer / colorier", "ct-pixeleditor");
    const hostEl = document.getElementById("ct-pixeleditor"); if (hostEl) hostEl.style.zIndex = "90";
    card.appendChild(el("p", "hint", "Choisis un outil, puis touche la zone à traiter. La gomme efface le fond ; le pot colorie."));

    // barre d'outils
    const bar = el("div"); bar.style.cssText = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0";
    const bErase = el("button", "btn-secondary is-on", "🪄 Gomme magique");
    const bFill  = el("button", "btn-secondary", "🎨 Remplir");
    const color  = Object.assign(document.createElement("input"), { type: "color", value: "#ffd23f" });
    color.setAttribute("aria-label", "Couleur de remplissage"); color.style.cssText = "width:44px;height:36px;padding:0;border-radius:8px;border:2px solid var(--line)";
    const undo   = el("button", "btn-secondary", "↶ Annuler");
    bar.append(bErase, bFill, color, undo); card.appendChild(bar);

    // tolérance
    const tolWrap = el("div"); tolWrap.style.cssText = "display:flex;align-items:center;gap:10px;margin:2px 0 8px";
    tolWrap.append(Object.assign(el("label"), { textContent: "Sensibilité", style: "font-weight:700" }));
    const tol = Object.assign(document.createElement("input"), { type: "range", min: "8", max: "140", value: "60" });
    tol.style.flex = "1"; tolWrap.appendChild(tol); card.appendChild(tolWrap);

    // zone canvas (damier = transparence)
    const wrap = el("div"); wrap.style.cssText = "background:repeating-conic-gradient(#e6e6ec 0% 25%,#fff 0% 50%) 50%/22px 22px;border:2px solid var(--line);border-radius:12px;display:flex;justify-content:center;padding:6px;touch-action:none";
    const cv = document.createElement("canvas"); cv.style.cssText = "max-width:100%;height:auto;cursor:crosshair;image-rendering:auto";
    wrap.appendChild(cv); card.appendChild(wrap);

    const ctx = cv.getContext("2d", { willReadFrequently: true });
    let tool = "erase";
    const setTool = t => { tool = t; bErase.classList.toggle("is-on", t === "erase"); bFill.classList.toggle("is-on", t === "fill"); };
    bErase.onclick = () => setTool("erase"); bFill.onclick = () => setTool("fill");

    const undoStack = [];
    const pushUndo = () => { try { undoStack.push(ctx.getImageData(0, 0, cv.width, cv.height)); if (undoStack.length > 12) undoStack.shift(); } catch (e) {} };
    undo.onclick = () => { const s = undoStack.pop(); if (s) ctx.putImageData(s, 0, 0); };

    // chargement de l'image source (URL, dataURL ou Blob) -> canvas (max 480)
    const img = new Image(); img.crossOrigin = "anonymous";
    img.onload = () => {
      const MAX = 480, sc = Math.min(1, MAX / Math.max(img.width, img.height));
      cv.width = Math.max(1, Math.round(img.width * sc)); cv.height = Math.max(1, Math.round(img.height * sc));
      ctx.clearRect(0, 0, cv.width, cv.height); ctx.drawImage(img, 0, 0, cv.width, cv.height);
    };
    img.onerror = () => card.appendChild(el("p", "hint", "Cette image n'a pas pu être ouverte pour la retouche."));
    if (src instanceof Blob) img.src = URL.createObjectURL(src); else img.src = src;

    // remplissage par diffusion (flood fill) depuis le pixel touché
    function hexToRgb(h) { const m = h.replace("#", ""); return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)]; }
    function floodAt(px, py) {
      const w = cv.width, h = cv.height; if (px < 0 || py < 0 || px >= w || py >= h) return;
      const id = ctx.getImageData(0, 0, w, h), d = id.data;
      const i0 = (py * w + px) * 4;
      const tr = d[i0], tg = d[i0 + 1], tb = d[i0 + 2], ta = d[i0 + 3];
      const T = +tol.value, tol2 = T * T * 3;
      const [fr, fg, fb] = hexToRgb(color.value);
      const vis = new Uint8Array(w * h), st = [py * w + px]; vis[py * w + px] = 1;
      const match = i => { const dr = d[i] - tr, dg = d[i + 1] - tg, db = d[i + 2] - tb, da = d[i + 3] - ta; return dr * dr + dg * dg + db * db <= tol2 && da * da <= tol2; };
      while (st.length) {
        const p = st.pop(), i = p * 4;
        if (!match(i)) continue;
        if (tool === "erase") { d[i + 3] = 0; }
        else { d[i] = fr; d[i + 1] = fg; d[i + 2] = fb; d[i + 3] = 255; }
        const x = p % w, y = (p / w) | 0;
        [[1,0],[-1,0],[0,1],[0,-1]].forEach(([dx,dy]) => { const nx=x+dx, ny=y+dy; if(nx>=0&&ny>=0&&nx<w&&ny<h){ const np=ny*w+nx; if(!vis[np]){ vis[np]=1; st.push(np); } } });
      }
      ctx.putImageData(id, 0, 0);
    }
    function onTap(ev) {
      ev.preventDefault();
      const r = cv.getBoundingClientRect();
      const cx0 = (ev.touches ? ev.touches[0].clientX : ev.clientX) - r.left;
      const cy0 = (ev.touches ? ev.touches[0].clientY : ev.clientY) - r.top;
      pushUndo();
      floodAt(Math.round(cx0 * cv.width / r.width), Math.round(cy0 * cv.height / r.height));
    }
    cv.addEventListener("pointerdown", onTap);

    const acts = el("div", "modal-actions");
    const cancel = el("button", "btn-secondary", "Annuler"); cancel.onclick = close;
    const done = el("button", "btn-primary", "Terminer ✓");
    done.onclick = () => cv.toBlob(b => { close(); if (b && onDone) onDone(b); }, "image/png");
    acts.append(cancel, done); card.appendChild(acts);
  }

  /* ---------- utilitaires ---------- */
  function el(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
  // Bouton FERMER premium (pastille ronde ✕), accessible (aria-label). Cohérent dans tout l'outil.
  function closeX(onClick, label) {
    const b = el("button", "btn-close", "✕");
    b.type = "button"; b.setAttribute("aria-label", label || "Fermer"); b.title = label || "Fermer";
    if (onClick) b.addEventListener("click", onClick);
    return b;
  }
  function escapeXML(s) { return String(s).replace(/[<>&]/g, c => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c])); }
  function textSVG(label) {
    const t = (label || "?").slice(0, 8);
    const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect x='6' y='24' width='88' height='52' rx='10' fill='none' stroke='#5b6b7a' stroke-width='3'/><text x='50' y='50' font-size='22' font-family='sans-serif' font-weight='700' fill='#1a2733' text-anchor='middle' dominant-baseline='central'>${escapeXML(t)}</text></svg>`;
    return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
  }
  function ytId(url) {
    const m = String(url).match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/);
    return m ? m[1] : null;
  }
  function findAdded(id) {
    for (const b of Object.keys(state.overrides.added || {})) {
      const arr = state.overrides.added[b];
      const it = arr && arr.find(x => x.id === id);
      if (it) return { it, boardId: b };
    }
    return null;
  }

  /* ---------- Décor des cellules (média) ---------- */
  function decorateTile(elm, item, opts) {
    if (opts.cat || opts.back) return;
    const m = mediaOf(item);
    if (!m) return;
    const img = elm.querySelector("img");
    if (m.kind === "image") {
      if (m.key) urlFor(m.key).then(u => { if (u && img) { img.src = u; img.classList.add("tile-media"); } });
      else if (m.url && img) { img.src = m.url; img.classList.add("tile-media"); }
    } else if (m.kind === "audio") {
      const b = el("button", "play-badge", "🎤");
      b.setAttribute("aria-label", "Écouter la voix enregistrée");
      b.addEventListener("click", ev => { ev.stopPropagation(); playAudio(m.key); });
      elm.appendChild(b);
    } else if (m.kind === "bruitage") {
      const b = el("button", "play-badge", "🔊");
      b.setAttribute("aria-label", "Jouer le bruitage");
      b.addEventListener("click", ev => { ev.stopPropagation(); playBruitage(m.name); });
      elm.appendChild(b);
    } else if (["youtube", "video", "link"].includes(m.kind)) {
      const b = el("button", "play-badge", m.kind === "link" ? "🔗" : "▶");
      b.setAttribute("aria-label", "Ouvrir le média");
      b.addEventListener("click", ev => { ev.stopPropagation(); openViewer(item); });
      elm.appendChild(b);
    }
  }
  function playAudio(key) { if (!key) return; urlFor(key).then(u => { if (u) { const a = new Audio(u); a.play().catch(() => {}); } }); }

  /* ---------- Bruitages synthétisés (Web Audio, 0 fichier, 100% local) ---------- */
  let _actx = null;
  function actx() { if (!_actx) { const AC = window.AudioContext || window.webkitAudioContext; if (AC) _actx = new AC(); } return _actx; }
  function tone(a, t, f0, f1, dur, type, g0) {
    const o = a.createOscillator(), g = a.createGain();
    o.type = type || "sine"; o.frequency.setValueAtTime(f0, t);
    if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
    g.gain.setValueAtTime(g0 || 0.3, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g).connect(a.destination); o.start(t); o.stop(t + dur);
  }
  function noise(a, t, dur, g0) {
    const n = Math.floor(a.sampleRate * dur), buf = a.createBuffer(1, n, a.sampleRate), ch = buf.getChannelData(0);
    for (let i = 0; i < n; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / n);
    const s = a.createBufferSource(); s.buffer = buf; const g = a.createGain(); g.gain.value = g0 || 0.25;
    s.connect(g).connect(a.destination); s.start(t); s.stop(t + dur);
  }
  const BRUITAGES = {
    ding:    { label: "Ding 🔔",         fn: (a, t) => tone(a, t, 880, 880, 0.5, "sine", 0.35) },
    cloche:  { label: "Cloche 🛎️",       fn: (a, t) => { tone(a, t, 784, 784, 0.9, "triangle", 0.35); tone(a, t, 1176, 1176, 0.9, "sine", 0.12); } },
    pop:     { label: "Pop 🫧",          fn: (a, t) => tone(a, t, 300, 700, 0.12, "sine", 0.4) },
    boing:   { label: "Boing 🤸",         fn: (a, t) => tone(a, t, 500, 120, 0.35, "sawtooth", 0.3) },
    tada:    { label: "Tada 🎉",          fn: (a, t) => { [523, 659, 784, 1047].forEach((f, i) => tone(a, t + i * 0.11, f, f, 0.3, "triangle", 0.3)); } },
    coucou:  { label: "Coucou 👋",        fn: (a, t) => { tone(a, t, 784, 784, 0.2, "sine", 0.3); tone(a, t + 0.22, 523, 523, 0.3, "sine", 0.3); } },
    klaxon:  { label: "Klaxon 📣",        fn: (a, t) => { tone(a, t, 440, 440, 0.25, "square", 0.22); tone(a, t + 0.26, 349, 349, 0.3, "square", 0.22); } },
    whoosh:  { label: "Whoosh 💨",        fn: (a, t) => noise(a, t, 0.4, 0.3) },
    applaudir: { label: "Applaudir 👏",  fn: (a, t) => { for (let i = 0; i < 8; i++) noise(a, t + i * 0.09, 0.05, 0.35); } },
    erreur:  { label: "Non 🚫",           fn: (a, t) => tone(a, t, 200, 140, 0.35, "square", 0.28) },
  };
  function playBruitage(name) {
    const a = actx(); if (!a) return; if (a.state === "suspended") a.resume();
    const b = BRUITAGES[name]; if (b) b.fn(a, a.currentTime);
  }

  /* ---------- Modale générique ---------- */
  function modal(title, hostId) {
    const id = hostId || "ct-modal";
    let host = document.getElementById(id);
    if (!host) { host = el("div", "modal"); host.id = id; document.body.appendChild(host); }
    // 2e calque (ex. bibliothèque ouverte DEPUIS l'éditeur) : passe au-dessus sans détruire le 1er
    if (id !== "ct-modal") host.style.zIndex = "70";
    host.hidden = false; host.innerHTML = "";
    const card = el("div", "modal-card");
    card.setAttribute("role", "dialog");
    card.setAttribute("aria-modal", "true");
    card.setAttribute("aria-label", title);
    card.tabIndex = -1;
    const h = el("h3", null, title); card.appendChild(h);
    host.appendChild(card);
    const close = () => { host.hidden = true; document.removeEventListener("keydown", onKey); };
    function onKey(e) { if (e.key === "Escape") close(); }
    document.addEventListener("keydown", onKey);
    host.onclick = e => { if (e.target === host) close(); };
    setTimeout(() => card.focus(), 30);
    return { host, card, close };
  }

  /* ---------- Dialogues in-app (remplacent prompt/confirm/alert natifs) ----------
     prompt()/alert()/confirm() natifs sont BLOQUÉS en PWA installée / webview / sandbox
     (prompt lève « not supported » ; confirm peut renvoyer false en silence). Ces trois
     helpers rendent une Promise et fonctionnent partout, hors-ligne, sans réseau. */
  function ctDialog(opts) {
    return new Promise(resolve => {
      const m = modal(opts.title || " ", "ct-dialog");
      m.host.style.zIndex = "90";
      let settled = false;
      const finish = val => { if (settled) return; settled = true; document.removeEventListener("keydown", onKey, true); m.close(); resolve(val); };
      if (opts.message) { const p = el("p", "modal-msg", opts.message); m.card.appendChild(p); }
      let input = null;
      if (opts.input) { input = el("input", "modal-input"); input.type = "text"; input.value = opts.defaultValue || ""; input.setAttribute("aria-label", opts.title || "Saisie"); m.card.appendChild(input); }
      const row = el("div", "modal-actions");
      const cancelVal = opts.input ? null : false;
      if (opts.cancelLabel !== null) { const c = el("button", "btn-secondary", opts.cancelLabel || "Annuler"); c.type = "button"; c.onclick = () => finish(cancelVal); row.appendChild(c); }
      const ok = el("button", "btn-primary" + (opts.danger ? " danger" : ""), opts.okLabel || "OK"); ok.type = "button"; ok.onclick = () => finish(opts.input ? (input ? input.value : "") : true); row.appendChild(ok);
      m.card.appendChild(row);
      m.host.onclick = e => { if (e.target === m.host) finish(cancelVal); };
      function onKey(e) {
        if (e.key === "Escape") { e.stopPropagation(); finish(cancelVal); }
        else if (e.key === "Enter") { if (document.activeElement && document.activeElement.tagName === "TEXTAREA") return; e.preventDefault(); ok.click(); }
      }
      document.addEventListener("keydown", onKey, true);
      setTimeout(() => { (input || ok).focus(); if (input) input.select(); }, 40);
    });
  }
  const ctPrompt = (message, def, title) => ctDialog({ title: title || "", message, input: true, defaultValue: def || "", okLabel: "Valider", cancelLabel: "Annuler" });
  const ctConfirm = (message, title, opt) => ctDialog({ title: title || "", message, input: false, okLabel: (opt && opt.ok) || "Oui", cancelLabel: (opt && opt.cancel) || "Non", danger: opt && opt.danger });
  const ctAlert = (message, title) => ctDialog({ title: title || "", message, input: false, okLabel: "OK", cancelLabel: null });
  window.ctPrompt = ctPrompt; window.ctConfirm = ctConfirm; window.ctAlert = ctAlert;

  /* ---------- Lecteur média (RGPD : chargé au clic) ---------- */
  function openViewer(item) {
    const m = mediaOf(item); if (!m) return;
    const { card, close } = modal(labelFor(item));
    // RGPD / cohérence « 100 % local » : on ne rend EN-APP que les médias STOCKÉS sur l'appareil
    // (image/vidéo locales via `key` -> blob). Tout média DISTANT (YouTube, lien vidéo, lien web,
    // image distante) s'OUVRE DANS LE NAVIGATEUR : rien d'externe n'est embarqué dans l'appli
    // (la CSP `default-src 'self'` bloquerait de toute façon un embed distant -> écran blanc).
    if (m.kind === "image" && m.key) {
      const img = document.createElement("img"); img.style.maxWidth = "100%"; img.alt = labelFor(item);
      urlFor(m.key).then(u => { if (u) img.src = u; });
      card.appendChild(img);
    } else if (m.kind === "video" && m.key) {
      const v = document.createElement("video"); v.className = "viewer-frame"; v.controls = true;
      urlFor(m.key).then(u => { if (u) v.src = u; });
      card.appendChild(v);
    } else if (m.url) {
      const note = m.kind === "youtube" ? "Vidéo YouTube — s'ouvre dans le navigateur"
                 : m.kind === "video" ? "Vidéo en ligne — s'ouvre dans le navigateur"
                 : m.url;
      card.appendChild(el("p", null, note));
      const open = el("button", "btn-primary", "Ouvrir dans le navigateur");
      open.addEventListener("click", () => window.open(m.url, "_blank", "noopener"));
      card.appendChild(open);
    } else {
      card.appendChild(el("p", null, "Média non disponible."));
    }
    const actions = el("div", "modal-actions");
    const speakBtn = el("button", "btn-secondary", "🔊 Dire le mot");
    speakBtn.addEventListener("click", () => speak(labelFor(item)));
    actions.append(speakBtn, closeX(close)); card.appendChild(actions);
  }

  /* ---------- Éditeur de cellule ---------- */
  const COLORS = [
    ["pronom", "Pronom"], ["verbe", "Verbe"], ["descriptif", "Descriptif"],
    ["nom", "Nom"], ["social", "Social"], ["question", "Question"],
    ["petit", "Petit mot"], ["negation", "Négation"], ["advocacy", "Ma voix"],
  ];
  /* ---------- Bibliothèque de pictos (parcourir) + pictos animés (mouvement) ---------- */
  // Jeu de pictos ANIMÉS ORIGINAUX (SVG, style Mulberry) — libre de droit, vendable, 100% local.
  const ANIM_DIR = "assets/anim/";
  const ANIM_LIB = [
    ["mvt-marcher", "marcher"], ["mvt-courir", "courir"], ["mvt-sauter", "sauter"],
    ["mvt-danser", "danser"], ["mvt-saluer", "saluer"], ["mvt-applaudir", "applaudir"],
    ["mvt-coucou", "coucou · au revoir"], ["mvt-bravo", "bravo"], ["mvt-content", "content"],
    ["mvt-oui", "oui"], ["mvt-non", "non"], ["mvt-coeur", "j'aime · cœur"], ["mvt-bisou", "bisou"],
    ["mvt-dormir", "dormir"], ["mvt-pleurer", "pleurer"], ["mvt-musique", "musique"],
    ["mvt-attention", "attention · écoute"],
  ];
  // Licences VALIDÉES (affichées dans la bibliothèque) — deux banques bien distinctes.
  const LIB_LICENCE = {
    ct: "🟣 Banque Com'Together — pictogrammes originaux, créés pour l'outil. Libres de droit : réutilisables et vendables, y compris à des fins commerciales, avec ou sans mention. © Com'Together.",
    mb: "🟢 Banque Mulberry Symbols — licence CC BY-SA 2.0 : libres d'usage à condition de créditer « Mulberry Symbols » et de partager les dérivés sous la même licence.",
  };
  let _lib = null;
  async function loadLib() { if (_lib) return _lib; try { _lib = await (await fetch("assets/custom/library.json?v=" + (window.BUILD || "1"))).json(); } catch (e) { _lib = { comtogether: [] }; } return _lib; }
  function downloadPicto(src, label) {
    const a = document.createElement("a");
    const ext = src.split(".").pop().split("?")[0];
    a.href = src; a.download = (label || "picto").replace(/[^\w\-À-ÿ ]+/g, "").trim().replace(/\s+/g, "-") + "." + ext;
    document.body.appendChild(a); a.click(); a.remove();
  }
  function openLibrary(onPick) {
    const { card, close } = modal("📚 Bibliothèque de pictos", "ct-lib-modal");
    card.appendChild(el("p", "hint", "Tous les pictos sont libres de droit. Touche ⬇ pour télécharger un picto (à réutiliser dans Canva, un document…)" + (onPick ? ", ou touche l'image pour la mettre dans ta case." : ".")));
    const banks = el("div", "lib-banks");
    const search = Object.assign(document.createElement("input"), { type: "text", placeholder: "chercher un mot…" });
    search.setAttribute("aria-label", "Chercher un picto"); search.style.cssText = "width:100%;margin:6px 0;min-height:42px;font-size:1rem";
    const bar = el("div", "lib-cats");
    const grid = el("div", "lib-grid");
    const licence = el("p", "lib-licence");
    let bank = "ct";
    const tile = (src, label, color) => {
      const b = el("button", "tile"); b.dataset.color = color || "nom"; b.style.minHeight = "84px";
      const im = document.createElement("img"); im.src = src; im.alt = ""; im.loading = "lazy"; b.appendChild(im);
      b.appendChild(el("span", "tile-label" + (window.labelClass ? labelClass(label || "") : ""), label || ""));
      const dl = el("button", "lib-dl", "⬇"); dl.title = "Télécharger « " + (label || "picto") + " »"; dl.setAttribute("aria-label", "Télécharger " + (label || "picto"));
      dl.addEventListener("click", (e) => { e.stopPropagation(); downloadPicto(src, label); });
      b.appendChild(dl);
      b.addEventListener("click", () => { if (onPick) { onPick(src, label || ""); close(); } else downloadPicto(src, label); });
      return b;
    };
    const draw = (items) => {
      grid.innerHTML = "";
      (items || []).forEach(it => { const sp = it.sym_path || it.file; if (sp) grid.appendChild(tile(sp, it.label, it.color)); });
      if (!grid.children.length) grid.appendChild(el("p", "hint", "Rien ici — essaie un autre thème ou la recherche."));
    };
    const setActiveCat = (btn) => { bar.querySelectorAll(".kb-layout-btn").forEach(x => x.classList.remove("is-active")); if (btn) btn.classList.add("is-active"); };
    const mkCat = (label, fn) => { const c = el("button", "kb-layout-btn"); c.textContent = label; c.addEventListener("click", () => { setActiveCat(c); search.value = ""; fn(); }); bar.appendChild(c); return c; };
    let dict = null;
    async function renderBank() {
      bar.innerHTML = ""; licence.textContent = LIB_LICENCE[bank];
      banks.querySelectorAll(".lib-bank").forEach(x => x.classList.toggle("is-on", x.dataset.bank === bank));
      if (bank === "ct") {
        const lib = await loadLib();
        const items = (lib.comtogether || []);
        const mots = items.filter(i => i.kind === "mot"), anim = items.filter(i => i.kind === "anim"), vari = items.filter(i => i.kind === "variante");
        const bMots = mkCat("Mots & pictos", () => draw(mots));
        mkCat("🎞️ Mouvement", () => draw(anim));
        mkCat("Variantes (pronoms)", () => draw(vari));
        setActiveCat(bMots); draw(mots);
      } else {
        const themes = (state.vocab.themes || []).filter(t => (t.items || []).some(i => i.sym_path));
        let first = null;
        themes.forEach((t, i) => { const c = mkCat(t.label, () => draw(t.items)); if (i === 0) first = c; });
        if (first) { setActiveCat(first); draw(themes[0].items); }
      }
    }
    const mkBank = (id, label) => { const b = el("button", "lib-bank" + (id === bank ? " is-on" : "")); b.dataset.bank = id; b.textContent = label; b.addEventListener("click", () => { bank = id; search.value = ""; renderBank(); }); banks.appendChild(b); };
    mkBank("ct", "🟣 Com'Together"); mkBank("mb", "🟢 Mulberry");
    search.addEventListener("input", async () => {
      const q = normalize(search.value.trim()); if (q.length < 2) return; setActiveCat(null);
      if (bank === "ct") { const lib = await loadLib(); draw((lib.comtogether || []).filter(i => normalize(i.label).includes(q)).slice(0, 200)); }
      else { if (!dict) dict = await loadDict(); draw(dict.filter(d => d.sym_path && normalize(d.label).includes(q)).sort((a, b) => a.label.length - b.label.length).slice(0, 150)); }
    });
    card.append(banks, search, bar, grid, licence);
    renderBank();
  }

  // Variantes de pictos par mot (« choisis celui qui te ressemble ») — manifeste généré au build.
  let _variants = null;
  async function loadVariants() {
    if (_variants) return _variants;
    try { _variants = await (await fetch("assets/custom/variants.json?v=" + (window.BUILD || "1"))).json(); }
    catch (e) { _variants = {}; }
    if (_variants.je && !_variants.moi) _variants.moi = _variants.je;   // « moi » partage les dessins de « je »
    return _variants;
  }
  // Dialogue « Corriger la prononciation » d'un mot : écrit dans le lexique GLOBAL
  // (state.overrides.pron) -> s'applique à TOUTES les occurrences. Voir applyPron() (app.js).
  function openPronDialog(displayText) {
    const d = String(displayText || "").trim(); if (!d) return;
    const key = (typeof norm === "function" ? norm(d) : d.toLowerCase());
    if (!state.overrides.pron) state.overrides.pron = {};
    const cur = state.overrides.pron[key] || { display: d, speech: "", phonemes: "" };
    const { card, close } = modal("Ajouter au lexique global de prononciation");
    card.appendChild(el("p", "pron-warn", "⚠️ Cette correction s'appliquera PARTOUT où « " + d + " » apparaît (toutes les cellules), pas seulement ici."));
    card.appendChild(el("p", "hint", "La voix du système suit le « texte prononcé » (ré-écriture). Les phonèmes ne servent qu'avec la voix locale Piper."));
    const mkField = (label, val, ph, disabled) => {
      const f = el("div", "field"); f.append(Object.assign(el("label"), { textContent: label }));
      const i = Object.assign(document.createElement("input"), { type: "text", value: val || "", placeholder: ph || "" });
      if (disabled) i.disabled = true;
      f.appendChild(i); card.appendChild(f); return i;
    };
    mkField("Texte affiché", d, "", true);
    const iSpeech = mkField("Texte prononcé (ré-écriture)", cur.speech, "ex. Prissila");
    const iPhon = mkField("Phonèmes (Piper, facultatif)", cur.phonemes, "ex. pRisila");
    const row = el("div"); row.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;margin-top:12px";
    const hear = el("button", "btn-secondary", "🔊 Écouter");
    hear.addEventListener("click", () => { const t = iSpeech.value.trim() || d; if (typeof speak === "function") speak(t); });
    const del = el("button", "btn-secondary", "Retirer");
    del.addEventListener("click", () => { delete state.overrides.pron[key]; if (typeof saveOverrides === "function") saveOverrides(); if (window.renderPronManager) window.renderPronManager(); close(); });
    const save = el("button", "btn-primary", "Enregistrer");
    save.addEventListener("click", () => {
      const sp = iSpeech.value.trim(), pho = iPhon.value.trim();
      if (!sp && !pho) delete state.overrides.pron[key];
      else state.overrides.pron[key] = { display: d, speech: sp, phonemes: pho };
      if (typeof saveOverrides === "function") saveOverrides();
      if (window.renderPronManager) window.renderPronManager();
      close();
    });
    row.append(hear, del, save); card.appendChild(row);
  }
  function openEditor(item, ctx) {
    const isNew = ctx.isNew;
    const added = item && findAdded(item.id);
    const _ovv = (typeof ov === "function" && item) ? ov(item.id) : {};
    const cur = isNew ? { label: "", say: "", color: ctx.boardId === "advocacy" ? "advocacy" : "nom", media: null, lang: "", skin: "", hair: "", tr: "" }
      : { label: labelFor(item), say: sayFor(item) || "", color: colorFor(item) || "nom", media: mediaOf(item),
          lang: (typeof langFor === "function" ? langFor(item) : item.lang) || "",
          skin: (_ovv.skin ?? item.skin) || "", hair: (_ovv.hair ?? item.hair) || "",
          tr: (_ovv.tr ?? item.tr) || "" };
    const { card, close } = modal(isNew ? "Nouvelle case" : "Modifier la case");

    // Texte
    const fLabel = el("div", "field");
    fLabel.append(Object.assign(el("label"), { textContent: "Texte affiché (et lu)" }));
    const inLabel = Object.assign(document.createElement("input"), { type: "text", value: cur.label });
    inLabel.setAttribute("aria-label", "Texte affiché et lu");
    fLabel.appendChild(inLabel);
    fLabel.appendChild(Object.assign(el("p", "hint"), { textContent: "Tu peux garder un mot dans TA langue (émotion, terme de famille…) : écris-le tel quel — aucune traduction n'est imposée. Ajoute son sens et sa prononciation ci-dessous si tu veux." }));
    card.appendChild(fLabel);

    // Texte lu différent — LIMITÉ À CETTE CELLULE
    const fSay = el("div", "field");
    fSay.append(Object.assign(el("label"), { textContent: "Texte lu — seulement cette cellule (si différent, optionnel)" }));
    const inSay = Object.assign(document.createElement("input"), { type: "text", value: cur.say, placeholder: "laisser vide = comme ci-dessus" });
    inSay.setAttribute("aria-label", "Texte lu à voix haute, pour cette cellule uniquement (optionnel)");
    fSay.appendChild(inSay);
    fSay.appendChild(Object.assign(el("p", "hint"), { textContent: "N'affecte QUE cette case. Pour corriger un mot partout, vois « lexique global » plus bas." }));
    card.appendChild(fSay);

    // Accent / langue de la voix (par cellule) — ex. « six seven » en anglais.
    const fLang = el("div", "field");
    fLang.append(Object.assign(el("label"), { textContent: "Accent / langue de la voix (optionnel)" }));
    const selLang = document.createElement("select");
    selLang.setAttribute("aria-label", "Accent ou langue de la voix");
    const LANG_NAMES = { "fr": "Français", "en": "Anglais", "es": "Espagnol", "de": "Allemand",
      "it": "Italien", "pt": "Portugais", "ar": "Arabe", "nl": "Néerlandais", "ru": "Russe", "wo": "Wolof" };
    const opts = [["", "Par défaut (voix habituelle)"]];
    const langs = (typeof availableLangs === "function") ? availableLangs() : ["fr-FR", "en-US"];
    langs.forEach(l => { const base = l.split("-")[0].toLowerCase(); opts.push([l, (LANG_NAMES[base] || base) + " (" + l + ")"]); });
    // garantir que la valeur actuelle est présente même si la voix n'est pas listée
    if (cur.lang && !langs.includes(cur.lang)) opts.push([cur.lang, cur.lang]);
    opts.forEach(([v, t]) => selLang.appendChild(Object.assign(document.createElement("option"), { value: v, textContent: t, selected: v === cur.lang })));
    const bTest = el("button", "btn-secondary", "🔊 Tester");
    bTest.style.marginTop = "6px";
    bTest.addEventListener("click", () => { const t = (inSay.value.trim() || inLabel.value.trim()); if (t && typeof speak === "function") speak(t, selLang.value || undefined); });
    fLang.append(selLang, bTest); card.appendChild(fLang);

    // Corriger la prononciation (lexique GLOBAL : s'applique à toutes les occurrences du mot).
    const fPron = el("div", "field");
    fPron.append(Object.assign(el("label"), { textContent: "Lexique global de prononciation (prénom, sigle, marque…)" }));
    const bPron = el("button", "btn-secondary", "🌍 Ajouter au lexique global de prononciation");
    bPron.addEventListener("click", () => { const d = inLabel.value.trim(); if (d) openPronDialog(d); });
    fPron.appendChild(bPron);
    fPron.appendChild(Object.assign(el("p", "hint"), { textContent: "⚠️ S'applique PARTOUT où ce mot apparaît, pas seulement ici." }));
    card.appendChild(fPron);

    // Teinte de peau + cheveux PAR CELLULE (recolore le picto-personnage de cette case)
    const SKIN_OPTS = { "": "Défaut (réglage global)", claire: "Claire", moyenne: "Moyenne", mate: "Mate", caramel: "Caramel", foncee: "Foncée", ebene: "Ébène" };
    const HAIR_OPTS = { "": "Défaut (réglage global)", noir: "Noirs", chatain: "Châtains", brun: "Bruns", blond: "Blonds", roux: "Roux", gris: "Gris", blanc: "Blancs" };
    const mkSel = (label, opts, val) => {
      const f = el("div", "field"); f.append(Object.assign(el("label"), { textContent: label }));
      const sel = document.createElement("select");
      Object.keys(opts).forEach(k => sel.appendChild(Object.assign(document.createElement("option"), { value: k, textContent: opts[k], selected: k === val })));
      f.appendChild(sel); card.appendChild(f); return sel;
    };
    const selSkin = mkSel("Teinte de peau (cette case)", SKIN_OPTS, cur.skin);
    const selHair = mkSel("Couleur des cheveux (cette case)", HAIR_OPTS, cur.hair);

    // Traduction (2e langue) PAR CELLULE — s'affiche sous le mot et se dit dans la 2e langue.
    const fTr = el("div", "field");
    const lg2 = (typeof state !== "undefined" && state.settings.lang2) ? state.settings.lang2 : "";
    fTr.append(Object.assign(el("label"), { textContent: "Traduction ou explication (2ᵉ ligne sous le mot, optionnel" + (lg2 ? " · 2e langue : " + lg2 : "") + ")" }));
    const inTr = Object.assign(document.createElement("input"), { type: "text", value: cur.tr, placeholder: "ce que le mot veut dire, ou sa traduction" });
    fTr.appendChild(inTr); card.appendChild(fTr);

    // Couleur
    const fColor = el("div", "field");
    fColor.append(Object.assign(el("label"), { textContent: "Couleur (famille de mots)" }));
    const sw = el("div", "color-swatches"); let chosen = cur.color;
    COLORS.forEach(([id, name]) => {
      const b = el("button", "swatch" + (id === chosen ? " sel" : "")); b.title = name;
      b.style.background = `var(--c-${id})`;
      b.addEventListener("click", () => { chosen = id; sw.querySelectorAll(".swatch").forEach(x => x.classList.remove("sel")); b.classList.add("sel"); });
      sw.appendChild(b);
    });
    fColor.appendChild(sw); card.appendChild(fColor);

    // --- Choisir un PICTO (recherche + bibliothèque à parcourir, à l'ajout comme à la modif) ---
    let chosenPicto = null;
    const curSym = isNew ? null : ((typeof pictoFor === "function") ? pictoFor(item) : item.sym_path);
    const fPicto = el("div", "field");
    fPicto.append(Object.assign(el("label"), { textContent: "Picto (cherchez un mot, ou parcourez la bibliothèque)" }));
    // Aperçu du picto actuel / choisi
    const pPrev = document.createElement("img"); pPrev.alt = ""; pPrev.className = "picto-prev";
    pPrev.style.cssText = "width:56px;height:56px;object-fit:contain;vertical-align:middle;margin-right:8px" + (curSym ? "" : ";display:none");
    if (curSym) pPrev.src = curSym;
    const bLib = el("button", "btn-secondary", "📚 Parcourir la bibliothèque (thèmes + 🎞️ mouvement)");
    const pInput = Object.assign(document.createElement("input"), { type: "text", placeholder: "ex. chien, manger, maison…" });
    pInput.setAttribute("aria-label", "Chercher un picto");
    pInput.style.marginTop = "8px";
    const pRes = el("div", "search-results"); pRes.style.cssText = "max-height:190px;overflow:auto;margin-top:6px";
    const pHint = el("p", "hint", "Laissez vide = carte-mot (le mot écrit en grand sur la couleur).");
    const setPicto = (src) => {
      chosenPicto = src; pPrev.src = src; pPrev.style.display = "";
      pHint.textContent = "Picto choisi ✓ — touchez « Enregistrer ».";
    };
    bLib.addEventListener("click", () => openLibrary(setPicto));
    // Gomme magique + remplissage sur le picto lui-même
    const bClean = el("button", "btn-secondary", "🧽 Nettoyer / colorier");
    bClean.addEventListener("click", () => {
      const base = chosenPicto || curSym;
      if (!base) { pHint.textContent = "Choisis d'abord un picto à nettoyer."; return; }
      openPixelEditor(base, (blob) => {
        const fr = new FileReader();
        fr.onload = () => setPicto(fr.result);   // dataURL -> devient le picto de la case
        fr.readAsDataURL(blob);
      });
    });
    const prevRow = el("div"); prevRow.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px";
    prevRow.append(pPrev, bLib, bClean);
    fPicto.append(prevRow, pInput, pRes, pHint); card.appendChild(fPicto);

    // --- Sélecteur de VARIANTES du mot (« choisis le picto qui te ressemble ») ---
    const fVar = el("div", "field"); fVar.style.display = "none"; card.appendChild(fVar);
    loadVariants().then(v => {
      const key = normalize(cur.label || "");
      const list = v[key];
      if (!list || list.length < 2) return;
      fVar.style.display = "";
      fVar.append(Object.assign(el("label"), { textContent: "Choisis le picto qui te ressemble" }));
      const row = el("div", "var-choose");
      list.forEach(src => {
        const b = el("button", "var-opt-pic"); b.type = "button"; b.setAttribute("aria-label", "Choisir ce dessin");
        const im = document.createElement("img"); im.src = src; im.alt = ""; b.appendChild(im);
        if ((chosenPicto || curSym) === src) b.classList.add("sel");
        b.addEventListener("click", () => { setPicto(src); row.querySelectorAll(".var-opt-pic").forEach(x => x.classList.remove("sel")); b.classList.add("sel"); });
        row.appendChild(b);
      });
      fVar.appendChild(row);
    });
    let _pdict = null;
    async function pRun() {
      const q = normalize(pInput.value.trim()); pRes.innerHTML = "";
      if (q.length < 2) return;
      if (!_pdict) _pdict = await loadDict();
      const hits = _pdict.filter(d => d.sym_path && normalize(d.label).includes(q))
        .sort((a, b) => normalize(a.label).indexOf(q) - normalize(b.label).indexOf(q) || a.label.length - b.label.length)
        .slice(0, 24);
      if (!hits.length) { pRes.appendChild(el("p", "hint", "Aucun picto pour ce mot (il restera en carte-mot).")); return; }
      hits.forEach(d => {
        const bb = el("button", "tile"); bb.dataset.color = "nom"; bb.style.minHeight = "72px";
        const im = document.createElement("img"); im.src = d.sym_path; im.alt = ""; bb.appendChild(im);
        bb.appendChild(el("span", "tile-label" + (window.labelClass ? labelClass(d.label) : ""), d.label));
        bb.addEventListener("click", () => {
          setPicto(d.sym_path);
          pRes.querySelectorAll(".tile").forEach(x => x.classList.remove("sel")); bb.classList.add("sel");
        });
        pRes.appendChild(bb);
      });
    }
    pInput.addEventListener("input", pRun);

    // Média
    const fMedia = el("div", "field");
    fMedia.append(Object.assign(el("label"), { textContent: "Média (en plus du picto — la personne peut l'afficher)" }));
    const sel = document.createElement("select");
    [["none", "Aucun (picto seul)"], ["image", "Photo ou GIF (fichier de l'appareil)"],
     ["audio", "🎤 Enregistrer un son (voix)"], ["bruitage", "🔊 Bruitage (son synthétisé)"],
     ["youtube", "Lien YouTube"], ["video", "Lien vidéo (mp4…)"], ["link", "Lien web"]]
      .forEach(([v, t]) => sel.appendChild(Object.assign(document.createElement("option"), { value: v, textContent: t })));
    sel.value = cur.media ? cur.media.kind : "none";
    fMedia.appendChild(sel);
    const fileIn = Object.assign(document.createElement("input"), { type: "file", accept: "image/*" });
    fileIn.setAttribute("aria-label", "Choisir une image ou un GIF");
    const urlIn = Object.assign(document.createElement("input"), { type: "url", placeholder: "https://…" });
    urlIn.setAttribute("aria-label", "Adresse du média (lien)");
    if (cur.media && cur.media.url) urlIn.value = cur.media.url;
    fileIn.style.marginTop = urlIn.style.marginTop = "8px";
    fMedia.append(fileIn, urlIn);
    // --- Baguette magique : enlever le fond d'une photo importée ---
    let magicBlob = null;
    const magicBox = el("div"); magicBox.style.cssText = "margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap";
    const bMagic = el("button", "btn-secondary", "🪄 Enlever le fond");
    const magicPrev = document.createElement("img");
    magicPrev.style.cssText = "width:60px;height:60px;object-fit:contain;border:2px solid var(--line);border-radius:8px;background:repeating-conic-gradient(#eee 0% 25%,#fff 0% 50%) 50%/16px 16px;display:none";
    const magicHint = el("span", "hint", "Détoure le sujet ; la couleur de la case remplira le fond.");
    bMagic.addEventListener("click", async () => {
      let src = magicBlob || (fileIn.files && fileIn.files[0]);
      if (!src && cur.media && cur.media.key) { try { src = await getMedia(cur.media.key); } catch (e) {} }
      if (!src) { magicHint.textContent = "Choisissez d'abord une photo ci-dessus."; return; }
      bMagic.disabled = true; const t0 = bMagic.textContent; bMagic.textContent = "🪄 …";
      try { magicBlob = await magicRemoveBg(src); magicPrev.src = URL.createObjectURL(magicBlob); magicPrev.style.display = ""; magicHint.textContent = "Fond enlevé ✓ — touchez « Enregistrer »."; }
      catch (e) { magicHint.textContent = "Cette image n'a pas pu être détourée. Essaie une autre photo (JPG ou PNG), de préférence sur fond uni."; }
      bMagic.disabled = false; bMagic.textContent = t0;
    });
    // Retouche manuelle (gomme magique + remplissage) sur la photo
    const bTouch = el("button", "btn-secondary", "🧽 Retouche manuelle");
    bTouch.addEventListener("click", async () => {
      let src = magicBlob || (fileIn.files && fileIn.files[0]);
      if (!src && cur.media && cur.media.key) { try { src = await getMedia(cur.media.key); } catch (e) {} }
      if (!src) { magicHint.textContent = "Choisissez d'abord une photo ci-dessus."; return; }
      openPixelEditor(src, (blob) => {
        magicBlob = blob; magicPrev.src = URL.createObjectURL(blob); magicPrev.style.display = "";
        magicHint.textContent = "Retouche faite ✓ — touchez « Enregistrer ».";
      });
    });
    magicBox.append(bMagic, bTouch, magicPrev, magicHint);
    fMedia.append(magicBox);
    // --- Enregistrement de la voix (micro, 100% local) ---
    let recordedBlob = null, recorder = null, chunks = [];
    const audioBox = el("div"); audioBox.style.cssText = "margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap";
    const recBtn = el("button", "btn-secondary", "🎤 Enregistrer");
    const recStatus = el("span", "hint", "");
    const preview = document.createElement("audio"); preview.controls = true; preview.style.cssText = "margin-top:6px;max-width:100%;display:block";
    if (cur.media && cur.media.kind === "audio" && cur.media.key) urlFor(cur.media.key).then(u => { if (u) { preview.src = u; recStatus.textContent = "Voix enregistrée ✓"; } });
    recBtn.addEventListener("click", async () => {
      if (recorder && recorder.state === "recording") { recorder.stop(); return; }
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        recorder = new MediaRecorder(stream); chunks = [];
        recorder.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
        recorder.onstop = () => {
          recordedBlob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
          preview.src = URL.createObjectURL(recordedBlob);
          recStatus.textContent = "Voix enregistrée ✓ (écoutez ci-dessous)";
          recBtn.textContent = "🎤 Ré-enregistrer"; recBtn.classList.remove("is-on");
          stream.getTracks().forEach(t => t.stop());
        };
        recorder.start(); recBtn.textContent = "⏹ Arrêter"; recBtn.classList.add("is-on"); recStatus.textContent = "Enregistrement…";
      } catch (e) { recStatus.textContent = "Micro non autorisé — touche le 🔒 dans la barre d'adresse, autorise le micro, puis réessaie."; }
    });
    audioBox.append(recBtn, recStatus);
    fMedia.append(audioBox, preview);
    // --- Bruitage synthétisé (Web Audio) ---
    const bruBox = el("div"); bruBox.style.cssText = "margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap";
    const bruSel = document.createElement("select"); bruSel.setAttribute("aria-label", "Choisir un bruitage");
    Object.keys(BRUITAGES).forEach(k => bruSel.appendChild(Object.assign(document.createElement("option"), { value: k, textContent: BRUITAGES[k].label })));
    if (cur.media && cur.media.kind === "bruitage" && cur.media.name) bruSel.value = cur.media.name;
    const bruTest = el("button", "btn-secondary", "▶ Écouter");
    bruTest.addEventListener("click", () => playBruitage(bruSel.value));
    bruBox.append(bruSel, bruTest); fMedia.append(bruBox);
    card.appendChild(fMedia);
    const rgpd = el("p", "hint", "Astuce RGPD : fichier ou voix enregistrés restent 100% sur l'appareil. Un lien externe (YouTube, image) contacte un service tiers quand on l'ouvre.");
    card.appendChild(rgpd);
    function syncMedia() {
      const k = sel.value;
      fileIn.hidden = k !== "image";
      magicBox.hidden = k !== "image";
      urlIn.hidden = !["youtube", "video", "link"].includes(k);
      audioBox.hidden = preview.hidden = k !== "audio";
      bruBox.hidden = k !== "bruitage";
    }
    sel.addEventListener("change", syncMedia); syncMedia();

    // Actions
    const actions = el("div", "modal-actions");
    if (!isNew) {
      const hide = el("button", "btn-secondary danger", "Masquer cette case");
      hide.addEventListener("click", () => {
        if (added) { const a = state.overrides.added[added.boardId]; a.splice(a.indexOf(added.it), 1); }
        else state.overrides.hidden.push(item.id);
        saveOverrides(); close(); refresh();
      });
      actions.appendChild(hide);
    }
    const cancel = el("button", "btn-secondary", "Annuler"); cancel.addEventListener("click", close);
    const save = el("button", "btn-primary", "Enregistrer");
    save.addEventListener("click", async () => {
      const label = inLabel.value.trim(); if (!label) { inLabel.focus(); return; }
      const say = inSay.value.trim();
      let media = null;
      const k = sel.value;
      if (k === "image") {
        const imgFile = magicBlob || fileIn.files[0];   // baguette magique prioritaire si le fond a été enlevé
        if (imgFile) { const key = "m_" + (item ? item.id : "u" + Date.now()); await putMedia(key, imgFile); urlCache.delete(key); media = { kind: "image", key }; }
        else if (urlIn.value.trim()) media = { kind: "image", url: urlIn.value.trim() };
        else if (cur.media) media = cur.media;
      } else if (k === "audio") {
        if (recordedBlob) { const key = "a_" + (item ? item.id : "u" + Date.now()); await putMedia(key, recordedBlob); urlCache.delete(key); media = { kind: "audio", key }; }
        else if (cur.media && cur.media.kind === "audio") media = cur.media;
      } else if (k === "bruitage") {
        media = { kind: "bruitage", name: bruSel.value };
      } else if (["youtube", "video", "link"].includes(k)) {
        if (urlIn.value.trim()) media = { kind: k, url: urlIn.value.trim() };
      }
      const lang = selLang.value || undefined;
      const skin = selSkin.value || undefined, hair = selHair.value || undefined;
      const tr = inTr.value.trim() || undefined;
      const patch = { label, say: say || undefined, color: chosen, media, lang, skin, hair, tr };
      if (chosenPicto) patch.sym_path = chosenPicto;   // picto modifiable sur n'importe quelle case (lu par pictoFor)

      if (isNew) {
        const id = "u_" + Date.now();
        const it = { id, label, say: say || undefined, color: chosen, lang, skin, hair, tr,
          phrase: ctx.boardId === "advocacy",
          sym_path: chosenPicto || (media && media.kind === "image" ? null : textSVG(label)), media };
        (state.overrides.added[ctx.boardId] ||= []).push(it);
      } else if (added) {
        Object.assign(added.it, patch);
        if (chosenPicto) added.it.sym_path = chosenPicto;
        else if (media && media.kind === "image") added.it.sym_path = added.it.sym_path || null;
      } else {
        state.overrides.edited[item.id] = { ...state.overrides.edited[item.id], ...patch };
      }
      saveOverrides(); close(); refresh();
    });
    actions.append(cancel, save); card.appendChild(actions);
  }

  /* ---------- Rendu ORGANISÉ partagé (tous les pop-ups) : tri alphabétique + abécédaire A–Z
     cliquable (grandes lettres pour la commande oculaire) + filtre texte. ---------- */
  function organizedGrid(inner, host, items) {
    const full = items || [];
    const big = full.length > 40;
    const keyOf = it => normalize(labelFor(it));
    const sorted = big ? [...full].sort((a, b) => keyOf(a).localeCompare(keyOf(b), "fr")) : full;
    const CAP = 120;
    const grid = el("div", "popup-grid"); grid.style.setProperty("--cols", Math.min(state.settings.cols, 6));
    const note = el("p", "popup-note");
    const draw = list => {
      grid.innerHTML = "";
      list.slice(0, CAP).forEach(it => grid.appendChild(commTile(it, host)));
      note.textContent = list.length > CAP ? `${list.length} mots — touche une lettre (ou tape) pour voir les autres.` : "";
    };
    if (big) {
      let letter = null, fInput = null;
      const alpha = el("div", "popup-alpha");
      const setActive = b => { [...alpha.children].forEach(c => c.classList.remove("is-on")); if (b) b.classList.add("is-on"); };
      const mkBtn = (txt, val) => {
        const b = el("button", "alpha-btn", txt); b.type = "button";
        b.addEventListener("click", () => {
          letter = (letter === val) ? null : val; if (fInput) fInput.value = "";
          setActive(letter === null ? alpha.firstChild : b);
          draw(letter ? sorted.filter(it => keyOf(it).charAt(0) === letter) : sorted);
        });
        return b;
      };
      alpha.appendChild(mkBtn("Tout", null));
      [...new Set(sorted.map(it => keyOf(it).charAt(0)).filter(c => /[a-zà-ÿ]/.test(c)))].sort()
        .forEach(c => alpha.appendChild(mkBtn(c.toUpperCase(), c)));
      alpha.firstChild.classList.add("is-on");
      inner.appendChild(alpha);
      fInput = el("input", "popup-filter"); fInput.type = "text";
      fInput.placeholder = "ou tape un mot…"; fInput.setAttribute("aria-label", "Filtrer la liste");
      fInput.addEventListener("input", () => {
        letter = null; setActive(alpha.firstChild);
        const q = normalize(fInput.value);
        draw(q ? sorted.filter(it => keyOf(it).indexOf(q) >= 0) : sorted);
      });
      inner.appendChild(fInput); inner.appendChild(note);
    }
    draw(sorted);
    inner.appendChild(grid);
  }
  /* ---------- Pop-up de catégorie ---------- */
  function openPopup(fringeId) {
    const f = findBoard(fringeId); if (!f) return;
    let host = document.getElementById("ct-popup");
    if (!host) { host = el("div", "popup"); host.id = "ct-popup"; document.body.appendChild(host); }
    host.hidden = false; host.innerHTML = "";
    const inner = el("div", "popup-inner");
    const head = el("div", "popup-head");
    head.appendChild(el("strong", null, f.label));
    head.appendChild(closeX(() => host.hidden = true)); inner.appendChild(head);
    const items = [...(f.items || []).filter(i => !state.overrides.hidden.includes(i.id)), ...(state.overrides.added[fringeId] || [])];
    organizedGrid(inner, host, items);
    host.appendChild(inner);
    host.onclick = e => { if (e.target === host) host.hidden = true; };
  }
  // Pop-up d'une LISTE de mots quelconque (ex. « Tout voir » d'une colonne de l'accueil).
  function openList(title, color, items) {
    let host = document.getElementById("ct-popup");
    if (!host) { host = el("div", "popup"); host.id = "ct-popup"; document.body.appendChild(host); }
    host.hidden = false; host.innerHTML = "";
    const inner = el("div", "popup-inner");
    const head = el("div", "popup-head");
    head.appendChild(el("strong", null, title || ""));
    head.appendChild(closeX(() => host.hidden = true)); inner.appendChild(head);
    // EN LIEN AVEC LA PHRASE (additif) : les mots de CETTE liste associés au début de phrase
    // sont proposés en tête — la liste complète reste ensuite dans son ORDRE HABITUEL (repérage
    // stable). Associations éditoriales, pas une fréquence de corpus prouvée.
    const rel = (typeof contextRelevantItems === "function") ? contextRelevantItems(items || []) : [];
    if (rel.length) {
      inner.appendChild(el("div", "popup-section", "✨ En lien avec votre phrase"));
      const rg = el("div", "popup-grid"); rg.style.setProperty("--cols", Math.min(state.settings.cols, 6));
      rel.forEach(it => rg.appendChild(commTile(it, host)));
      inner.appendChild(rg);
      inner.appendChild(el("div", "popup-section muted", "Tous les mots"));
    }
    // Puis la liste complète, ORGANISÉE (alphabétique + abécédaire A–Z + filtre) — rendu partagé.
    organizedGrid(inner, host, items);
    host.appendChild(inner);
    host.onclick = e => { if (e.target === host) host.hidden = true; };
  }

  /* ============================================================
     LITTÉRATIE — Décodeur animé « mot → lecture » (T2L), FRANÇAIS (v1)
     Déclenché UNIQUEMENT par appui long sur un mot (jamais automatique) :
     demander « chocolat » ne doit pas infliger un cours de phonologie.
     Moteur GRAPHÉMIQUE français (ch, ou, eau, on, ain… pas lettre à lettre) —
     PREMIÈRE VERSION, à affiner/évaluer avec une orthophoniste.
     Le son de chaque graphème est illustré par un MOT-REPÈRE (« comme chat »)
     et non par un phonème isolé : c'est fiable avec la synthèse et c'est une
     méthode de lecture reconnue. Voir [[litteratie-caa]] (bangers 1 + 12).
     ============================================================ */
  // Voyelles (pour la règle des nasales : « an » nasal SAUF devant voyelle / n-m doublé).
  const _DEC_VOW = "aeiouyàâäéèêëîïôöùûüÿœæ";
  const _isVow = c => c && _DEC_VOW.indexOf(c.toLowerCase()) >= 0;
  // Inventaire ordonné du PLUS LONG au plus court ; { g, son, ex, nasal? }.
  const GRAPHEMES = [
    // { g graphème · son AFFICHÉ · say = ce qui est DIT (si ≠ son, ex. « ch » se dit « che ») ·
    //   ex mot-repère · ex2 mot de secours si ex = le mot décodé (jamais s'illustrer par lui-même) }
    // trigraphes / quadrigraphes
    { g: "eau", son: "o", ex: "orange", ex2: "bateau" },
    { g: "œu", son: "eu", ex: "œuf", ex2: "cœur" }, { g: "oeu", son: "eu", ex: "œuf", ex2: "cœur" },
    { g: "ain", son: "in", ex: "pain", ex2: "main", nasal: true }, { g: "ein", son: "in", ex: "peinture", ex2: "frein", nasal: true },
    { g: "aim", son: "in", ex: "faim", ex2: "daim", nasal: true }, { g: "oin", son: "oin", ex: "point", ex2: "coin", nasal: true },
    { g: "ill", son: "ille", ex: "fille", ex2: "papillon" }, { g: "gn", son: "gneu", ex: "araignée", ex2: "champignon" },
    // digraphes consonnes — une consonne seule n'est pas prononçable telle quelle : on l'appuie
    // sur le schwa [ə] noté « eu » (« ch » se dit « cheu », pas « ché »). Réf. système phono FR.
    { g: "ch", son: "cheu", ex: "chat", ex2: "chien" }, { g: "ph", son: "feu", ex: "photo", ex2: "téléphone" },
    { g: "th", son: "teu", ex: "thé", ex2: "théâtre" }, { g: "qu", son: "keu", ex: "pastèque", ex2: "musique" },
    // digraphes voyelles orales
    { g: "ou", son: "ou", ex: "ours", ex2: "loup" }, { g: "oi", son: "oi", ex: "oiseau", ex2: "poisson" },
    { g: "au", son: "o", ex: "orange", ex2: "jaune" }, { g: "eu", son: "eu", ex: "œuf", ex2: "feu" },
    { g: "ai", son: "è", ex: "aile", ex2: "maison" }, { g: "ei", son: "è", ex: "aile", ex2: "neige" },
    // voyelles nasales
    { g: "an", son: "an", ex: "ange", ex2: "antilope", nasal: true }, { g: "am", son: "an", ex: "ange", ex2: "jambe", nasal: true },
    { g: "en", son: "an", ex: "antilope", ex2: "serpent", nasal: true }, { g: "em", son: "an", ex: "ange", ex2: "temps", nasal: true },
    { g: "in", son: "in", ex: "lapin", ex2: "requin", nasal: true }, { g: "im", son: "in", ex: "timbre", ex2: "singe", nasal: true },
    { g: "on", son: "on", ex: "avion", ex2: "mouton", nasal: true }, { g: "om", son: "on", ex: "nombre", ex2: "concombre", nasal: true },
    { g: "un", son: "un", ex: "brun", ex2: "lundi", nasal: true },
    // voyelles accentuées
    { g: "é", son: "é", ex: "étoile", ex2: "été" }, { g: "è", son: "è", ex: "aile", ex2: "zèbre" }, { g: "ê", son: "è", ex: "aile", ex2: "tête" },
    { g: "ë", son: "e", ex: "Noël", ex2: "canoë" }, { g: "à", son: "a", ex: "là", ex2: "déjà" }, { g: "â", son: "a", ex: "ananas", ex2: "pâte" },
    { g: "î", son: "i", ex: "boîte", ex2: "île" }, { g: "ï", son: "i", ex: "maïs", ex2: "canoë" }, { g: "ô", son: "o", ex: "orange", ex2: "tôt" },
    { g: "ù", son: "u", ex: "où", ex2: "où" }, { g: "û", son: "u", ex: "usine", ex2: "flûte" }, { g: "ç", son: "seu", ex: "garçon", ex2: "leçon" },
    // lettres simples (mots-repères)
    { g: "a", son: "a", ex: "ananas", ex2: "avion" }, { g: "b", son: "beu", ex: "ballon", ex2: "bateau" }, { g: "c", son: "keu", ex: "carotte", ex2: "canard" },
    { g: "d", son: "deu", ex: "dinosaure", ex2: "dé" }, { g: "e", son: "eu", ex: "cheval", ex2: "melon" }, { g: "f", son: "feu", ex: "fusée", ex2: "fourmi" },
    { g: "g", son: "gueu", ex: "gâteau", ex2: "gomme" }, { g: "h", son: "muet", ex: "hibou", ex2: "hérisson" }, { g: "i", son: "i", ex: "souris", ex2: "lit" },
    { g: "j", son: "jeu", ex: "jupe", ex2: "jardin" }, { g: "k", son: "keu", ex: "kangourou", ex2: "koala" }, { g: "l", son: "leu", ex: "lune", ex2: "lit" },
    { g: "m", son: "meu", ex: "maman", ex2: "moto" }, { g: "n", son: "neu", ex: "nid", ex2: "nuage" }, { g: "o", son: "o", ex: "orange", ex2: "moto" },
    { g: "p", son: "peu", ex: "papa", ex2: "pomme" }, { g: "q", son: "keu", ex: "coq", ex2: "quatre" }, { g: "r", son: "reu", ex: "robe", ex2: "rue" },
    { g: "s", son: "seu", ex: "sac", ex2: "souris" }, { g: "t", son: "teu", ex: "tomate", ex2: "tortue" }, { g: "u", son: "u", ex: "usine", ex2: "lune" },
    { g: "v", son: "veu", ex: "vélo", ex2: "vache" }, { g: "w", son: "veu", ex: "kiwi", ex2: "wagon" }, { g: "x", son: "kseu", ex: "taxi", ex2: "boxe" },
    { g: "y", son: "i", ex: "pyjama", ex2: "yoyo" }, { g: "z", son: "zeu", ex: "zèbre", ex2: "zéro" },
  ];
  // Voyelles qui ADOUCISSENT c et g (c+e/i/y = /s/, g+e/i/y = /ʒ/).
  const _SOFT = "eiyéèêëîï";
  // Découpe un mot en graphèmes (longest-match + règle des nasales + lettres à son VARIABLE
  // selon le contexte : c, g, s — comme le rappelle la table phonie-graphie du français).
  function segmentFr(word) {
    const w = String(word || "").toLowerCase();
    const orig = String(word || "");
    const out = []; let i = 0;
    while (i < w.length) {
      const start = i;
      let hit = null;
      for (const gr of GRAPHEMES) {
        if (!w.startsWith(gr.g, i)) continue;
        if (gr.nasal) {                       // nasale seulement si PAS suivie d'une voyelle ni du n/m doublé
          const nx = w[i + gr.g.length];
          if (nx && (_isVow(nx) || nx === gr.g[gr.g.length - 1])) continue;
        }
        hit = { g: orig.slice(i, i + gr.g.length), son: gr.son, say: gr.say, ex: gr.ex, ex2: gr.ex2 };
        i += gr.g.length; break;
      }
      if (!hit) {                              // caractère hors inventaire (tiret, apostrophe…) : morceau brut
        hit = { g: orig.slice(i, i + 1), son: "", ex: "" }; i += 1;
      }
      // Son VARIABLE selon les lettres voisines (c/g doux, s = /z/ entre deux voyelles).
      const gl = hit.g.toLowerCase();
      const nc = w[start + 1];                 // lettre suivante (undefined en fin de mot)
      if (gl === "c" && nc && _SOFT.indexOf(nc) >= 0) {          // ci, ce, cy → /s/ (jamais en fin de mot)
        hit.son = "seu"; hit.say = undefined; hit.ex = "citron"; hit.ex2 = "cerise";
      } else if (gl === "g" && nc && _SOFT.indexOf(nc) >= 0) {   // gi, ge, gy → /ʒ/
        hit.son = "jeu"; hit.say = undefined; hit.ex = "girafe"; hit.ex2 = "nuage";
      } else if (gl === "s" && _isVow(w[start - 1]) && _isVow(nc)) { // voyelle-s-voyelle → /z/
        hit.son = "zeu"; hit.say = undefined; hit.ex = "zèbre"; hit.ex2 = "maison";
      }
      out.push(hit);
    }
    return out;
  }
  // Regroupe des graphèmes en SYLLABES (heuristique FR : noyau voyelle ; entre deux voyelles,
  // une consonne va avec la suivante (ba·teau), deux consonnes se coupent (par·ti) sauf groupe
  // « bl/tr/ch… » qui reste soudé (ta·ble)). Première version, à affiner.
  function syllabify(segs) {
    const isV = s => /^[aeiouyàâäéèêëîïôöùûüœ]/i.test(String(s.say || s.son || ""));
    const CL = new Set(["bl", "cl", "fl", "gl", "pl", "br", "cr", "dr", "fr", "gr", "pr", "tr", "vr", "ch", "ph", "th", "gn", "gu", "qu"]);
    const syl = []; let onset = [];
    for (const s of segs) {
      if (isV(s)) { syl.push(onset.concat([s])); onset = []; }   // voyelle = ferme une syllabe (consonnes en attaque)
      else onset.push(s);
    }
    if (onset.length) { if (syl.length) syl[syl.length - 1] = syl[syl.length - 1].concat(onset); else syl.push(onset); }
    // VC.CV : si une syllabe démarre par ≥2 consonnes non-groupe, la 1re revient à la précédente.
    for (let i = 1; i < syl.length; i++) {
      let j = 0; while (j < syl[i].length - 1 && !isV(syl[i][j])) j++;
      if (j >= 2) {
        const twoLast = syl[i].slice(j - 2, j).map(g => (g.g || "").toLowerCase()).join("");
        const keep = CL.has(twoLast) ? 2 : 1;
        const move = syl[i].slice(0, j - keep);
        if (move.length) { syl[i - 1] = syl[i - 1].concat(move); syl[i] = syl[i].slice(move.length); }
      }
    }
    return syl;
  }
  // ===== Modèle PHONOLOGIQUE (indexé par PHONÈME, pas par graphème) =====
  // Ancre par PHONÈME (API) : un vrai mot qui contient le son (démarre par lui si possible),
  // avec un picto. /o/ ≠ /ɔ/, /ø/ ≠ /œ/, /e/ ≠ /ɛ/ — entrées distinctes.
  const PHON_ANCHOR = {
    "a": "ananas", "i": "souris", "y": "usine", "u": "ours", "wa": "oiseau",
    "o": "autobus", "ɔ": "orange", "ø": "feu", "œ": "œuf", "ə": "cheval", "e": "étoile", "ɛ": "aile",
    "ɑ̃": "ange", "ɔ̃": "mouton", "ɛ̃": "requin", "œ̃": "brun", "wɛ̃": "point",
    "s": "serpent", "z": "zèbre", "ʃ": "chat", "ʒ": "jupe", "b": "ballon", "k": "carotte", "d": "dinosaure",
    "f": "fourmi", "g": "gâteau", "ɲ": "montagne", "l": "lune", "m": "maman", "n": "nid", "p": "papa",
    "ʁ": "robe", "t": "tomate", "v": "vélo", "ks": "taxi", "j": "fille", "ij": "fille",
  };
  const _SON2IPA = { seu: "s", zeu: "z", cheu: "ʃ", jeu: "ʒ", beu: "b", keu: "k", deu: "d", feu: "f", gueu: "g", gneu: "ɲ", leu: "l", meu: "m", neu: "n", peu: "p", reu: "ʁ", teu: "t", veu: "v", kseu: "ks" };
  const _NAS2IPA = { an: "ɑ̃", on: "ɔ̃", in: "ɛ̃", un: "œ̃", oin: "wɛ̃", ille: "ij" };
  const _isVowSeg = s => /^[aeiouyàâäéèêëîïôöùûüœ]/i.test(String((s && s.son) || ""));
  // La syllabe se ferme-t-elle par une consonne APRÈS cette voyelle ? (loi de position)
  function _syllClosed(seg, syls) {
    for (const sy of syls) { const p = sy.indexOf(seg); if (p >= 0) { for (let k = p + 1; k < sy.length; k++) { if (!_isVowSeg(sy[k])) return true; } return false; } }
    return false;
  }
  // Phonème (API) d'un graphème. variable=true = dépend du mot/de l'accent (loi de position
  // approximative : la vraie prononciation par mot viendra d'un lexique type Lexique-Infra).
  function phonemeInfo(seg, syls) {
    const g = (seg.g || "").toLowerCase();
    if (seg.son === "muet") return { ph: "∅", variable: false };
    if (_SON2IPA[seg.son]) return { ph: _SON2IPA[seg.son], variable: false };   // consonnes (c/g/s déjà contextualisés)
    if (_NAS2IPA[seg.son]) return { ph: _NAS2IPA[seg.son], variable: false };
    if (["a", "à", "â"].includes(g)) return { ph: "a", variable: false };
    if (["i", "î", "ï", "y"].includes(g)) return { ph: "i", variable: false };
    if (["u", "ù", "û"].includes(g)) return { ph: "y", variable: false };
    if (g === "ou") return { ph: "u", variable: false };
    if (g === "oi") return { ph: "wa", variable: false };
    if (g === "é") return { ph: "e", variable: false };
    if (["è", "ê", "ai", "ei"].includes(g)) return { ph: "ɛ", variable: true };   // parfois /e/ selon le mot
    if (g === "ë" || g === "e") return { ph: "ə", variable: true };
    if (["eau", "ô"].includes(g)) return { ph: "o", variable: false };
    if (["œu", "oeu"].includes(g)) return { ph: "œ", variable: false };
    if (["o", "au"].includes(g)) { return { ph: _syllClosed(seg, syls) ? "ɔ" : "o", variable: true }; }   // loi de position
    if (g === "eu") { return { ph: _syllClosed(seg, syls) ? "œ" : "ø", variable: true }; }
    return { ph: (seg.son || ""), variable: true };
  }
  const _decSleep = ms => new Promise(r => setTimeout(r, ms));
  let _decRun = 0;
  // ===== BANQUE DE SONS HUMAINS (par phonème API, par PERSONNE) =====
  // Le VRAI son isolé vient d'un enregistrement HUMAIN local (jamais de synthèse).
  // PLUSIEURS voix : chaque proche (maman, papa, orthophoniste…) peut enregistrer sa série.
  // Clé IndexedDB : « phon_<voix>_<API> ». 100 % local.
  function _phonVoices() { const v = state.overrides && state.overrides.phonVoices; return (Array.isArray(v) && v.length) ? v : [{ id: "def", name: "Voix par défaut" }]; }
  function _curPhonVoice() { return state.settings.phonVoice || "def"; }
  function _curVoiceName() { return (_phonVoices().find(v => v.id === _curPhonVoice()) || {}).name || "cette personne"; }
  const _phonKey = (ipa, voice) => "phon_" + (voice || _curPhonVoice()) + "_" + ipa;
  const _wordKey = (w, voice) => "word_" + (voice || _curPhonVoice()) + "_" + normalize(w);
  const _blendKey = (w, voice) => "blend_" + (voice || _curPhonVoice()) + "_" + normalize(w);   // fusion CONTINUE enregistrée
  async function _hasKey(key) { try { return !!(await getMedia(key)); } catch (e) { return false; } }
  function _playKey(key) {   // joue et RÉSOUT à la fin (bool). Voix COURANTE seulement, jamais de repli auto.
    return new Promise(async res => {
      try { const b = await getMedia(key); if (!b) { res(false); return; } const a = new Audio(URL.createObjectURL(b)); a.onended = () => { URL.revokeObjectURL(a.src); res(true); }; a.onerror = () => res(false); a.play().catch(() => res(false)); }
      catch (e) { res(false); }
    });
  }
  async function hasPhonemeAudio(ipa, voice) { return _hasKey(_phonKey(ipa, voice)); }
  function playPhonemeAudio(ipa) { return _playKey(_phonKey(ipa)); }   // voix COURANTE uniquement
  // Enregistreur audio GÉNÉRIQUE (micro → IndexedDB à `key`). Voix humaine, jouée telle quelle.
  function openAudioRecorder(key, title, hint, onDone) {
    const { card, close } = modal(title);
    card.appendChild(el("p", "hint", hint));
    let recorder = null, chunks = [], blob = null;
    const status = el("p", "hint", "");
    const preview = document.createElement("audio"); preview.controls = true; preview.style.cssText = "width:100%;margin-top:8px";
    urlFor(key).then(u => { if (u) { preview.src = u; status.textContent = "Un enregistrement existe déjà."; } });
    const recBtn = el("button", "btn-primary", "🎤 Enregistrer");
    recBtn.addEventListener("click", async () => {
      if (recorder && recorder.state === "recording") { recorder.stop(); return; }
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        recorder = new MediaRecorder(stream); chunks = [];
        recorder.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
        recorder.onstop = () => { blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }); preview.src = URL.createObjectURL(blob); status.textContent = "Enregistré ✓ — écoute, puis « Enregistrer »."; recBtn.textContent = "🎤 Ré-enregistrer"; recBtn.classList.remove("is-on"); stream.getTracks().forEach(t => t.stop()); };
        recorder.start(); recBtn.textContent = "⏹ Arrêter"; recBtn.classList.add("is-on"); status.textContent = "Enregistrement…";
      } catch (e) { status.textContent = "Micro non autorisé — autorise le micro, puis réessaie."; }
    });
    const save = el("button", "btn-primary", "Enregistrer");
    save.addEventListener("click", async () => { if (blob) { try { await putMedia(key, blob); urlCache.delete(key); } catch (e) {} } close(); if (onDone) onDone(); });
    const del = el("button", "btn-secondary", "Retirer");
    del.addEventListener("click", async () => { try { urlCache.delete(key); await delMedia(key); } catch (e) {} close(); if (onDone) onDone(); });
    const row = el("div"); row.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px"; row.append(recBtn, save, del);
    card.append(status, preview, row);
  }
  // Enregistreur d'un PHONÈME (voix d'un proche).
  function openPhonemeRecorder(ipa, grapheme, onDone, voice) {
    voice = voice || _curPhonVoice();
    const vName = (_phonVoices().find(v => v.id === voice) || {}).name || "";
    openAudioRecorder(_phonKey(ipa, voice), "Enregistrer /" + ipa + "/" + (vName ? " — " + vName : ""),
      "Le graphème « " + grapheme + " » se dit /" + ipa + "/. " + (vName ? "Voix : « " + vName + " ». " : "") + "Enregistre ce son avec une VRAIE voix (parent, proche, orthophoniste…), seul et continu (« ssss » pour /s/). Joué tel quel — jamais de synthèse. 100 % local.", onDone);
  }
  // Mot ENTIER naturel enregistré (voix COURANTE uniquement) — pour la fin de la fusion.
  function playWordAudio(w) { return _playKey(_wordKey(w)); }
  // Son manquant pour la voix choisie : on NE change PAS de personne en douce. On explique + on propose.
  function openPhonemeMissingDialog(ipa, grapheme, onDone) {
    const { card, close } = modal("Son /" + ipa + "/ non enregistré");
    card.appendChild(el("p", "hint", "Ce son /" + ipa + "/ n'a pas encore été enregistré par « " + _curVoiceName() + " »."));
    const row = el("div"); row.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px";
    const rec = el("button", "btn-primary", "🎤 Enregistrer ce son"); rec.addEventListener("click", () => { close(); openPhonemeRecorder(ipa, grapheme, onDone); });
    const other = el("button", "btn-secondary", "Choisir une autre personne"); other.addEventListener("click", () => { close(); openPhonemeBank(); });
    const skip = el("button", "btn-secondary", "Poursuivre sans ce son"); skip.addEventListener("click", close);
    row.append(rec, other, skip); card.appendChild(row);
  }
  // Menu d'enregistrement d'un mot : les 3 ressources SÉPARÉES, par la MÊME personne.
  function openRecordMenu(word) {
    const { card, close } = modal("🎤 Enregistrer une voix humaine");
    const guide = el("p", ""); const gb = el("button", "linklike", "📖 Lire le guide d'enregistrement"); gb.style.fontWeight = "800"; gb.addEventListener("click", () => { close(); openRecordGuide(); }); guide.appendChild(gb); card.appendChild(guide);
    card.appendChild(el("p", "hint", "Voix : « " + _curVoiceName() + " ». 100 % local, jamais de synthèse. Trois ressources distinctes, enregistrées par la même personne."));
    const opt = (label, hint, fn) => { const b = el("button", "btn-secondary"); b.style.cssText = "display:block;width:100%;text-align:left;margin:8px 0;font-weight:800"; b.textContent = label; b.addEventListener("click", () => { close(); fn(); }); card.appendChild(b); const p = el("p", "hint", hint); p.style.margin = "-4px 0 6px"; card.appendChild(p); };
    opt("🔤 Les sons (banque)", "Chaque phonème isolé, réutilisé dans tous les mots.", () => openPhonemeBank());
    opt("🔗 La fusion de « " + word + " »", "Le mot dit d'UNE SEULE traite en RELIANT les sons (« ssssaaa… sac »), sans coupure. Prolonge les sons qui filent (f, l, m, n, s) ; n'ajoute PAS de « euh » aux sons courts (p, t, k, b, d, g).", () => openAudioRecorder(_blendKey(word), "Fusion continue de « " + word + " » — " + _curVoiceName(), "Dis « " + word + " » d'une seule traite en reliant les sons, sans t'arrêter. 100 % local.", () => {}));
    opt("🗣️ Le mot entier « " + word + " »", "Le mot naturel, prononcé normalement.", () => openAudioRecorder(_wordKey(word), "Mot « " + word + " » — " + _curVoiceName(), "Dis le mot « " + word + " » normalement, d'une vraie voix. 100 % local.", () => {}));
  }
  // Guide d'enregistrement pour les proches (verrouille les bonnes pratiques).
  function openRecordGuide() {
    const { card, close } = modal("📖 Guide d'enregistrement (pour les proches)");
    const h = t => el("h4", "guide-h", t);
    const p = html => { const e = el("p", "guide-p"); e.innerHTML = html; return e; };
    const ul = items => { const u = document.createElement("ul"); u.className = "guide-ul"; items.forEach(i => { const li = document.createElement("li"); li.innerHTML = i; u.appendChild(li); }); return u; };
    card.append(
      p("Trois choses à enregistrer, <strong>séparément</strong> et par la <strong>même personne</strong> : les <strong>sons</strong>, la <strong>fusion</strong> de quelques mots, le <strong>mot entier</strong>."),
      h("1. Préparation"),
      ul(["Pièce <strong>calme</strong> (pas de télé, pas de ventilateur).", "Micro à environ <strong>20 cm</strong> de la bouche.", "Fais un <strong>essai</strong> : enregistre un « aaa », écoute, ajuste."]),
      h("2. Les sons isolés"),
      ul(["<strong>Court et net</strong> : un son, pas une syllabe.", "<strong>Aucune voyelle ajoutée</strong> : « s » = « <strong>ssss</strong> » (jamais « seu »), « f » = « ffff », « m » = « mmmm ».", "Sons qui <strong>filent</strong> (f, l, m, n, s, v, z, ch, j) : tu peux les tenir ~1 seconde.", "Sons <strong>courts / occlusifs</strong> (p, t, k, b, d, g) : un petit coup, <strong>sans « euh »</strong>. Mieux vaut trop court que « peuh / keuh »."]),
      h("3. La fusion continue"),
      ul(["<strong>Une seule prise</strong>, <strong>sans pause</strong> entre les sons : « <strong>ssssaaa… sac</strong> ».", "<strong>Commence par des mots simples à consonne qui file</strong> : <strong>ma, mou, fil, sol, sac</strong>.", "Les mots avec p / t / k / b / d / g sont plus délicats (plus d'accompagnement) — on ne <strong>prolonge JAMAIS</strong> ces sons artificiellement.", "Toutes les fusions ne se valent pas : n'enregistre que les mots <strong>travaillés</strong> avec la personne."]),
      h("4. Le mot entier"),
      ul(["Prononciation <strong>naturelle</strong>, comme dans la vraie vie.", "<strong>Sans surarticuler</strong> (pas de « SA-C » exagéré)."]),
      h("5. Écoute et validation"),
      ul(["Après chaque prise, <strong>écoute</strong> l'aperçu.", "Ne « mets dans la banque » que si c'est <strong>bon</strong>."]),
      h("6. Refaire, supprimer, changer de personne"),
      ul(["Tu peux <strong>ré-enregistrer</strong> autant que tu veux ; « Retirer » supprime.", "Chaque proche a <strong>sa</strong> voix (Maman, Papa, orthophoniste…), jamais mélangées."]),
      h("7. Accents et voix de famille"),
      ul(["<strong>Ta prononciation est la bonne pour ta famille.</strong>", "L'appli ne décrète pas « le bon français » : tous les accents (Nord, Sud, Québec, Belgique, Afrique…) sont légitimes."]),
    );
    const foot = el("div"); foot.style.cssText = "margin-top:12px;display:flex;gap:8px;flex-wrap:wrap";
    const print = el("button", "btn-secondary", "🖨️ Imprimer"); print.addEventListener("click", () => window.print());
    const ok = el("button", "btn-primary", "J'ai compris"); ok.addEventListener("click", close);
    foot.append(print, ok); card.appendChild(foot);
  }
  // Inventaire des phonèmes du français (≈ 36 : 16 voyelles, 17 consonnes, 3 semi-voyelles).
  // [API, mot-exemple]. /ɑ/, /œ̃/, /ŋ/ sont marginaux (souvent fusionnés) — à enregistrer si utile.
  const PHONEMES = [
    { grp: "Voyelles orales", items: [["a", "chat"], ["ɑ", "pâte"], ["e", "été"], ["ɛ", "mère"], ["ə", "le"], ["i", "lit"], ["o", "moto"], ["ɔ", "or"], ["u", "loup"], ["y", "lune"], ["ø", "feu"], ["œ", "peur"]] },
    { grp: "Voyelles nasales", items: [["ɛ̃", "pain"], ["ɑ̃", "enfant"], ["ɔ̃", "pont"], ["œ̃", "brun"]] },
    { grp: "Consonnes", items: [["p", "papa"], ["b", "ballon"], ["t", "tomate"], ["d", "dé"], ["k", "carotte"], ["g", "gâteau"], ["f", "fusée"], ["v", "vélo"], ["s", "serpent"], ["z", "zèbre"], ["ʃ", "chat"], ["ʒ", "jupe"], ["m", "maman"], ["n", "nid"], ["ɲ", "montagne"], ["ŋ", "parking"], ["l", "lune"], ["ʁ", "robe"]] },
    { grp: "Semi-voyelles", items: [["j", "yoyo"], ["w", "oui"], ["ɥ", "huit"]] },
  ];
  // Gestionnaire de la banque de sons : PLUSIEURS voix (proches) + liste + avancement.
  function openPhonemeBank() {
    const { card, close } = modal("🎤 Banque de sons (voix humaine)");
    card.appendChild(el("p", "hint", "Chaque proche (maman, papa, une orthophoniste…) enregistre SA série. Enregistré une fois → joué partout, jamais synthétisé, jamais mélangé à une autre voix. 100 % local. L'inventaire ci-dessous est un profil standard : il pourra varier selon les variétés de français."));
    const gl = el("p", ""); const glb = el("button", "linklike", "📖 Lire le guide d'enregistrement"); glb.style.fontWeight = "800"; glb.addEventListener("click", () => openRecordGuide()); gl.appendChild(glb); card.appendChild(gl);
    const voiceRow = el("div", "phon-voices"); card.appendChild(voiceRow);
    const prog = el("p", "phon-prog", ""); card.appendChild(prog);
    const listBox = el("div", "phon-bank"); card.appendChild(listBox);
    function rebuildVoices() {
      voiceRow.innerHTML = "";
      voiceRow.appendChild(el("span", "hint", "Personne : "));
      const sel = document.createElement("select");
      _phonVoices().forEach(v => sel.appendChild(Object.assign(document.createElement("option"), { value: v.id, textContent: v.name, selected: v.id === _curPhonVoice() })));
      sel.addEventListener("change", () => { state.settings.phonVoice = sel.value; if (typeof saveSettings === "function") saveSettings(); refresh(); });
      voiceRow.appendChild(sel);
      const add = el("button", "btn-secondary", "➕ Ajouter une personne");
      add.addEventListener("click", async () => {
        const n = (await ctPrompt("Nom de la personne qui enregistre (ex. Maman, Papa, Léa) :") || "").trim(); if (!n) return;
        if (!Array.isArray(state.overrides.phonVoices)) state.overrides.phonVoices = [{ id: "def", name: "Voix par défaut" }];
        const id = "v" + Date.now(); state.overrides.phonVoices.push({ id, name: n }); state.settings.phonVoice = id;
        if (typeof saveOverrides === "function") saveOverrides(); if (typeof saveSettings === "function") saveSettings();
        rebuildVoices(); refresh();
      });
      voiceRow.appendChild(add);
      if (_curPhonVoice() !== "def") {
        const del = el("button", "btn-secondary", "🗑️ Retirer");
        del.addEventListener("click", async () => {
          if (!await ctConfirm("Retirer cette personne et tous ses enregistrements ?", "", { ok: "Retirer", cancel: "Annuler", danger: true })) return;
          const vid = _curPhonVoice();
          for (const g of PHONEMES) for (const [ipa] of g.items) { try { urlCache.delete(_phonKey(ipa, vid)); await delMedia(_phonKey(ipa, vid)); } catch (e) {} }
          state.overrides.phonVoices = _phonVoices().filter(v => v.id !== vid); state.settings.phonVoice = "def";
          if (typeof saveOverrides === "function") saveOverrides(); if (typeof saveSettings === "function") saveSettings();
          rebuildVoices(); refresh();
        });
        voiceRow.appendChild(del);
      }
    }
    async function refresh() {
      listBox.innerHTML = ""; let done = 0, total = 0; const voice = _curPhonVoice();
      for (const g of PHONEMES) {
        listBox.appendChild(el("h4", "phon-grp", g.grp));
        const row = el("div", "phon-row");
        for (const [ipa, ex] of g.items) {
          total++; const has = await hasPhonemeAudio(ipa, voice); if (has) done++;
          const b = el("button", "phon-cell" + (has ? " has" : ""));
          b.appendChild(el("span", "phon-ipa", "/" + ipa + "/"));
          b.appendChild(el("span", "phon-ex", ex));
          b.appendChild(el("span", "phon-state", has ? "🔊" : "🎤"));
          b.addEventListener("click", () => openPhonemeRecorder(ipa, ex, refresh, voice));
          row.appendChild(b);
        }
        listBox.appendChild(row);
      }
      const vName = (_phonVoices().find(v => v.id === _curPhonVoice()) || {}).name || "";
      prog.textContent = done + " / " + total + " sons proposés dans ce profil" + (vName ? " — " + vName : "") + ".";
    }
    rebuildVoices(); refresh();
  }
  // SON d'une lettre/graphème (« b » → « be », « ch » → « che »), jamais le NOM de la lettre.
  // Utilisé par la touche « 🔉 son » du clavier ET le décodeur (même source de vérité).
  function soundOf(ch) {
    const s = segmentFr(String(ch || ""))[0];
    if (!s) return null;
    if (s.son === "muet") return null;      // lettre muette : pas de son
    return s.say || s.son || null;
  }
  // Cherche une illustration LOCALE pour un mot : d'abord ce que la personne a
  // associé elle-même (image/vidéo/lien), puis une zone de scène visuelle, puis
  // un média d'une case perso. Jamais de recherche réseau.
  function findWordMedia(word) {
    const w = normalize(word);
    const wm = (state.overrides && state.overrides.wordMedia) || {};
    if (wm[w] && wm[w].media) return Object.assign({ personal: true }, wm[w].media);   // photo/vidéo perso (jamais le lien)
    const scenes = (state.overrides && state.overrides.scenes) || [];
    for (const s of scenes) {
      const h = (s.hotspots || []).find(z => { const zl = normalize(z.label); return zl === w || zl.split(/\s+/).includes(w); });
      if (h && s.imgKey) return { kind: s.kind === "video" ? "video" : "image", key: s.imgKey, sceneName: s.name, hotspot: s.kind === "video" ? null : h };
    }
    const added = (state.overrides && state.overrides.added) || {};
    for (const k in added) {
      for (const it of (added[k] || [])) {
        if (it.media && (it.media.kind === "image" || it.media.kind === "video") && normalize(it.label || "") === w)
          return { kind: it.media.kind, key: it.media.key, url: it.media.url };
      }
    }
    return null;
  }
  // Ouvre le décodeur pour UN mot. ctx = { words:[…], index } permet de circuler
  // d'un mot à l'autre de la phrase avec ‹ › (accessible sans appui long).
  function openDecoder(rawWord, ctx) {
    if (state.settings.decoder === false) return;
    const word = String(rawWord || "").replace(/[?!.,;:«»"]/g, "").trim();
    if (!word || !/[a-zàâäéèêëîïôöùûüÿçœæ]/i.test(word)) return;
    const words = (ctx && Array.isArray(ctx.words) && ctx.words.length) ? ctx.words : [word];
    let index = (ctx && typeof ctx.index === "number") ? ctx.index : words.indexOf(word);
    if (index < 0) index = 0;
    const segs = segmentFr(word);
    // Jamais illustrer un morceau par le mot lui-même : si le mot-repère = le mot décodé, prendre le secours.
    const _wn = normalize(word);
    segs.forEach(x => { if (x.ex && x.ex2 && normalize(x.ex) === _wn) x.ex = x.ex2; });
    let host = document.getElementById("ct-decoder");
    if (!host) { host = el("div", "popup ct-decoder"); host.id = "ct-decoder"; document.body.appendChild(host); }
    host.hidden = false; host.innerHTML = "";
    const close = () => { _decRun++; host.hidden = true; if ("speechSynthesis" in window) window.speechSynthesis.cancel(); };
    host.onclick = e => { if (e.target === host) close(); };
    const inner = el("div", "popup-inner dec-inner");
    const head = el("div", "popup-head");
    if (words.length > 1) {
      const prev = el("button", "btn-secondary dec-nav", "‹"); prev.title = "Mot précédent"; prev.disabled = index <= 0;
      prev.addEventListener("click", () => openDecoder(words[index - 1], { words, index: index - 1 }));
      head.appendChild(prev);
    }
    head.appendChild(el("strong", null, "Décoder « " + word + " »"));
    if (words.length > 1) {
      const next = el("button", "btn-secondary dec-nav", "›"); next.title = "Mot suivant"; next.disabled = index >= words.length - 1;
      next.addEventListener("click", () => openDecoder(words[index + 1], { words, index: index + 1 }));
      head.appendChild(next);
    }
    head.appendChild(closeX(close));
    inner.appendChild(head);
    // Picto du mot (relie le décodage au SENS) — rempli en asynchrone depuis la banque.
    const picWrap = el("div", "dec-pic"); inner.appendChild(picWrap);
    (async () => {
      try {
        const m = await ensureSymMap();
        let sp = symFor(m, word) || symFor(m, word.toLowerCase().replace(/s$/, "")) || symFor(m, word.toLowerCase().replace(/e$/, ""));
        if (sp && !host.hidden) { const im = document.createElement("img"); im.src = sp; im.alt = ""; im.className = "dec-pic-img"; picWrap.appendChild(im); }
      } catch (e) { /* pas de picto pour ce mot : on l'illustre sans, jamais de blocage */ }
    })();
    // Mot entier (chaque graphème surlignable pendant l'animation).
    const whole = el("div", "dec-whole");
    const wspans = segs.map((s, idx) => { const sp = el("span", "dec-w-g", s.g); sp.dataset.idx = idx; whole.appendChild(sp); return sp; });
    inner.appendChild(whole);
    const syls = syllabify(segs);
    // Phonème (API) + ancre PAR PHONÈME pour chaque graphème (jamais d'auto-référence).
    segs.forEach(s => {
      const info = phonemeInfo(s, syls);
      s.ph = info.ph; s.phVar = info.variable;
      let a = PHON_ANCHOR[s.ph] || null;
      if (a && normalize(a) === _wn) a = null;   // ne pas s'illustrer par le mot décodé
      s.anchor = a;
    });
    // --- MODES d'exploration (ce ne sont PAS des « niveaux » : pas une progression) ---
    inner.appendChild(el("p", "dec-modes-q", "Comment explorer le mot ?"));
    let decMode = ["graph", "syll", "anchor"].includes(state.settings.decMode) ? state.settings.decMode : "graph";
    const modes = el("div", "dec-levels");
    const MODES = [{ id: "graph", lab: "Graphèmes et sons" }, { id: "syll", lab: "Syllabes orales" }, { id: "anchor", lab: "Mots-repères" }];
    const modeBtns = MODES.map(M => {
      const b = el("button", "dec-lvl", M.lab); b.dataset.m = M.id;
      b.addEventListener("click", () => { decMode = M.id; state.settings.decMode = M.id; if (typeof saveSettings === "function") saveSettings(); renderChips(); });
      return b;
    });
    modes.append(...modeBtns); inner.appendChild(modes);
    const chipsRow = el("div", "dec-chips"); inner.appendChild(chipsRow);
    const keyBox = el("div", "dec-key"); keyBox.hidden = true; inner.appendChild(keyBox);
    const note = el("p", "dec-note", ""); inner.appendChild(note);
    // Voix locale Piper : présentée honnêtement comme moteur à valider (pas « exact »).
    const piperNote = el("p", "dec-piper"); piperNote.hidden = true; inner.appendChild(piperNote);
    if (window.prepPiperForDecoder) window.prepPiperForDecoder().then(ready => { if (ready) { piperNote.hidden = false; piperNote.className = "dec-piper ok"; piperNote.textContent = "🔊 Voix locale Piper active (moteur à valider phonème par phonème)."; } });
    let chipEls = [];
    function renderChips() {
      chipsRow.innerHTML = ""; keyBox.hidden = true; keyBox.innerHTML = ""; clearHi();
      modeBtns.forEach(b => b.classList.toggle("on", b.dataset.m === decMode));
      chipEls = [];
      if (decMode === "syll") {                                   // SYLLABES ORALES (vrais sons)
        syls.forEach(sy => {
          const txt = sy.map(g => g.g).join("");
          const c = el("button", "dec-chip dec-syl"); c.appendChild(el("span", "dec-graph", txt));
          c.addEventListener("click", () => { highlightSyl(sy); dsay(txt); });
          chipsRow.appendChild(c);
        });
        note.innerHTML = "Découpage <strong>indicatif</strong> : la syllabe ORALE varie selon les accents (« table » = /tabl/ en 1 syllabe, ou /ta.blə/ dans le Sud). Touche une syllabe pour l'entendre.";
      } else {                                                    // GRAPHÈME → /PHONÈME/ (API)
        chipEls = segs.map((s, idx) => {
          const c = el("button", "dec-chip"); c.dataset.idx = String(idx);
          c.appendChild(el("span", "dec-graph", s.g));
          c.appendChild(el("span", "dec-son", s.ph === "∅" ? "muet" : "→ /" + s.ph + "/" + (s.phVar ? " *" : "")));
          if (decMode === "anchor" && s.anchor) c.appendChild(el("span", "dec-ex", "comme " + s.anchor));
          if (decMode === "anchor") { c.addEventListener("click", () => playAnchor(idx)); }
          else {                                                   // GRAPHÈMES : SON HUMAIN de la banque (jamais de synthèse)
            const badge = el("span", "dec-phon-badge", "…"); if (s.ph !== "∅") c.appendChild(badge);
            if (s.ph !== "∅") hasPhonemeAudio(s.ph).then(h => { badge.textContent = h ? "🔊" : "🎤"; badge.title = h ? "Son enregistré — touche pour l'entendre" : "Pas encore de son — touche pour l'enregistrer"; });
            c.addEventListener("click", () => onGraphTap(idx, badge));
          }
          chipsRow.appendChild(c); return c;
        });
        note.innerHTML = decMode === "graph"
          ? "Touche un son : tu entends l'<strong>enregistrement humain</strong> s'il existe (🔊), sinon tu peux l'enregistrer (🎤). <strong>Jamais de synthèse.</strong> <strong>*</strong> = varie selon le mot/l'accent."
          : "Un <strong>mot-repère</strong> qui contient le son — pour y accéder par un vrai mot. Touche-le pour l'entendre.";
      }
    }
    // Touche « son » (mode Graphèmes) : joue l'enregistrement HUMAIN de la voix CHOISIE.
    // S'il manque, on N'utilise PAS une autre personne : on l'explique et on propose.
    async function onGraphTap(idx, badge) {
      const s = segs[idx]; highlight(idx);
      if (s.ph === "∅") return;
      const played = await playPhonemeAudio(s.ph);
      if (!played) openPhonemeMissingDialog(s.ph, s.g, () => { hasPhonemeAudio(s.ph).then(h => { if (badge) badge.textContent = h ? "🔊" : "🎤"; }); });
    }
    // Indicateur de VOIX choisie (jamais de changement silencieux) — cliquable pour en changer.
    const voiceLine = el("p", "dec-voice", "");
    voiceLine.append(document.createTextNode("🎙️ Voix : "));
    const vb = el("button", "linklike", _curVoiceName()); vb.addEventListener("click", () => openPhonemeBank()); voiceLine.appendChild(vb);
    inner.appendChild(voiceLine);
    const btns = el("div", "dec-btns");
    const bPlay = el("button", "btn-primary", "▶ Les sons"); bPlay.addEventListener("click", playAll);
    const bWord = el("button", "btn-secondary", "🔊 Le mot"); bWord.addEventListener("click", async () => { const h = await playWordAudio(word); if (!h) sayWord(1); });
    const bFusion = el("button", "btn-secondary", "🔗 Fusion"); bFusion.addEventListener("click", playFusion);
    const bRec = el("button", "btn-secondary", "🎤 Enregistrer"); bRec.addEventListener("click", () => openRecordMenu(word));
    btns.append(bPlay, bWord, bFusion, bRec); inner.appendChild(btns);
    // Illustration LOCALE : photo/vidéo issue des scènes visuelles (jamais un lien web — RGPD/hors-ligne).
    const sceneBox = el("div", "dec-scene"); sceneBox.hidden = true; inner.appendChild(sceneBox);
    const wkey = normalize(word);
    if (!state.overrides.wordMedia) state.overrides.wordMedia = {};
    const wm = state.overrides.wordMedia;
    const entry = () => (wm[wkey] || (wm[wkey] = {}));                 // { media?:{kind,key}, link?:url }
    const cleanEntry = () => { const e = wm[wkey]; if (e && !e.media && !e.link) delete wm[wkey]; };
    const cur = wm[wkey] || {};
    // 1) VOIR l'illustration locale (photo/vidéo perso, ou zone de scène). Jamais un lien ici.
    const found = findWordMedia(word);
    if (found) {
      const seeLbl = found.kind === "video" ? "🎬 Voir la vidéo" : (found.sceneName ? "🖼️ Voir en scène" : "🖼️ Voir la photo");
      const bSee = el("button", "btn-secondary", seeLbl);
      bSee.addEventListener("click", () => toggleScene(found, bSee));
      btns.appendChild(bSee);
    }
    // 2) SA photo/vidéo locale (fichier de l'appareil) — 100 % hors-ligne. Ajouter / changer / retirer.
    const hasPhoto = !!(cur.media && (cur.media.kind === "image" || cur.media.kind === "video"));
    const bPhoto = el("button", "btn-secondary", hasPhoto ? "📷 Changer la photo / vidéo" : "📷 Ma photo / vidéo");
    bPhoto.addEventListener("click", () => {
      const inp = Object.assign(document.createElement("input"), { type: "file", accept: "image/*,video/*" });
      inp.addEventListener("change", async () => {
        const f = inp.files[0]; if (!f) return;
        const key = "wm_" + wkey.replace(/\s+/g, "_") + "_" + Date.now();
        try {
          await putMedia(key, f); urlCache.delete(key);
          if (cur.media && cur.media.key) { urlCache.delete(cur.media.key); delMedia(cur.media.key); }  // remplace : libère l'ancien fichier
          entry().media = { kind: f.type.startsWith("video") ? "video" : "image", key };
          saveOverrides(); openDecoder(word);
        } catch (e) {}
      });
      inp.click();
    });
    btns.appendChild(bPhoto);
    if (hasPhoto) {
      const bDel = el("button", "btn-secondary dec-linkedit", "🗑️");
      bDel.title = "Retirer la photo / vidéo";
      bDel.addEventListener("click", async () => {
        if (!await ctConfirm("Retirer la photo / vidéo de ce mot ? Vous pourrez annuler juste après.", "", { ok: "Retirer", cancel: "Annuler" })) return;
        const snap = cur.media ? { ...cur.media } : null;
        if (snap && snap.key) { urlCache.delete(snap.key); await delMedia(snap.key); }
        delete entry().media; cleanEntry(); saveOverrides(); openDecoder(word);
        if (window.toast) toast("Photo / vidéo retirée.", null, "🗑️", snap ? { label: "↺ Annuler", fn: async () => {
          if (snap.key) await restoreMedia(snap.key);
          entry().media = snap; saveOverrides(); openDecoder(word); toast("Photo / vidéo restaurée.", null, "↺");
        } } : null);
      });
      btns.appendChild(bDel);
    }
    // 3) Lien web au CHOIX de la personne : rien n'est fermé, tout est explicite. Ajouter / ouvrir / changer / retirer.
    const hasLink = !!cur.link;
    const bLink = el("button", "btn-secondary", hasLink ? "🔗 Ouvrir le lien" : "🔗 Lien vidéo (web)");
    if (hasLink) bLink.title = cur.link;
    bLink.addEventListener("click", async () => {
      if (cur.link) { window.open(cur.link, "_blank", "noopener,noreferrer"); return; }
      const url = (await ctPrompt("Adresse d'une vidéo à ouvrir dans le navigateur (ton choix, rien n'est envoyé automatiquement) :", "https://") || "").trim();
      if (/^https?:\/\//i.test(url)) { entry().link = url; saveOverrides(); openDecoder(word); }
    });
    btns.appendChild(bLink);
    if (hasLink) {
      const bEditLink = el("button", "btn-secondary dec-linkedit", "✎");
      bEditLink.title = "Changer ou retirer le lien";
      bEditLink.addEventListener("click", async () => {
        const u = (await ctPrompt("Nouveau lien (laisser vide pour le retirer) :", cur.link) || "").trim();
        if (!u) delete entry().link;
        else if (/^https?:\/\//i.test(u)) entry().link = u;
        cleanEntry(); saveOverrides(); openDecoder(word);
      });
      btns.appendChild(bEditLink);
    }
    host.appendChild(inner);

    function toggleScene(found, btn) {
      if (!sceneBox.hidden) { sceneBox.hidden = true; sceneBox.innerHTML = ""; return; }
      sceneBox.hidden = false; sceneBox.innerHTML = "";
      if (found.sceneName) sceneBox.appendChild(el("div", "dec-scene-cap", "Dans la scène « " + found.sceneName + " »"));
      const frame = el("div", "dec-scene-frame");
      if (found.kind === "video") {
        const vid = document.createElement("video"); vid.controls = true; vid.playsInline = true; vid.className = "dec-scene-media";
        if (found.key) urlFor(found.key).then(u => { if (u) vid.src = u; }); else if (found.url) vid.src = found.url;
        frame.appendChild(vid);
      } else {
        const im = document.createElement("img"); im.alt = found.sceneName || word; im.className = "dec-scene-media";
        if (found.key) urlFor(found.key).then(u => { if (u) im.src = u; }); else if (found.url) im.src = found.url;
        frame.appendChild(im);
        if (found.hotspot) {
          const h = found.hotspot, mark = el("div", "dec-scene-hot");
          mark.style.cssText = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
          frame.appendChild(mark);
        }
      }
      sceneBox.appendChild(frame);
      sceneBox.scrollIntoView({ block: "nearest" });
    }
    function highlight(idx) { wspans.forEach((s, i) => s.classList.toggle("on", i === idx)); chipEls.forEach((c, i) => c.classList.toggle("on", i === idx)); }
    function clearHi() { wspans.forEach(s => s.classList.remove("on")); chipEls.forEach(c => c.classList.remove("on")); }
    // Voix VIVE du décodeur : plus d'énergie (rate/pitch = multiplicateurs des réglages).
    const DEC_VOICE = { lang: "fr-FR", rate: 1.08, pitch: 1.22 };
    // ABC : on utilise SYSTÉMATIQUEMENT Piper (voix locale neuronale = sons nets, pas de
    // « nesiu/yu »). Repli sur la voix du système seulement si aucun modèle Piper n'est installé.
    function dsay(text, extra) {
      const o = Object.assign({}, DEC_VOICE, extra || {});
      if (window.decPiperReady && window.decPiperReady() && window.speakPiperP) {
        return window.speakPiperP(text, { rate: o.rate }).catch(() => (window.speakP ? window.speakP(text, o) : (typeof speak === "function" && speak(text, "fr-FR"), Promise.resolve())));
      }
      if (window.speakP) return window.speakP(text, o);
      if (typeof speak === "function") speak(text, "fr-FR");
      return Promise.resolve();
    }
    // Picto + légende du MOT-REPÈRE (mode « Mots-repères »).
    async function showKeyAnchor(s) {
      if (!keyBox) return;
      keyBox.innerHTML = "";
      if (!s.anchor) { keyBox.hidden = true; return; }
      keyBox.hidden = false;
      keyBox.appendChild(el("div", "dec-key-cap", "comme " + s.anchor));
      try {
        const m = await ensureSymMap();
        const sp = symFor(m, s.anchor);
        if (sp && !keyBox.hidden) { const im = document.createElement("img"); im.className = "dec-key-img"; im.src = sp; im.alt = ""; keyBox.insertBefore(im, keyBox.firstChild); }
      } catch (e) { /* pas de picto : légende seule */ }
    }
    // Mode « Mots-repères » : on dit un VRAI mot (jamais un son isolé synthétisé).
    function playAnchor(idx) { highlight(idx); const s = segs[idx]; if (s.anchor) { showKeyAnchor(s); dsay(s.anchor); } }
    // Surligne les graphèmes d'une syllabe (mode « Syllabes »).
    function highlightSyl(sy) { const set = new Set(sy.map(g => segs.indexOf(g))); wspans.forEach((w, i) => w.classList.toggle("on", set.has(i))); chipEls.forEach(c => c.classList.remove("on")); }
    function sayWord(rate) { clearHi(); if (keyBox) { keyBox.hidden = true; keyBox.innerHTML = ""; } return dsay(word, { rate: rate != null ? rate : 1, pitch: 1.12 }); }
    // FUSION = un ENREGISTREMENT CONTINU réel de la voix choisie (jamais fabriqué par concaténation :
    // coller des sons isolés ne fait pas un mot — co-articulation). L'animation surligne les
    // graphèmes PENDANT la lecture de cet unique fichier. Sinon : pas de fausse fusion → mot entier.
    async function playFusion() {
      const my = ++_decRun; clearHi(); if (keyBox) { keyBox.hidden = true; keyBox.innerHTML = ""; }
      const blob = await getMedia(_blendKey(word));
      if (blob) {                                    // vraie fusion continue enregistrée
        const a = new Audio(URL.createObjectURL(blob));
        a.onended = () => { URL.revokeObjectURL(a.src); if (my === _decRun) clearHi(); };
        const startHi = () => { const dur = (a.duration && isFinite(a.duration)) ? a.duration * 1000 : segs.length * 350; segs.forEach((s, i) => setTimeout(() => { if (my === _decRun) highlight(i); }, dur * i / Math.max(1, segs.length))); };
        if (a.readyState >= 1) startHi(); else a.onloadedmetadata = startHi;
        a.play().catch(() => {});
        return;
      }
      // Pas de fusion continue enregistrée : on NE joue PAS le mot comme si c'était une fusion.
      // Deux actions DISTINCTES : enregistrer la fusion, ou écouter le mot entier.
      clearHi();
      const { card, close } = modal("Fusion non enregistrée");
      card.appendChild(el("p", "hint", "La fusion continue de « " + word + " » (les sons reliés d'une seule traite, « ssssaaa… sac ») n'a pas encore été enregistrée par « " + _curVoiceName() + " »."));
      const row = el("div"); row.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px";
      const rec = el("button", "btn-primary", "🎤 Enregistrer la fusion");
      rec.addEventListener("click", () => { close(); openAudioRecorder(_blendKey(word), "Fusion continue de « " + word + " » — " + _curVoiceName(), "Dis « " + word + " » d'UNE SEULE traite en reliant les sons, sans t'arrêter. Prolonge les sons qui filent (f, l, m, n, s) ; n'ajoute PAS de « euh » aux sons courts (p, t, k, b, d, g). 100 % local.", () => {}); });
      const wbtn = el("button", "btn-secondary", "🔊 Écouter le mot entier");
      wbtn.addEventListener("click", async () => { close(); wspans.forEach(s => s.classList.add("on")); const h = await playWordAudio(word); if (!h) await sayWord(0.95); setTimeout(clearHi, 1200); });
      row.append(rec, wbtn); card.appendChild(row);
    }
    // « Écouter » — que des VRAIS sons (mot, syllabes, mots-repères). Jamais un phonème isolé synthétisé.
    async function playAll() {
      const my = ++_decRun; clearHi(); if (keyBox) { keyBox.hidden = true; keyBox.innerHTML = ""; }
      await sayWord(1); if (my !== _decRun) return;                    // le mot entier
      if (decMode === "syll") {                                        // syllabe par syllabe (vrais sons)
        for (const sy of syls) { if (my !== _decRun) return; highlightSyl(sy); await dsay(sy.map(g => g.g).join("")); if (my !== _decRun) return; await _decSleep(220); }
      } else if (decMode === "anchor") {                              // chaque mot-repère (vrai mot)
        for (let i = 0; i < segs.length; i++) { if (my !== _decRun) return; const s = segs[i]; if (!s.anchor) continue; highlight(i); showKeyAnchor(s); await dsay(s.anchor); if (my !== _decRun) return; await _decSleep(220); }
      } else {                                                         // « Graphèmes et sons » : sons HUMAINS de la banque (rien si absent)
        for (let i = 0; i < segs.length; i++) { if (my !== _decRun) return; highlight(i); if (segs[i].ph !== "∅") await playPhonemeAudio(segs[i].ph); await _decSleep(500); }
      }
      if (my !== _decRun) return;
      if (keyBox) { keyBox.hidden = true; keyBox.innerHTML = ""; }
      chipEls.forEach(c => c.classList.add("fuse")); wspans.forEach(s => s.classList.add("on"));
      await _decSleep(450); if (my !== _decRun) return;
      chipEls.forEach(c => c.classList.remove("fuse")); clearHi();
      await sayWord(0.6);                                              // recomposition : le mot entier, plus lent
    }
    renderChips();   // affichage initial selon le mode mémorisé
  }

  function commTile(it, closeHost) {
    const b = el("button", "tile"); b.dataset.color = colorFor(it) || it.color || "petit";
    if (it.sym_path) { const im = document.createElement("img"); im.src = it.sym_path; im.alt = ""; b.appendChild(im); }
    else b.classList.add("word-tile");
    b.appendChild(el("span", "tile-label" + (window.labelClass ? labelClass(labelFor(it)) : ""), labelFor(it)));
    decorateTile(b, it, {});
    b.addEventListener("click", () => { addToken(it); if (closeHost) closeHost.hidden = true; });
    return b;
  }

  /* ---------- Scènes visuelles (VSD) ---------- */
  function ensureScenes() { if (!Array.isArray(state.overrides.scenes)) state.overrides.scenes = []; }
  let openSceneId = null, scenePlacing = false;

  /* ---------- Encart de modélisation pour le PARTENAIRE (Light & McNaughton) ---------- */
  const PARTNER_TIPS = [
    ["👉", "Modéliser", "Pointez les cases en parlant. La personne observe et absorbe — nul besoin qu'elle produise en retour."],
    ["⏳", "Laisser du temps", "Attendez, vraiment. Le temps de traitement peut être long. Ne comblez pas le silence."],
    ["💬", "Répondre à tout", "Un regard, un geste, une case : toute tentative est une communication. Répondez-y."],
    ["🎲", "Répéter dans le plaisir", "Le vocabulaire s'ancre dans le jeu et le quotidien agréable, pas dans le test."],
    ["🔓", "Garder l'accès ouvert", "L'outil reste disponible tout le temps, pas seulement « à l'heure de la CAA »."],
    ["🫶", "Honorer toutes les voix", "La communication est multimodale : le regard, le geste, le corps comptent autant que l'appli."],
  ];
  function openPartnerTips() {
    const { card } = modal("Pour le partenaire");
    card.appendChild(el("p", "hint", "Ce qui fait grandir une voix, c'est la relation. Six repères fondés sur la recherche (Light & McNaughton ; Biggs, Carter & Gilson)."));
    const list = el("div", "tips-list");
    PARTNER_TIPS.forEach(([emo, t, d]) => {
      const c = el("div", "tip");
      c.appendChild(el("div", "tip-emo", emo));
      const b = el("div", "tip-body");
      b.appendChild(el("strong", null, t)); b.appendChild(el("p", null, d));
      c.appendChild(b); list.appendChild(c);
    });
    card.appendChild(list);
    const act = el("div", "modal-actions");
    const bm = el("button", "btn-primary", "🖐️ Activer le mode modélisation");
    bm.title = "Montrer les mots en les touchant, sans les ajouter à la phrase";
    bm.addEventListener("click", () => { if (window.setModeling) { const host = document.getElementById("ct-modal"); if (host) host.hidden = true; setModeling(true); } });
    act.appendChild(bm); card.appendChild(act);
    card.appendChild(el("p", "hint", "Pointez et dites les mots pour montrer l'exemple, sans écrire à la place de la personne."));
  }
  // ---- Regard sur mes scènes (multiculturalisme, audit §9.3) : des QUESTIONS pour aider la famille
  // à vérifier SA propre représentation. Jamais un score, jamais des « packs culturels » tout faits :
  // les meilleures scènes sont co-construites avec les personnes concernées. ----
  const SCENE_CHECK = [
    ["Qui apparaît ?", "Retrouve-t-on les vraies personnes de la vie : familles de formes différentes, âges variés, couleurs de peau et de cheveux, corps et handicaps divers ?"],
    ["Qui agit ?", "La personne qui utilise l'outil est-elle montrée comme ACTRICE (elle joue, choisit, décide) — pas seulement comme quelqu'un qu'on aide ?"],
    ["Qui parle, qui aide qui ?", "Les rôles (femmes, hommes, enfants, aîné·es) sont-ils variés, ou toujours les mêmes ?"],
    ["Qu'est-ce qui est « ordinaire » ?", "Quels lieux, repas, fêtes, coiffures, vêtements, croyances de TA famille sont présents — et lesquels manquent ?"],
    ["De la place pour tout ?", "Peut-on parler aussi de ce qui compte vraiment : grandes émotions, désaccord, intimité, ce qui fait de la peine ?"],
  ];
  function openSceneCheck() {
    const { card } = modal("Regard sur mes scènes");
    card.appendChild(el("p", "hint", "Ce ne sont pas des règles ni une note — juste des questions pour que tes scènes ressemblent à ta vie et à celles des personnes que tu accompagnes. Les meilleures scènes se construisent AVEC les personnes concernées, à partir de vraies photos — jamais avec des « décors culturels » tout faits."));
    const list = el("div", "tips-list");
    SCENE_CHECK.forEach(([t, d]) => {
      const c = el("div", "tip");
      const b = el("div", "tip-body");
      b.appendChild(el("strong", null, t)); b.appendChild(el("p", null, d));
      c.appendChild(b); list.appendChild(c);
    });
    card.appendChild(list);
    const act = el("div", "modal-actions");
    const ba = el("button", "btn-primary", "📷 Ajouter une scène de ma vie");
    ba.addEventListener("click", () => { const host = document.getElementById("ct-modal"); if (host) host.hidden = true; addScene(); });
    act.appendChild(ba); card.appendChild(act);
  }
  // ---- Ajouter une LISTE de mots qui se range DANS LA GRILLE (pas juste le dico) ----
  // Un mot par ligne → chaque mot reçoit son picto Mulberry (ou une bulle) ET atterrit dans une
  // catégorie de la grille (overrides.added), donc visible et utilisable. Réversible (Annuler).
  async function openBulkAdd() {
    try { await ensureSymMap(); } catch (e) {}   // charge la carte des pictos pour pictoFor()
    const { card } = modal("Ajouter une liste de mots");
    card.appendChild(el("p", "hint", "Un mot par ligne. Chaque mot reçoit son picto (ou une bulle de parole) et se range DANS la grille, dans la catégorie choisie — jamais seulement dans le dictionnaire."));
    const ta = document.createElement("textarea"); ta.rows = 8;
    ta.placeholder = "un mot par ligne…\nexemple :\ncousin\nvoisine\ndoudou";
    ta.style.cssText = "width:100%;box-sizing:border-box;min-height:150px;font-size:1rem;padding:10px 12px;border:2px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);font-family:inherit;resize:vertical";
    card.appendChild(ta);
    const row = el("div", "row"); row.style.cssText = "border:0;flex-wrap:wrap;gap:8px;align-items:center;margin-top:8px";
    row.appendChild(el("label", null, "Ranger dans :"));
    const sel = document.createElement("select");
    sel.style.cssText = "flex:1;min-width:180px;min-height:44px;font-size:1rem;padding:6px 10px;border:2px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink)";
    sel.appendChild(new Option("Accueil", "home"));
    (state.vocab.functions || []).forEach(f => sel.appendChild(new Option(f.label, f.id)));
    (state.vocab.themes || []).forEach(t => sel.appendChild(new Option(t.label, t.id)));
    // pré-sélectionne la catégorie ouverte, si on est dedans
    if (state.board && state.board !== "home" && [...sel.options].some(o => o.value === state.board)) sel.value = state.board;
    row.appendChild(sel); card.appendChild(row);
    const act = el("div", "modal-actions");
    const add = el("button", "btn-primary", "Ajouter à la grille");
    add.addEventListener("click", () => {
      const words = [...new Set(ta.value.split(/[\n,;]+/).map(w => w.trim()).filter(Boolean))];
      if (!words.length) return;
      const dest = sel.value;
      const board = dest === "home" ? null : findBoard(dest);
      const color = board ? (board.color || "nom") : "nom";
      const arr = (state.overrides.added[dest] ||= []);
      const addedItems = [];
      words.forEach((w, i) => {
        const sym = pictoFor(w) || textSVG(w);   // Mulberry si connu, sinon bulle de parole
        const it = { id: "u_" + Date.now() + "_" + i, label: w, color: color, sym_path: sym };
        arr.push(it); addedItems.push(it);
      });
      saveOverrides();
      const host = document.getElementById("ct-modal"); if (host) host.hidden = true;
      if (typeof refresh === "function") refresh();
      if (window.toast) toast(addedItems.length + " mot(s) rangé(s) dans « " + (board ? board.label : "Accueil") + " ».", null, "＋",
        { label: "↺ Annuler", fn: () => { const a = state.overrides.added[dest] || []; addedItems.forEach(it => { const j = a.indexOf(it); if (j >= 0) a.splice(j, 1); }); saveOverrides(); if (typeof refresh === "function") refresh(); } });
    });
    act.appendChild(add); card.appendChild(act);
  }
  // ---- « Mes mots » : statistiques d'usage LOCALES, visuelles, pour la personne ----
  function openStats() {
    const { card } = modal("📊 Mes mots");
    card.appendChild(el("p", "hint", "Les mots que tu utilises le plus, rien qu'à toi. Tout reste sur l'appareil."));
    const usage = state.overrides.usage || {};
    const gc = (it) => (typeof colorFor === "function" ? colorFor(it) : it.color) || "petit";
    const gl = (it) => (typeof labelFor === "function" ? labelFor(it) : it.label);
    const entries = Object.keys(usage)
      .map(id => ({ it: (typeof itemById === "function" ? itemById(id) : null), n: usage[id] && usage[id].n }))
      .filter(x => x.it && x.n > 0)
      .sort((a, b) => b.n - a.n);
    if (!entries.length) {
      card.appendChild(el("p", "hint", "Rien pour l'instant 🌱 Dès que tu composes des messages, tes mots préférés apparaîtront ici, du plus grand au plus petit."));
      return;
    }
    // Nuage : la taille du picto grandit avec l'usage (visuel, pas de chiffres bruts)
    const top = entries.slice(0, 30), maxN = top[0].n;
    const cloud = el("div", "stats-cloud");
    top.forEach(({ it, n }) => {
      const scale = 0.75 + 0.85 * (n / maxN);
      const w = el("button", "stats-word"); w.dataset.color = gc(it);
      w.title = n + (n > 1 ? " fois" : " fois");
      if (it.sym_path) { const im = el("img"); im.src = it.sym_path; im.alt = ""; im.style.width = im.style.height = Math.round(38 * scale) + "px"; w.appendChild(im); }
      w.appendChild(el("span", "sw-label", gl(it)));
      w.addEventListener("click", () => { if (typeof speakOut === "function") speakOut(gl(it)); });
      cloud.appendChild(w);
    });
    card.appendChild(cloud);
    // Familles de mots utilisées (code Fitzgerald) — on célèbre la VARIÉTÉ (Van Tatenhove)
    const FAM = { pronom: "Personnes", verbe: "Actions", descriptif: "Descriptifs", nom: "Noms",
                  social: "Social", question: "Questions", petit: "Petits mots", negation: "Refus", advocacy: "Ma voix" };
    const fams = {}; entries.forEach(({ it, n }) => { const c = gc(it); fams[c] = (fams[c] || 0) + n; });
    const nfam = Object.keys(fams).filter(c => FAM[c]).length;
    card.appendChild(el("h4", "stats-fam-title", `Tu utilises ${nfam} famille${nfam > 1 ? "s" : ""} de mots — belle variété !`));
    const fl = el("div", "fam-legend");
    Object.keys(FAM).forEach(c => { if (fams[c]) { const d = el("span", "fam-chip"); d.dataset.color = c; d.textContent = FAM[c]; fl.appendChild(d); } });
    card.appendChild(fl);
    // Imprimer la liste illustrée (picto + mot, du plus utilisé au moins utilisé ; sans chiffres bruts)
    const printBtn = el("button", "btn-secondary", "🖨️ Imprimer mes mots préférés");
    printBtn.style.marginRight = "8px";
    printBtn.addEventListener("click", async () => {
      const cells = [];
      for (const { it } of top) {
        let src = it.sym_path || "";
        if (src && src.indexOf("data:") !== 0) {
          try { const abs = new URL(src, location.href).href; src = await blobUrlToDataURL(abs); } catch (e) { /* garde l'original */ }
        }
        const img = src ? `<img src="${src}" alt="">` : "";
        cells.push(`<div class="cell"><span>${img}</span><span class="cl">${esc(gl(it))}</span></div>`);
      }
      const html = "<h1>Mes mots préférés</h1><p class='sub'>Les mots que j'utilise le plus</p>"
        + `<div class="grid" style="--c:5">${cells.join("")}</div>`;
      printDoc("Mes mots préférés", html);
    });
    card.appendChild(printBtn);
    const reset = el("button", "linklike", "Effacer mes statistiques");
    reset.addEventListener("click", () => { state.overrides.usage = {}; state.overrides.usageSeq = 0; if (typeof saveOverrides === "function") saveOverrides(); openStats(); });
    card.appendChild(reset);
  }
  // ---- Packs pan-francophones : la personne/famille choisit ses univers ----
  function openPacks() {
    const { card } = modal("🌍 Régions & cultures");
    card.appendChild(el("p", "hint", "Ta langue, tes mots. Active les univers qui te parlent — plusieurs à la fois, c'est permis. Rien n'est imposé."));
    const packs = state.vocab.packs || [];
    const fams = {};
    packs.forEach(p => (fams[p.family] = fams[p.family] || []).push(p));
    const order = ["Socle", "France & régions", "Europe francophone", "Canada francophone",
                   "Caraïbes & Amériques", "Afrique & Maghreb", "Océan Indien & Pacifique"];
    const names = Object.keys(fams).sort((a, b) => ((order.indexOf(a) + 1) || 99) - ((order.indexOf(b) + 1) || 99));
    names.forEach(fam => {
      card.appendChild(el("h4", "packs-fam", fam));
      const grid = el("div", "packs-grid");
      fams[fam].forEach(p => {
        const lab = el("label", "pack-chip");
        const cb = document.createElement("input"); cb.type = "checkbox";
        cb.checked = (state.settings.packs || []).includes(p.id);
        cb.addEventListener("change", () => {
          const set = new Set(state.settings.packs || []);
          cb.checked ? set.add(p.id) : set.delete(p.id);
          state.settings.packs = [...set];
          if (typeof saveSettings === "function") saveSettings();
          if (typeof renderTabs === "function") renderTabs();
        });
        lab.appendChild(cb);
        lab.appendChild(el("span", null, p.label + "  ·  " + p.items.length));
        grid.appendChild(lab);
      });
      card.appendChild(grid);
    });
    const vb = el("button", "linklike", "🔤 Choisir mes variantes régionales (chocolatine / pain au chocolat…)");
    vb.addEventListener("click", openVariants);
    card.appendChild(vb);
  }
  // ---- Variante préférée par concept : « ton mot » devient celui affiché/dit ----
  function openVariants() {
    const { card } = modal("🔤 Mes variantes régionales");
    card.appendChild(el("p", "hint", "Choisis TON mot pour chaque idée : c'est lui qui s'affiche et se dit dans « Variantes régionales ». Chocolatine ou pain au chocolat ? Natel ou portable ? À toi de décider."));
    (state.vocab.synonyms || []).forEach(s => {
      const row = el("div", "var-row");
      row.appendChild(el("div", "var-concept", s.label));
      const opts = el("div", "var-opts");
      const chosen = ((state.settings.variants || {})[s.id]) || s.variants[0].label;
      s.variants.forEach(v => {
        const btn = el("button", "var-opt" + (v.label === chosen ? " is-chosen" : ""), v.label);
        btn.addEventListener("click", () => {
          state.settings.variants = state.settings.variants || {};
          state.settings.variants[s.id] = v.label;
          if (typeof saveSettings === "function") saveSettings();
          if (typeof speakOut === "function") speakOut(v.say || v.label);
          if (typeof renderTabs === "function") renderTabs();
          if (state.board === "__variantes" && typeof renderBoard === "function") renderBoard();
          openVariants();
        });
        opts.appendChild(btn);
      });
      row.appendChild(opts); card.appendChild(row);
    });
  }
  /* ---------- Compagnon de modélisation (routines tout-petits) ----------
     Aide l'ADULTE à montrer des mots-noyau dans les VRAIES routines, par répétition de qualité
     (immersion / aided language input). Ce n'est PAS un exercice pour l'enfant : rien n'est testé,
     rien n'est noté sur lui. Chaque mot touché est MONTRÉ (voix du partenaire). Un compteur LOCAL
     note ce que l'enfant a ENTENDU/VU (jamais ce qu'il « réussit »), remisable. 100 % sur l'appareil.
     Mots-noyau uniquement (Van Tatenhove/Baker) : chaque mot a un vrai picto (jamais de case sans picto). */
  const MODEL_ROUTINES = [
    { id: "repas", name: "Repas / biberon",
      words: ["encore", "fini", "manger", "boire", "aimer", "aider"],
      tips: ["Montre « encore » quand tu redonnes une cuillère.", "Montre « fini » quand le repas se termine."] },
    { id: "bain", name: "Bain / soins",
      words: ["chaud", "froid", "laver", "regarder", "encore", "fini"],
      tips: ["Montre « chaud » et « froid » en touchant l'eau.", "Montre « laver » pendant que tu laves."] },
    { id: "jeu", name: "Jeu / câlin",
      words: ["encore", "donner", "regarder", "aimer", "tourner", "arrêter"],
      tips: ["Montre « encore » avant de relancer le jeu.", "Montre « donner » quand vous vous passez le jouet."] },
    { id: "dodo", name: "Dodo / calme",
      words: ["fatigué", "dormir", "fini", "aimer", "tenir", "non"],
      tips: ["Montre « fatigué » puis « dormir » au coucher.", "Reste calme : montre lentement, une fois, sans attendre de réponse."] },
  ];
  const _nzc = s => String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\s+/g, " ").trim();
  let _coachIdx = null;
  function coachIndex() {
    if (_coachIdx) return _coachIdx;
    _coachIdx = {};
    const push = i => { if (i && !_coachIdx[_nzc(i.label)]) _coachIdx[_nzc(i.label)] = i; };
    (state.vocab && state.vocab.columns || []).forEach(c => (c.items || []).forEach(push));
    if (state.vocab && state.vocab.boards) Object.values(state.vocab.boards).forEach(b => (b.items || []).forEach(push));
    return _coachIdx;
  }
  // Résout un mot en {label, say, sym} avec un PICTO GARANTI (règle : jamais de case sans picto) ; sinon null.
  function coachChip(word) {
    const it = coachIndex()[_nzc(word)];
    let sym = it ? (window.pictoFor ? window.pictoFor(it) : it.sym_path) : null;
    if (!sym) { try { sym = pictoFor(word); } catch (e) {} }   // pictoFor local (par label) = Mulberry
    if (!sym) return null;
    return { label: it ? it.label : word, say: it ? (it.say || it.label) : word, sym };
  }
  // Journal LOCAL de modélisation : { "AAAA-MM-JJ": n } = ce que l'enfant a ENTENDU/VU (jamais un score enfant).
  function _todayStr() { return new Date().toISOString().slice(0, 10); }
  function logModel() {
    const ov = state.overrides; if (!ov.modelLog) ov.modelLog = {};
    const k = _todayStr(); ov.modelLog[k] = (ov.modelLog[k] || 0) + 1; saveOverrides();
  }
  function modelTotals() {
    const log = (state.overrides && state.overrides.modelLog) || {};
    const now = new Date(); let week = 0;
    for (let i = 0; i < 7; i++) { const d = new Date(now); d.setDate(now.getDate() - i); week += log[d.toISOString().slice(0, 10)] || 0; }
    return { today: log[_todayStr()] || 0, week };
  }
  let _coachRoutine = "repas";
  function openModelingCoach() {
    let host = document.getElementById("ct-modelcoach");
    if (!host) { host = el("div", "popup"); host.id = "ct-modelcoach"; document.body.appendChild(host); }
    host.hidden = false; host.innerHTML = "";
    const inner = el("div", "popup-inner coach-inner");
    const head = el("div", "popup-head");
    head.appendChild(el("strong", null, "Routines de modélisation"));
    head.appendChild(closeX(() => host.hidden = true));
    inner.appendChild(head);
    inner.appendChild(el("p", "hint", "Pour VOUS aider à montrer des mots dans les vraies routines. Ce n'est pas un exercice pour l'enfant : rien n'est testé. L'enfant apprend en vous voyant faire, encore et encore."));
    const tabs = el("div", "coach-tabs");
    MODEL_ROUTINES.forEach(r => {
      const b = el("button", "coach-tab" + (r.id === _coachRoutine ? " is-on" : ""), r.name);
      b.type = "button"; b.setAttribute("aria-pressed", String(r.id === _coachRoutine));
      b.addEventListener("click", () => { _coachRoutine = r.id; openModelingCoach(); });
      tabs.appendChild(b);
    });
    inner.appendChild(tabs);
    const routine = MODEL_ROUTINES.find(r => r.id === _coachRoutine) || MODEL_ROUTINES[0];
    inner.appendChild(el("p", "coach-tip", "Conseil : " + routine.tips[0]));
    const grid = el("div", "coach-grid");
    routine.words.forEach(w => {
      const chip = coachChip(w); if (!chip) return;   // jamais de case sans picto
      const b = el("button", "coach-chip"); b.type = "button";
      const im = document.createElement("img"); im.src = chip.sym; im.alt = ""; b.appendChild(im);
      b.appendChild(el("span", "coach-chip-lab", chip.label));
      b.setAttribute("aria-label", "Montrer « " + chip.label + " »");
      b.addEventListener("click", () => {
        if (window.speak) window.speak(chip.say, "fr-FR", { modeling: true });   // voix du partenaire (modélisation)
        b.classList.remove("coach-pulse"); void b.offsetWidth; b.classList.add("coach-pulse");
        setTimeout(() => b.classList.remove("coach-pulse"), 480);
        logModel(); updateCount();
      });
      grid.appendChild(b);
    });
    inner.appendChild(grid);
    const foot = el("div", "coach-foot");
    const cnt = el("p", "coach-count"); foot.appendChild(cnt);
    const reset = el("button", "linklike", "Réinitialiser le décompte");
    reset.addEventListener("click", async () => {
      if (await ctConfirm("Remettre le décompte de modélisation à zéro ?", "", { ok: "Réinitialiser", cancel: "Annuler" })) { state.overrides.modelLog = {}; saveOverrides(); updateCount(); }
    });
    foot.appendChild(reset);
    foot.appendChild(el("p", "hint", "Ce décompte reste sur l'appareil et compte ce que VOUS avez montré (jamais ce que l'enfant « réussit »). Ferme pour revenir à la grille complète — elle reste toujours disponible."));
    inner.appendChild(foot);
    function updateCount() { const t = modelTotals(); cnt.textContent = "Montré : " + t.today + " aujourd'hui · " + t.week + " cette semaine."; }
    updateCount();
    host.appendChild(inner);
  }

  function renderScenes() {
    ensureScenes();
    const host = document.getElementById("scenes"); host.innerHTML = "";
    const scene = state.overrides.scenes.find(s => s.id === openSceneId);
    if (!scene) {
      const head = el("div", "scene-toolbar");
      head.appendChild(el("strong", null, "🖼️ Scènes visuelles"));
      const add = el("button", "btn-primary", "📷 Ajouter une photo ou une vidéo");
      add.addEventListener("click", addScene);
      head.appendChild(add);
      host.appendChild(head);
      host.appendChild(el("p", "hint", "Une photo ou une vidéo d'un vrai moment, avec des zones qui parlent. Le chemin le plus facile vers les premiers mots."));
      const tips = el("button", "linklike", "💡 Conseils au partenaire");
      tips.addEventListener("click", openPartnerTips);
      host.appendChild(tips);
      const coach = el("button", "linklike", "🖐️ Routines de modélisation (tout-petits)");
      coach.title = "Des mots-noyau à montrer dans les routines (repas, bain, jeu, dodo)";
      coach.addEventListener("click", openModelingCoach);
      host.appendChild(coach);
      const find = el("button", "linklike", "🔎 Chercher un mot (jeu)");
      find.title = "Apprendre où sont les mots dans la grille, avec des indices — jamais un test, on arrête quand on veut";
      find.addEventListener("click", () => { if (window.startFindGame) window.startFindGame(); });
      host.appendChild(find);
      const rep = el("button", "linklike", "👀 Regard sur mes scènes");
      rep.title = "Des questions pour vérifier que tes scènes représentent bien ta vie et celles des personnes que tu accompagnes";
      rep.addEventListener("click", openSceneCheck);
      host.appendChild(rep);
      if (!state.overrides.scenes.length) {
        const es = el("div", "empty-state");
        es.appendChild(el("div", "es-emoji", "🖼️"));
        es.appendChild(el("p", null, "Aucune scène pour l'instant. Touchez « 📷 Ajouter une photo ou une vidéo » ci-dessus — puis touchez l'image pour créer des zones qui parlent."));
        host.appendChild(es);
      }
      const list = el("div", "scenes-list");
      state.overrides.scenes.forEach(s => {
        const card = el("div", "scene-card");
        const thumb = sceneMediaEl(s, { controls: false });
        card.appendChild(thumb);
        if (s.kind === "video") card.appendChild(el("span", "scene-badge", "🎬"));
        card.appendChild(el("div", "scene-name", s.name));
        // Retirer la scène (photo + zones) — RÉVERSIBLE : la scène et son image partent en corbeille, annulable.
        const del = el("button", "scene-del", "🗑️");
        del.title = "Retirer cette scène"; del.setAttribute("aria-label", "Retirer la scène " + s.name);
        del.addEventListener("click", async (ev) => {
          ev.stopPropagation();
          if (!await ctConfirm(`Retirer la scène « ${s.name} » et ses zones ? Vous pourrez annuler juste après.`, "", { ok: "Retirer", cancel: "Annuler" })) return;
          const snapScene = s, snapIdx = state.overrides.scenes.indexOf(s);
          if (snapIdx >= 0) state.overrides.scenes.splice(snapIdx, 1);
          if (s.imgKey) { urlCache.delete(s.imgKey); await delMedia(s.imgKey); }
          if (openSceneId === s.id) openSceneId = null;
          saveOverrides(); renderScenes();
          if (window.toast) toast("Scène retirée.", null, "🗑️", { label: "↺ Annuler", fn: async () => {
            if (snapScene.imgKey) await restoreMedia(snapScene.imgKey);
            const arr = state.overrides.scenes; arr.splice(Math.max(0, Math.min(snapIdx, arr.length)), 0, snapScene);
            saveOverrides(); renderScenes(); toast("Scène restaurée.", null, "↺");
          } });
        });
        card.appendChild(del);
        card.addEventListener("click", () => { openSceneId = s.id; scenePlacing = false; renderScenes(); });
        list.appendChild(card);
      });
      host.appendChild(list);
      return;
    }
    // Scène ouverte
    const bar = el("div", "scene-toolbar");
    const back = el("button", "btn-secondary", "‹ Toutes les scènes");
    back.addEventListener("click", () => { openSceneId = null; scenePlacing = false; renderScenes(); });
    const addZone = el("button", "btn-secondary" + (scenePlacing ? " is-on" : ""), scenePlacing ? "✓ En cours…" : "＋ Zone qui parle");
    addZone.setAttribute("aria-pressed", String(scenePlacing));
    addZone.addEventListener("click", () => { scenePlacing = !scenePlacing; renderScenes(); });
    bar.append(back, el("strong", null, scene.name), addZone);
    host.appendChild(bar);
    if (scenePlacing) host.appendChild(el("p", "hint", "Touchez la photo à l'endroit voulu pour créer une zone qui parle. Touchez une zone existante pour la retirer."));
    const editMode = scenePlacing || state.editing;
    const stage = el("div", "scene-stage" + (editMode ? " placing" : ""));
    const media = sceneMediaEl(scene, { controls: scene.kind === "video" });
    stage.appendChild(media);
    (scene.hotspots || []).forEach(h => {
      const hs = el("button", "hotspot"); hs.setAttribute("aria-label", h.label);
      hs.style.cssText += `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
      hs.addEventListener("click", async (ev) => {
        ev.stopPropagation();
        if (editMode) {
          if (await ctConfirm(`Retirer la zone « ${h.label} » ?`, "", { ok: "Retirer", cancel: "Annuler", danger: true })) { scene.hotspots.splice(scene.hotspots.indexOf(h), 1); saveOverrides(); renderScenes(); }
          return;
        }
        addToken({ id: "sc_" + Date.now(), label: h.label, say: h.say, color: "nom", sym_path: null });
      });
      stage.appendChild(hs);
    });
    // Création de zone en un geste (mode « ＋ Zone qui parle », sans passer par Modifier)
    stage.addEventListener("click", async (ev) => {
      if (!editMode || ev.target !== media) return;
      const r = media.getBoundingClientRect();
      const x = ((ev.clientX - r.left) / r.width) * 100, y = ((ev.clientY - r.top) / r.height) * 100;
      const label = await ctPrompt("Mot ou phrase de cette zone :"); if (!label) return;
      scene.hotspots.push({ x: Math.max(0, x - 8), y: Math.max(0, y - 6), w: 16, h: 12, label: label.trim() });
      saveOverrides(); renderScenes();
    });
    host.appendChild(stage);
  }
  function addScene() {
    ensureScenes();
    const inp = Object.assign(document.createElement("input"), { type: "file", accept: "image/*,video/*" });
    inp.addEventListener("change", async () => {
      const f = inp.files[0]; if (!f) return;
      const name = await ctPrompt("Nom de la scène (ex. « le goûter ») :", "Ma scène") || "Ma scène";
      const id = "scene_" + Date.now(), key = "s_" + id;
      await putMedia(key, f); urlCache.delete(key);
      state.overrides.scenes.push({ id, name, imgKey: key, kind: f.type.startsWith("video") ? "video" : "image", hotspots: [] });
      saveOverrides(); openSceneId = id; scenePlacing = true; renderScenes();  // just-in-time : on peut poser des zones tout de suite
    });
    inp.click();
  }
  // Élément média d'une scène : <video> si la scène est une vidéo, sinon <img>.
  function sceneMediaEl(scene, opts) {
    opts = opts || {};
    const isVid = scene.kind === "video";
    const m = document.createElement(isVid ? "video" : "img");
    m.alt = scene.name || "";
    if (isVid) { m.playsInline = true; m.preload = "metadata"; if (opts.controls) m.controls = true; else { m.muted = true; } }
    if (scene.imgKey) urlFor(scene.imgKey).then(u => { if (u) m.src = u; });
    return m;
  }

  /* ---------- Spirale adaptative (fréquence + récence) ---------- */
  function allWords() {
    const pool = [];
    (state.vocab.columns || []).forEach(c => (c.items || []).forEach(it => pool.push(it)));
    (state.vocab.functions || []).forEach(f => (f.items || []).forEach(it => pool.push(it)));
    (state.vocab.themes || []).forEach(f => (f.items || []).forEach(it => pool.push(it)));
    Object.keys(state.overrides.added || {}).forEach(b => {
      if (b !== "advocacy") (state.overrides.added[b] || []).forEach(it => pool.push(it));
    });
    const seen = new Set();
    return pool.filter(it => it.id && !seen.has(it.id) && seen.add(it.id) && !state.overrides.hidden.includes(it.id));
  }

  /* ---------- Recherche dans tout le dictionnaire (4000+ mots) ---------- */
  let DICT = null;
  async function loadDict() {
    if (DICT) return DICT;
    try { DICT = await (await fetch("dictionary.json?v=" + (window.BUILD || "13"))).json(); }
    catch { DICT = []; }
    return DICT;
  }
  function normalize(s) {
    return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
  }
  // Prédiction : mots du dictionnaire commençant par le fragment (insensible aux accents).
  function predict(frag, max = 8) {
    if (!DICT || !frag) return [];
    const nf = normalize(frag);
    const out = [], seen = new Set();
    for (const d of DICT) {
      if (/\d/.test(d.label)) continue;                    // écarte le bruit (« bone 2 »…)
      const nl = normalize(d.label);
      if (nl.startsWith(nf) && !seen.has(nl)) { seen.add(nl); out.push(d.label); if (out.length >= max) break; }
    }
    // mots simples d'abord, puis expressions
    out.sort((a, b) => a.split(" ").length - b.split(" ").length || a.length - b.length);
    return out;
  }
  // ---- RECHERCHE = GUIDE DE CHEMIN MOTEUR (bandeau bas, ne cache PAS la grille) ----
  // Au lieu de téléporter la personne sur le mot, on lui fait FAIRE le trajet elle-même,
  // étape par étape, la grille restant visible : Accueil → catégorie → mot. La main apprend
  // le chemin (Baker : automatisation motrice). Un bouton « y aller directement » reste en secours.
  let _guide = null, _guideObs = null, _guideTimer = null;
  function psClearHighlight() { document.querySelectorAll(".guide-target").forEach(e => e.classList.remove("guide-target")); }
  function psTeardownObs() { if (_guideObs) { _guideObs.disconnect(); _guideObs = null; } if (_guideTimer) { clearTimeout(_guideTimer); _guideTimer = null; } }
  function psClose() {
    const bar = document.getElementById("path-search"); if (bar) bar.hidden = true;
    document.body.classList.remove("path-open"); psClearHighlight(); psTeardownObs(); _guide = null;
  }
  async function openSearch() {
    let bar = document.getElementById("path-search");
    if (!bar) { bar = el("div", "path-search"); bar.id = "path-search"; bar.setAttribute("role", "search"); document.body.appendChild(bar); }
    bar.hidden = false; document.body.classList.add("path-open");
    await psShowSearch(bar);
  }
  async function psShowSearch(bar) {
    psTeardownObs(); psClearHighlight(); _guide = null; bar.innerHTML = "";
    const row = el("div", "ps-row");
    const input = Object.assign(document.createElement("input"), { type: "text", placeholder: "chercher un mot…" });
    input.className = "ps-input"; input.setAttribute("aria-label", "Chercher un mot");
    row.append(input, closeX(psClose, "Fermer la recherche"));
    const results = el("div", "ps-results");
    bar.append(row, results);
    // Le dictionnaire se charge en tâche de fond ; l'écouteur est branché TOUT DE SUITE et charge
    // le dico à la volée (mis en cache) → plus de course : taper marche même pendant le chargement.
    let dict = null;
    loadDict().then(d => { dict = d; }).catch(() => {});
    const runSearch = async () => {
      if (!dict) { try { dict = await loadDict(); } catch (e) { return; } }
      const q = normalize(input.value.trim()); results.innerHTML = "";
      if (q.length < 2) return;
      const hits = dict.filter(d => d.sym_path && normalize(d.label).includes(q))
        .sort((a, b) => normalize(a.label).indexOf(q) - normalize(b.label).indexOf(q) || a.label.length - b.label.length)
        .slice(0, 24);
      if (!hits.length) { results.appendChild(el("p", "ps-hint", "Aucun mot trouvé.")); return; }
      hits.forEach(d => {
        const chip = el("button", "ps-chip");
        if (d.sym_path) { const im = document.createElement("img"); im.src = d.sym_path; im.alt = ""; chip.appendChild(im); }
        chip.appendChild(el("span", null, d.label));
        chip.onclick = () => psStartGuide(d);
        results.appendChild(chip);
      });
    };
    input.addEventListener("input", runSearch);
    setTimeout(() => input.focus(), 50);
  }
  function psStartGuide(d) {
    const loc = (typeof locateWord === "function") ? locateWord(d.label) : null;
    if (!loc) {   // mot absent de la grille : on l'ajoute directement (avec conjugaison si c'est un verbe)
      addToken({ id: d.id, label: d.label, color: d.verb ? "verbe" : "nom", sym_path: d.sym_path, verb: d.verb });
      if (window.toast) window.toast("« " + d.label + " » ajouté (pas encore de place fixe dans la grille).", null, "➕");
      return;
    }
    const steps = [{ kind: "home" }];
    if (loc.boardId !== "home") steps.push({ kind: "cat", boardId: loc.boardId, catLabel: loc.title });
    steps.push({ kind: "word" });
    _guide = { d, label: d.label, norm: normalize(d.label), boardId: loc.boardId, catLabel: loc.title, steps, i: 0 };
    psAdvancePassive();
    psRenderGuide();
    psTeardownObs();
    _guideObs = new MutationObserver(() => { if (_guideTimer) return; _guideTimer = setTimeout(() => { _guideTimer = null; psTick(); }, 70); });
    const board = document.getElementById("board"), msg = document.getElementById("message");
    if (board) _guideObs.observe(board, { childList: true, subtree: true });
    if (msg) _guideObs.observe(msg, { childList: true, subtree: true });
  }
  function psStepDone(st) {
    if (!st || !_guide) return false;
    if (st.kind === "home") return state.board === "home" && state.view === "board";
    if (st.kind === "cat") return state.board === st.boardId;
    if (st.kind === "word") return (state.tokens || []).some(t => normalize(t.label || "") === _guide.norm);
    return false;
  }
  function psAdvancePassive() {   // franchit les étapes de NAVIGATION déjà satisfaites (jamais l'étape « mot »)
    while (_guide && _guide.i < _guide.steps.length - 1 && _guide.steps[_guide.i].kind !== "word" && psStepDone(_guide.steps[_guide.i])) _guide.i++;
  }
  function psTick() {
    if (!_guide) return;
    psAdvancePassive();
    const st = _guide.steps[_guide.i];
    // Mot atteint : le mot apparaît dans le message (retour visuel suffisant) → on ferme, sans texte.
    if (st && st.kind === "word" && psStepDone(st)) { psClose(); return; }
    psRenderGuide();
  }
  function psFindWordTile() {
    return [...document.querySelectorAll("#board .tile")].find(t => { const l = t.querySelector(".tile-label"); return l && normalize(l.textContent) === _guide.norm; }) || null;
  }
  function psHighlightTarget() {
    psClearHighlight(); if (!_guide) return;
    const st = _guide.steps[_guide.i]; if (!st) return;
    // la barre de catégories doit être dépliée pour toucher l'Accueil / une catégorie
    if ((st.kind === "home" || st.kind === "cat") && state.settings && state.settings.catCollapsed) {
      state.settings.catCollapsed = false; if (typeof saveSettings === "function") saveSettings(); if (typeof applyLook === "function") applyLook();
    }
    let target = null;
    if (st.kind === "home") target = [...document.querySelectorAll("#tabs .tab")].find(t => t.dataset.cat === "home");
    else if (st.kind === "cat") target = [...document.querySelectorAll("#tabs .tab")].find(t => t.dataset.cat === st.boardId);
    else if (st.kind === "word") {
      target = psFindWordTile();
      if (!target) {
        // Mot profond (non visible) : cibler le « Tout voir » de SA colonne,
        // pas le dernier « Tout voir » de l'écran (bug : renvoyait vers Social).
        if (_guide.boardId === "home") {
          const cols = (state.vocab && state.vocab.columns) || [];
          const ci = cols.findIndex(c => (c.items || []).some(it => normalize(labelFor(it)) === _guide.norm));
          if (ci >= 0) {
            const vcols = [...document.querySelectorAll("#board .vcol")].filter(v => !v.closest("#context-zone"));
            target = (vcols[ci] && vcols[ci].querySelector(".tile-more")) || null;
          }
        }
        if (!target) target = document.querySelector("#context-zone .tile-more") || [...document.querySelectorAll("#board .tile-more")].pop() || null;
      }
    }
    // Surlignage EN PLACE : pas de scrollIntoView → la grille ne bouge pas.
    if (target) target.classList.add("guide-target");
  }
  function psRenderGuide() {
    const bar = document.getElementById("path-search"); if (!bar || !_guide) return;
    bar.innerHTML = "";
    const st = _guide.steps[_guide.i];
    const line = el("div", "ps-guide");
    const tr = el("div", "ps-steps");
    _guide.steps.forEach((s, idx) => {
      const chip = el("div", "ps-step" + (idx < _guide.i ? " done" : idx === _guide.i ? " now" : ""));
      chip.textContent = String(idx + 1); tr.appendChild(chip);
    });
    line.appendChild(tr);
    // Cible courante : icône + nom court, sans phrase (texte minimal).
    let ic = "✨", name = _guide.label;
    if (st && st.kind === "home") { ic = "🏠"; name = "Accueil"; }
    else if (st && st.kind === "cat") { ic = "🗂️"; name = st.catLabel; }
    else if (st && st.kind === "word" && !psFindWordTile()) { ic = "📂"; name = "Tout voir → " + _guide.label; }
    line.appendChild(el("span", "ps-target", ic + " " + name));
    const direct = el("button", "ps-icon", "➡️"); direct.setAttribute("aria-label", "Y aller directement");
    direct.onclick = () => { if (typeof spotlightWord === "function") spotlightWord(_guide.boardId, _guide.label); };
    line.append(direct, closeX(psClose));
    bar.appendChild(line);
    psHighlightTarget();
  }
  function scored(pool) {
    const u = state.overrides.usage || {}, maxSeq = state.overrides.usageSeq || 1;
    let maxN = 1; pool.forEach(it => { const e = u[it.id]; if (e && e.n > maxN) maxN = e.n; });
    return pool.map((it, i) => {
      const e = u[it.id], n = e ? e.n : 0, t = e ? e.t : 0;
      // fréquence + récence, chacune normalisée
      const s = e ? (0.7 * (n / maxN) + 0.3 * (t / maxSeq)) : 0;
      return { it, s, i, used: !!e };
    });
  }
  function renderSpiralBoard(container, mode) {
    let arr;
    if (mode === "freq") {
      arr = scored(allWords()).filter(x => x.used).sort((a, b) => b.s - a.s).slice(0, 28);
      if (!arr.length) {
        const h = el("p", "hint"); h.style.padding = "24px";
        h.textContent = "Touchez des mots dans les tableaux : vos mots les plus utilisés récemment apparaîtront ici, au centre de la spirale.";
        container.appendChild(h); return;
      }
    } else {
      arr = scored(homeItems()).sort((a, b) => (b.s - a.s) || (a.i - b.i));
    }
    layoutSpiral(container, arr.map(x => x.it));
  }
  function layoutSpiral(container, items) {
    const stage = el("div", "spiral-stage");
    const TILE = 88, GA = Math.PI * (3 - Math.sqrt(5)), C = TILE * 0.62;
    let maxR = 0;
    const pos = items.map((_, i) => { const r = C * Math.sqrt(i), a = i * GA; if (r > maxR) maxR = r; return { x: r * Math.cos(a), y: r * Math.sin(a) }; });
    const size = 2 * (maxR + TILE);
    stage.style.width = stage.style.height = size + "px";
    const cx = size / 2, cy = size / 2;
    items.forEach((it, i) => {
      const t = tileEl(it, {});
      t.classList.add("spiral-tile");
      const scale = i < 3 ? 1.25 : i < 9 ? 1.1 : 1;   // saillance centrale (neurovisuel)
      const w = TILE * scale;
      t.style.width = t.style.height = w + "px";
      t.style.left = (cx + pos[i].x) + "px";
      t.style.top = (cy + pos[i].y) + "px";
      t.style.zIndex = String(1000 - i);
      stage.appendChild(t);
    });
    container.appendChild(stage);
  }

  /* ---------- MODE ANCRES (compaction sémantique française, ORIGINALE) ----------
     Optionnel, activable par la personne (jamais imposé). Principes de Baker
     seulement (grille stable + icônes polysémiques + séquences courtes) ; les
     associations ci-dessous sont les nôtres, les pictos = Mulberry (CC BY-SA) ou
     emoji de secours en attendant les customs. Le moteur grammatical (invisible)
     conjugue/accorde tout seul. [[droits-caa-compaction]] */
  const ANCHORS = [
    { id: "personne", label: "personne", color: "pronom", emo: "👤", pic: "je", keys: [
      { l: "je", s: "je" }, { l: "tu", s: "tu" }, { l: "il", s: "il" }, { l: "elle", s: "elle" }, { l: "iel", s: "iel" },
      { l: "on", s: "on" }, { l: "nous", s: "nous" }, { l: "vous", s: "vous" }, { l: "ils", s: "ils" }, { l: "elles", s: "elles" },
      { l: "moi", s: "je" }, { l: "qui", c: "question" } ] },
    { id: "bouche", label: "bouche", color: "verbe", emo: "👄", pic: "bouche", keys: [
      { l: "parler", v: "parler" }, { l: "dire", v: "dire" }, { l: "manger", v: "manger", pic: "manger" },
      { l: "boire", v: "boire" }, { l: "goûter" }, { l: "chanter" }, { l: "crier" }, { l: "chut", c: "social" },
      { l: "bon", c: "descriptif" }, { l: "faim", c: "nom" }, { l: "soif", c: "nom" } ] },
    { id: "oeil", label: "œil", color: "verbe", emo: "👁️", pic: "oeil", keys: [
      { l: "regarder", v: "regarder" }, { l: "voir" }, { l: "montrer" }, { l: "beau", c: "descriptif" }, { l: "attention", c: "nom" } ] },
    { id: "oreille", label: "oreille", color: "verbe", emo: "👂", pic: "oreille", keys: [
      { l: "écouter", v: "écouter" }, { l: "entendre" }, { l: "musique", c: "nom" }, { l: "bruit", c: "nom" } ] },
    { id: "main", label: "main", color: "verbe", emo: "✋", pic: "main gauche", keys: [
      { l: "faire", v: "faire" }, { l: "donner", v: "donner" }, { l: "prendre", v: "prendre" },
      { l: "aider", v: "aider" }, { l: "toucher" }, { l: "porter" }, { l: "montrer" }, { l: "caresser", pic: "caresser" },
      { l: "travail", c: "nom" } ] },
    { id: "coeur", label: "cœur", color: "social", emo: "❤️", pic: "cœur", keys: [
      { l: "aimer", v: "aimer" }, { l: "vouloir", v: "vouloir" }, { l: "envie", c: "nom" }, { l: "amour", c: "nom" },
      { l: "câlin", c: "nom", pic: "un câlin" }, { l: "courage", c: "nom" }, { l: "merci", c: "social" },
      { l: "content", c: "descriptif" }, { l: "triste", c: "descriptif" }, { l: "peur", c: "nom" } ] },
    { id: "tete", label: "tête", color: "verbe", emo: "🧠", pic: "tête", keys: [
      { l: "penser", v: "penser" }, { l: "savoir", v: "savoir" }, { l: "comprendre", v: "comprendre" },
      { l: "rêver" }, { l: "apprendre" }, { l: "se souvenir" }, { l: "attention", c: "nom" },
      { l: "idée", c: "nom" }, { l: "oublier" } ] },
    { id: "pied", label: "pied", color: "verbe", emo: "🦶", pic: "pied", keys: [
      { l: "aller", v: "aller" }, { l: "venir", v: "venir" }, { l: "partir" }, { l: "marcher" },
      { l: "courir" }, { l: "sauter" }, { l: "danser" }, { l: "tomber" }, { l: "monter" }, { l: "descendre" } ] },
    { id: "feu", label: "feu", color: "descriptif", emo: "🔥", pic: "feu", keys: [
      { l: "chaud", c: "descriptif" }, { l: "cuire" }, { l: "été", c: "nom" } ] },
    { id: "flocon", label: "flocon", color: "descriptif", emo: "❄️", pic: "neige", keys: [
      { l: "froid", c: "descriptif" }, { l: "hiver", c: "nom" }, { l: "brrr" } ] },
    { id: "eau", label: "eau", color: "nom", emo: "💧", pic: "eau", keys: [
      { l: "boire", v: "boire" }, { l: "eau", c: "nom" }, { l: "pluie", c: "nom" }, { l: "laver" } ] },
    { id: "maison", label: "maison", color: "petit", emo: "🏠", pic: "maison", keys: [
      { l: "maison", c: "nom" }, { l: "ici", c: "petit" }, { l: "là", c: "petit" }, { l: "là-bas", c: "petit" },
      { l: "à côté", c: "petit" }, { l: "à gauche", c: "petit" }, { l: "à droite", c: "petit" },
      { l: "en haut", c: "petit" }, { l: "en bas", c: "petit" }, { l: "dehors", c: "petit" }, { l: "où", c: "question" } ] },
    { id: "temps", label: "temps", color: "petit", emo: "🕐", pic: "montre", keys: [
      { l: "maintenant", c: "petit" }, { l: "tout de suite", c: "petit" }, { l: "plus tard", c: "petit" },
      { l: "bientôt", c: "petit" }, { l: "ensuite", c: "petit" }, { l: "finalement", c: "petit" },
      { l: "hier", c: "petit" }, { l: "demain", c: "petit" }, { l: "quand", c: "question" } ] },
    { id: "quantite", label: "quantité", color: "descriptif", emo: "🔢", pic: "beaucoup", keys: [
      { l: "beaucoup", c: "descriptif" }, { l: "un peu", c: "descriptif" }, { l: "encore", c: "descriptif" },
      { l: "plus", c: "descriptif" }, { l: "combien", c: "question" } ] },
    { id: "oui", label: "oui", color: "social", emo: "👍", pic: "oui", keys: [
      { l: "oui", c: "social" }, { l: "d'accord", c: "social" }, { l: "bravo", c: "social" }, { l: "voilà", c: "social" },
      { l: "vraiment", c: "social" }, { l: "exactement", c: "social" }, { l: "tout à fait", c: "social" },
      { l: "peut-être", c: "social" }, { l: "tant mieux", c: "social" }, { l: "ça marche", c: "social" } ] },
    { id: "non", label: "non", color: "negation", emo: "🚫", pic: "non", keys: [
      { l: "non", c: "negation" }, { l: "pas", c: "negation" }, { l: "stop", c: "social" }, { l: "arrête", c: "verbe" },
      { l: "ça suffit", phrase: true, c: "social" }, { l: "ça m'énerve", phrase: true, c: "social" },
      { l: "je ne veux pas", phrase: true, c: "social" }, { l: "mais", c: "petit" }, { l: "contre" } ] },
    { id: "question", label: "question", color: "question", emo: "❓", pic: "demander", keys: [
      { l: "quoi", c: "question" }, { l: "qui", c: "question" }, { l: "où", c: "question" },
      { l: "pourquoi", c: "question" }, { l: "comment", c: "question" } ] },
    { id: "besoin", label: "besoin", color: "social", emo: "🙏", pic: "aider", keys: [
      { l: "j'ai besoin de", phrase: true, c: "social" }, { l: "aide", pic: "aider", c: "nom" }, { l: "il faut", phrase: true, c: "social" } ] },
    { id: "stop", label: "stop", color: "verbe", emo: "🛑", pic: "arrêter", keys: [
      { l: "arrêter", v: "arrêter" }, { l: "attendre", v: "attendre" }, { l: "fini", c: "descriptif" }, { l: "pause", c: "nom" } ] },
    { id: "relation", label: "relation", color: "social", emo: "🧑‍🤝‍🧑", pic: "famille", keys: [
      { l: "ami", c: "nom" }, { l: "copain", c: "nom" }, { l: "copine", c: "nom" }, { l: "famille", c: "nom" },
      { l: "ensemble", c: "petit" }, { l: "avec", c: "petit" }, { l: "aimer", v: "aimer" }, { l: "câlin", c: "nom", pic: "un câlin" },
      { l: "aider", v: "aider" }, { l: "seul", c: "descriptif" }, { l: "lapinou", c: "nom", pic: "lapinou" } ] },
    { id: "emotion", label: "émotion", color: "social", emo: "😊", pic: "sourire", keys: [
      { l: "content", c: "descriptif" }, { l: "triste", c: "descriptif" }, { l: "peur", c: "nom" }, { l: "fâché", c: "descriptif" },
      { l: "fatigué", c: "descriptif" }, { l: "surpris", c: "descriptif" }, { l: "inquiet", c: "descriptif" },
      { l: "amoureux", c: "descriptif" }, { l: "calme", c: "descriptif" }, { l: "j'en ai marre", phrase: true, c: "social" } ] },
    { id: "douleur", label: "douleur", color: "descriptif", emo: "🤕", pic: "malade", keys: [
      { l: "mal", c: "nom" }, { l: "malade", c: "descriptif" }, { l: "aïe", c: "social" }, { l: "j'ai besoin de", phrase: true, c: "social" } ] },
    { id: "politesse", label: "politesse", color: "social", emo: "🙏", pic: "merci", keys: [
      { l: "bonjour", c: "social" }, { l: "merci", c: "social" }, { l: "s'il te plaît", c: "social" }, { l: "pardon", c: "social" }, { l: "au revoir", c: "social" } ] },
    { id: "soleil", label: "soleil", color: "descriptif", emo: "☀️", pic: "soleil", keys: [
      { l: "beau", c: "descriptif" }, { l: "chaud", c: "descriptif" }, { l: "jour", c: "nom" }, { l: "été", c: "nom" } ] },
    { id: "nature", label: "nature", color: "nom", emo: "🌳", pic: "arbre", keys: [
      { l: "dehors", c: "petit" }, { l: "animal", c: "nom" }, { l: "fleur", c: "nom" }, { l: "arbre", c: "nom" } ] },
    { id: "droits", label: "mes droits", color: "question", emo: "⚖️", pic: "loi", keys: [
      { l: "mes droits", phrase: true, c: "social" }, { l: "mon avis", phrase: true, c: "social" }, { l: "oui", c: "social" }, { l: "non", c: "negation" } ] },
    { id: "corps", label: "corps", color: "nom", pic: "mon corps", keys: [
      { l: "mal", c: "nom" }, { l: "malade", c: "descriptif" }, { l: "fatigué", c: "descriptif" }, { l: "grand", c: "descriptif" }, { l: "petit", c: "descriptif" } ] },
    { id: "mouvement", label: "mouvement", color: "verbe", pic: "aller", keys: [
      { l: "aller", v: "aller" }, { l: "venir", v: "venir" }, { l: "partir" }, { l: "monter" }, { l: "descendre" } ] },
    { id: "changement", label: "changement", color: "descriptif", pic: "changer", keys: [
      { l: "autre", c: "descriptif" }, { l: "encore", c: "descriptif" }, { l: "nouveau", c: "descriptif" }, { l: "changer" } ] },
    { id: "force", label: "force", color: "verbe", pic: "muscles", keys: [
      { l: "pouvoir", v: "pouvoir" }, { l: "fort", c: "descriptif" }, { l: "capable", c: "descriptif" }, { l: "essayer" } ] },
    { id: "avoir", label: "avoir", color: "verbe", pic: "avoir", keys: [
      { l: "avoir", v: "avoir" }, { l: "mon", c: "petit" }, { l: "il y a", phrase: true, c: "petit" } ] },
    { id: "etre", label: "être", color: "verbe", pic: "être", keys: [
      { l: "être", v: "être" }, { l: "c'est", phrase: true, c: "petit" }, { l: "devenir" } ] },
    { id: "lien", label: "et · ou", color: "petit", pic: "lien", keys: [
      { l: "et", c: "petit" }, { l: "ou", c: "petit" }, { l: "parce que", c: "petit" }, { l: "donc", c: "petit" }, { l: "avec", c: "petit" } ] },
    { id: "ecrire", label: "écrire", color: "petit", pic: "crayon", keys: [
      { l: "écrire" }, { l: "lire" }, { l: "lettres", c: "nom" }, { l: "clavier", c: "nom" } ] },
    { id: "argent", label: "argent", color: "nom", pic: "argent", keys: [
      { l: "acheter" }, { l: "payer" }, { l: "euro", c: "nom" }, { l: "magasin", c: "nom" } ] },
    { id: "intime", label: "vie intime", color: "social", pic: "un câlin", keys: [
      { l: "amour", c: "nom" }, { l: "câlin", c: "nom" }, { l: "respect", c: "nom" }, { l: "mon corps", phrase: true, c: "social" }, { l: "consentement", c: "nom" } ] },
    { id: "deuil", label: "deuil", color: "social", pic: "triste", keys: [
      { l: "mourir" }, { l: "triste", c: "descriptif" }, { l: "un souvenir", c: "nom" }, { l: "courage", c: "nom" } ] },
    { id: "transport", label: "transport", color: "nom", pic: "voiture", keys: [
      { l: "voiture", c: "nom" }, { l: "bus", c: "nom" }, { l: "train", c: "nom" }, { l: "vélo", c: "nom" } ] },
    { id: "loisir", label: "loisir", color: "verbe", pic: "jouer", keys: [
      { l: "jouer", v: "jouer" }, { l: "jeu", c: "nom" }, { l: "sport", c: "nom" }, { l: "musique", c: "nom" } ] },
  ];
  // PROMENADE : d'une idée on peut flâner vers des idées VOISINES (association
  // d'idées, pas de règle linguistique). Carte curée pour les idées centrales ;
  // pour les autres, on relie automatiquement les idées qui PARTAGENT un mot.
  const ANC_NEXT = {
    personne: ["coeur", "relation", "question", "besoin"],
    bouche: ["coeur", "eau", "oreille", "besoin"],
    oeil: ["tete", "oreille", "main", "nature"],
    oreille: ["bouche", "oeil", "tete", "loisir"],
    main: ["pied", "coeur", "force", "besoin"],
    coeur: ["relation", "emotion", "intime", "bouche"],
    tete: ["question", "coeur", "temps", "ecrire"],
    pied: ["mouvement", "transport", "loisir", "nature"],
    feu: ["eau", "flocon", "soleil", "corps"],
    flocon: ["feu", "eau", "soleil", "nature"],
    eau: ["bouche", "feu", "nature", "corps"],
    maison: ["personne", "relation", "temps", "besoin"],
    temps: ["tete", "changement", "mouvement", "loisir"],
    quantite: ["argent", "temps", "changement", "question"],
    oui: ["non", "besoin", "politesse", "droits"],
    non: ["oui", "stop", "droits", "besoin"],
    question: ["tete", "personne", "temps", "besoin"],
    besoin: ["droits", "politesse", "coeur", "stop"],
    stop: ["non", "besoin", "douleur", "temps"],
    relation: ["coeur", "emotion", "intime", "personne"],
    emotion: ["coeur", "relation", "douleur", "besoin"],
    douleur: ["corps", "besoin", "emotion", "stop"],
    politesse: ["besoin", "oui", "relation", "coeur"],
    soleil: ["feu", "flocon", "nature", "loisir"],
    nature: ["soleil", "eau", "pied", "loisir"],
    droits: ["besoin", "question", "non", "oui"],
    corps: ["douleur", "mouvement", "eau", "force"],
    mouvement: ["pied", "transport", "force", "temps"],
    changement: ["temps", "mouvement", "quantite", "tete"],
    force: ["main", "corps", "mouvement", "coeur"],
    avoir: ["etre", "quantite", "besoin", "argent"],
    etre: ["avoir", "emotion", "corps", "personne"],
    lien: ["tete", "question", "temps", "changement"],
    ecrire: ["tete", "oeil", "loisir", "lien"],
    argent: ["quantite", "besoin", "loisir", "transport"],
    intime: ["coeur", "relation", "emotion", "droits"],
    deuil: ["coeur", "emotion", "relation", "temps"],
    transport: ["pied", "mouvement", "loisir", "nature"],
    loisir: ["pied", "nature", "relation", "oreille"],
  };
  // Résolution id -> label (robuste), pour agréger l'usage PAR MOT quel que soit l'endroit où il a été
  // tapé (socle « c-je », pack « pm-se », association « assoc-petit-au », promenade « anc-bouche-manger »…).
  let _idLabelMap = null;
  function _buildIdLabel() {
    const m = {}, v = state.vocab || {};
    const add = items => (items || []).forEach(it => { if (it.id) m[it.id] = it.label; });
    (v.columns || []).forEach(c => add(c.items));
    ["functions", "themes", "packs"].forEach(g => (v[g] || []).forEach(f => add(f.items)));
    (v.advocacy || []).forEach(it => { if (it.id) m[it.id] = it.label; });
    return m;
  }
  function idToLabel(id) {
    if (!_idLabelMap) _idLabelMap = _buildIdLabel();
    if (_idLabelMap[id]) return _idLabelMap[id];
    const it = (typeof itemById === "function") ? itemById(id) : null;
    if (it && it.label) return it.label;
    let mm = id.match(/^(?:assoc|anc)-[^-]+-(.+)$/); if (mm) return mm[1];
    mm = id.match(/^(?:c|p|pm|vb|d|w|f|adv|mine|t|s|sc)-(.+)$/); if (mm) return mm[1];
    return id;
  }
  // CHEMINS PERSONNALISÉS : à partir d'une idée, les idées voisines vers lesquelles la personne
  // ENCHAÎNE vraiment (bigrammes agrégés par label, captés partout dans l'appli — pas seulement ici).
  function personalNeighborIds(a, limit) {
    const bg = (state.overrides && state.overrides.bigrams) || {};
    const myWords = new Set((a.keys || []).map(k => normalize(k.l)));
    const nextCount = {};
    for (const srcId of Object.keys(bg)) {
      if (!myWords.has(normalize(idToLabel(srcId)))) continue;   // départ = un mot de CETTE idée
      const m = bg[srcId];
      for (const nid of Object.keys(m)) {
        const nlab = normalize(idToLabel(nid));
        if (myWords.has(nlab)) continue;                          // pas un mot de l'idée elle-même
        nextCount[nlab] = (nextCount[nlab] || 0) + m[nid];
      }
    }
    if (!Object.keys(nextCount).length) return [];
    const score = {};
    for (const x of ANCHORS) {
      if (x.id === a.id) continue;
      let s = 0;
      for (const k of (x.keys || [])) { const c = nextCount[normalize(k.l)]; if (c) s += c; }
      if (s > 0) score[x.id] = s;
    }
    return Object.keys(score).sort((p, q) => score[q] - score[p]).slice(0, limit || 4);
  }
  // Idées voisines : d'ABORD les chemins de la personne (bigrammes), puis la carte curée, puis les
  // idées qui partagent un mot. Toujours du sens (jamais vide/aléatoire), et adapté aux habitudes.
  function anchorNeighbors(a) {
    const out = [], seen = new Set([a.id]);
    personalNeighborIds(a, 4).forEach(id => { if (!seen.has(id) && ANCHORS.some(x => x.id === id)) { out.push(id); seen.add(id); } });
    (ANC_NEXT[a.id] || []).forEach(id => { if (out.length < 4 && !seen.has(id) && ANCHORS.some(x => x.id === id)) { out.push(id); seen.add(id); } });
    if (out.length < 4) {
      const myWords = new Set((a.keys || []).map(k => normalize(k.l)));
      for (const x of ANCHORS) {
        if (out.length >= 4) break;
        if (seen.has(x.id)) continue;
        if ((x.keys || []).some(k => myWords.has(normalize(k.l)))) { out.push(x.id); seen.add(x.id); }
      }
    }
    return out.slice(0, 4);
  }
  let _symMap = null;
  let _symMapPromise = null;
  function ensureSymMap() {
    if (_symMap) return Promise.resolve(_symMap);
    // Cache de PROMESSE : les appels concurrents attendent la MÊME carte complète.
    // (Sinon un 2e appel récupérait la carte encore sans le dictionnaire → pictos du dico
    //  faussement absents.) _symMap n'est publié qu'une fois le dictionnaire chargé.
    if (_symMapPromise) return _symMapPromise;
    _symMapPromise = (async () => {
      const m = new Map();
      // On n'accepte QUE de vrais pictos-fichiers. On refuse les data-URI (cartes-mots
      // « gen_text » du dictionnaire) : sinon les ancres afficheraient un mot-image.
      const add = (label, sp) => {
        if (!sp || sp.indexOf("data:") === 0) return;
        const k = normalize(label); if (k && !m.has(k)) m.set(k, sp);
      };
      // 1) VOCABULAIRE de l'appli d'abord : pictos déjà résolus + labels PROPRES
      //    (« parler », « je »… avec leur vraie image ; le dico les garde en « parler , à »).
      const v = state.vocab || {};
      (v.columns || []).forEach(c => (c.items || []).forEach(i => add(i.label, i.sym_path)));
      ["functions", "themes", "packs"].forEach(g => (v[g] || []).forEach(f => (f.items || []).forEach(i => add(i.label, i.sym_path))));
      (v.advocacy || []).forEach(i => add(i.label, i.sym_path));
      // 2) dictionnaire de recherche en complément
      try { const d = await loadDict(); d.forEach(e => add(e.label, e.sym_path)); } catch (e) { /* hors-ligne */ }
      _symMap = m; return m;
    })();
    return _symMapPromise;
  }
  function symFor(m, word) { return word ? (m.get(normalize(word)) || null) : null; }
  // Accès SYNCHRONE au picto d'un mot (depuis la carte déjà chargée) : pour le clavier,
  // les scènes, le cahier — sans await. Renvoie null tant que la carte n'est pas prête.
  function pictoFor(word) { return _symMap ? (_symMap.get(normalize(word)) || null) : null; }
  const _ANC_TINT = { pronom: "#ffe08a", verbe: "#bfe3bf", descriptif: "#bcd8f2", nom: "#f2cfa8",
    social: "#f2b8dc", question: "#d8c8f5", petit: "#e2e2e2", negation: "#f4b8b8", advocacy: "#ffe08a" };
  function bubbleSVG(color) {
    const fill = _ANC_TINT[color] || "#e9eef3";
    return "data:image/svg+xml;utf8," + encodeURIComponent(
      '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M20 22 h60 a11 11 0 0 1 11 11 v26 a11 11 0 0 1 -11 11 h-34 l-17 15 v-15 h-9 a11 11 0 0 1 -11 -11 v-26 a11 11 0 0 1 11 -11 z" fill="' + fill + '" stroke="#2a3b47" stroke-width="4" stroke-linejoin="round"/><circle cx="37" cy="46" r="5.5" fill="#2a3b47"/><circle cx="55" cy="46" r="5.5" fill="#2a3b47"/><circle cx="73" cy="46" r="5.5" fill="#2a3b47"/></svg>');
  }
  function ancTile(o, onClick) {
    const b = el("button", "tile"); b.dataset.color = o.color || "nom"; b.type = "button";
    const im = document.createElement("img"); im.alt = "";
    im.src = o.sym || bubbleSVG(o.color);   // vrai picto, sinon bulle harmonisée (jamais d'emoji)
    b.appendChild(im); if (window.applySkin) applySkin(im);
    b.appendChild(el("span", "tile-label" + (window.labelClass ? labelClass(o.label) : ""), o.label));
    b.addEventListener("click", onClick);
    return b;
  }
  // Exemple concret « je + ancre = phrase » (mode apprentissage) — utilise le moteur réel.
  function anchorExample(a) {
    const k = a.keys.find(x => x.v) || a.keys[0]; if (!k) return null;
    const save = state.tokens;
    state.tokens = [{ label: "je", subject: "je", color: "pronom" },
      { label: k.l, color: k.c || a.color, verb: k.v, subject: k.s, phrase: !!k.phrase }];
    const s = (typeof sentence === "function") ? sentence() : null;
    state.tokens = save; return s;
  }
  // Révélation progressive (Baker/Drager) : on ÉCLAIRE les ancres probables et on
  // ESTOMPE les autres — sans JAMAIS les déplacer (le geste reste le même).
  function anchorHints() {
    const t = state.tokens, last = t[t.length - 1];
    if (!t.length) return { hot: new Set(["personne", "besoin", "oui", "non", "question", "politesse", "emotion"]) };
    if (last && last.subject) return { hot: new Set(["bouche", "main", "coeur", "tete", "pied", "feu", "flocon", "eau", "stop", "besoin", "emotion", "douleur", "force", "avoir", "etre", "mouvement", "intime", "loisir"]) };
    if (last && last.verb) return { hot: new Set(["personne", "maison", "eau", "quantite", "question", "temps", "relation", "nature", "transport", "argent", "corps", "lien", "loisir"]) };
    return { hot: null };
  }
  // PERSONNALISATION de la Promenade : elle s'adapte à ce que la personne écrit VRAIMENT —
  // partout dans l'appli, pas seulement ici (données `usage` sur l'appareil, jamais envoyées).
  // Honnête : ce sont SES habitudes, pas une « fréquence » universelle ; on propose, on n'impose pas.
  function promPersonalWords(limit) {
    const u = (state.overrides && state.overrides.usage) || {};
    const arr = [];
    for (const id of Object.keys(u)) {
      if (/^(anc-|sc_|d-)/.test(id)) continue;               // ni ancres, ni scènes, ni entrées dico brutes
      const n = (u[id] && u[id].n) || 0; if (n < 1) continue;
      const it = (typeof itemById === "function") ? itemById(id) : null;
      if (it && it.label) arr.push({ it, n });
    }
    arr.sort((a, b) => b.n - a.n);
    const seen = new Set(), out = [];
    for (const x of arr) { const k = normalize(x.it.label); if (seen.has(k)) continue; seen.add(k); out.push(x.it); if (out.length >= (limit || 8)) break; }
    return out;
  }
  function personalAnchorSet() {
    const top = new Set(promPersonalWords(16).map(it => normalize(it.label)));
    const s = new Set(); if (!top.size) return s;
    ANCHORS.forEach(a => { if ((a.keys || []).some(k => top.has(normalize(k.l)))) s.add(a.id); });
    return s;
  }
  async function renderAnchors(container) {
    container.innerHTML = "";
    const learn = state.settings.anchorLearn && !state.settings.tipsOff;   // texte de rappel coupé avec les aides
    const m = await ensureSymMap();
    if (!Array.isArray(state.anchorTrail)) state.anchorTrail = [];
    const a = state.activeAnchor ? ANCHORS.find(x => x.id === state.activeAnchor) : null;
    if (!a) state.anchorTrail = [];   // au départ, le chemin est vierge

    // FIL DE LA PROMENADE : le chemin parcouru (départ › idée › idée). Chaque
    // étape est cliquable pour revenir en arrière le long du sentier.
    const trail = el("div", "prom-trail");
    const start = el("button", "prom-step prom-start", "🚶 Départ");
    start.addEventListener("click", () => { state.anchorTrail = []; state.activeAnchor = null; renderAnchors(container); });
    trail.appendChild(start);
    state.anchorTrail.forEach((id, i) => {
      const an = ANCHORS.find(x => x.id === id); if (!an) return;
      trail.appendChild(el("span", "prom-sep", "›"));
      const last = i === state.anchorTrail.length - 1;
      const step = el("button", "prom-step" + (last ? " is-here" : ""), an.label);
      step.addEventListener("click", () => { state.anchorTrail = state.anchorTrail.slice(0, i + 1); state.activeAnchor = id; renderAnchors(container); });
      trail.appendChild(step);
    });
    container.appendChild(trail);

    // Aide COURTE et pratique (mode aide seulement) — pas de texte décoratif.
    if (learn) {
      const hint = el("div", "anchor-learn");
      hint.append(el("span", "", a ? "Choisis un mot. Les chemins voisins sont plus bas." : "Touche une idée pour commencer."));
      const off = el("button", "anchor-learn-x", "✕");
      off.title = "Masquer les aides (réactivable dans Réglages)";
      off.addEventListener("click", (e) => { e.stopPropagation(); state.settings.tipsOff = true; if (window.saveSettings) saveSettings(); renderAnchors(container); });
      hint.appendChild(off);
      container.appendChild(hint);
    }

    if (a) {
      // Les mots de l'endroit où l'on est. On ÉCLAIRE (sans déplacer) ceux que la personne
      // utilise le plus souvent — la promenade reflète ses habitudes jusque dans chaque idée.
      const words = el("div", "anchor-grid anchor-words"); container.appendChild(words);
      const topPerso = new Set(promPersonalWords(20).map(it => normalize(it.label)));
      a.keys.forEach(k => {
        const ksym = symFor(m, k.pic || k.l);
        const tile = ancTile({ label: k.l, color: k.c || a.color, sym: ksym }, () => {
          addToken({ id: "anc-" + a.id + "-" + k.l, label: k.l, color: k.c || a.color, verb: k.v, subject: k.s, phrase: !!k.phrase, sym_path: ksym || bubbleSVG(k.c || a.color) });
          if (typeof jitTip === "function") jitTip("promenade-enchaine", "Enchaîne d'autres mots, ou suis un chemin plus bas.", "🚶");
        });
        if (topPerso.has(normalize(k.l))) tile.classList.add("anc-hot");
        words.appendChild(tile);
      });
      // Le SENTIER : idées voisines en « pas japonais », pas une grille (association
      // d'idées, jamais Minspeak). On propose des chemins, on ne décide pas.
      const nb = anchorNeighbors(a);
      if (nb.length) {
        const path = el("div", "prom-path");
        path.appendChild(el("div", "prom-path-title", "Continuer la promenade"));
        const row = el("div", "prom-path-row");
        nb.forEach(nid => {
          const an = ANCHORS.find(x => x.id === nid); if (!an) return;
          const stone = el("button", "prom-stone");
          const im = document.createElement("img"); im.alt = ""; im.src = symFor(m, an.pic) || bubbleSVG(an.color); if (window.applySkin) applySkin(im);
          stone.appendChild(im); stone.appendChild(el("span", "prom-stone-lab", an.label));
          stone.addEventListener("click", () => { state.anchorTrail = [...state.anchorTrail, an.id]; state.activeAnchor = an.id; renderAnchors(container); });
          row.appendChild(stone);
        });
        path.appendChild(row); container.appendChild(path);
      }
    } else {
      // Départ. D'ABORD « Tes mots » : ce que la personne écrit le plus souvent (partout dans l'appli),
      // en accès direct — la Promenade s'adapte à ses habitudes. Vide au tout début (aucun usage encore).
      const perso = promPersonalWords(8);
      if (perso.length) {
        container.appendChild(el("div", "prom-path-title", "🫧 Tes mots (souvent utilisés)"));
        const prow = el("div", "anchor-grid anchor-words");
        perso.forEach(it => {
          const ksym = symFor(m, it.label) || it.sym_path || null;
          prow.appendChild(ancTile({ label: it.label, color: it.color, sym: ksym }, () => addToken(Object.assign({}, it))));
        });
        container.appendChild(prow);
        container.appendChild(el("div", "prom-path-title", "Ou pars d'une idée"));
      }
      // Carte d'idées : on ÉCLAIRE les idées probables (grammaire) ET les idées habituelles de la
      // personne (son usage), sans jamais déplacer les cases (le geste reste le même).
      const grid = el("div", "anchor-grid"); container.appendChild(grid);
      const hints = anchorHints();
      const hot = new Set([...(hints.hot || []), ...personalAnchorSet()]);
      ANCHORS.forEach(an => {
        const t = ancTile({ label: an.label, color: an.color, sym: symFor(m, an.pic), emo: an.emo }, () => { state.anchorTrail = [an.id]; state.activeAnchor = an.id; renderAnchors(container); });
        if (hot.size) t.classList.add(hot.has(an.id) ? "anc-hot" : "anc-dim");
        grid.appendChild(t);
      });
    }
  }

  /* ===================== IMPRESSION (réutilisable) ===================== */
  function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
  function printDoc(title, bodyHTML) {
    const w = window.open("", "_blank");
    if (!w) { if (window.toast) toast("L'impression a été bloquée par le navigateur. Autorise les fenêtres pop-up pour cette page (icône à droite de la barre d'adresse), puis touche à nouveau « Imprimer ».", "error", "🖨️"); else ctAlert("Autorisez les fenêtres pop-up pour imprimer."); return; }
    w.document.write(
      '<!doctype html><html lang="fr"><head><meta charset="utf-8"><title>' + esc(title) + '</title><style>' +
      '@page{margin:14mm} *{box-sizing:border-box} body{font-family:"Atkinson Hyperlegible",Verdana,sans-serif;color:#1a2733;line-height:1.35}' +
      'h1{color:#3a0577;margin:0 0 4px} .sub{color:#a52bfc;font-weight:700;margin:0 0 14px}' +
      '.pp{border:2px solid #eadcff;border-radius:12px;padding:10px 12px;margin:8px 0;break-inside:avoid;display:flex;gap:12px;align-items:flex-start}' +
      '.pp img{width:52px;height:52px;object-fit:contain;flex:0 0 auto}' +
      '.pp .l{font-weight:800;color:#3a0577;display:block;font-size:.9rem} .pp .v{font-size:1.15rem;white-space:pre-wrap}' +
      '.ans{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:4px}' +
      '.ans-pic{width:46px;height:46px;object-fit:contain} .ans-photo{width:96px;height:96px;object-fit:cover;border-radius:10px}' +
      '.paths.multi{display:grid;grid-template-columns:1fr 1fr;gap:12px}' +
      '.path{border:2px solid #eadcff;border-radius:14px;padding:16px;margin:0 0 12px;break-inside:avoid}' +
      '.path.one{break-after:page}' +
      '.path-word{display:flex;align-items:center;gap:12px;border-bottom:2px solid #eee;padding-bottom:10px;margin-bottom:12px}' +
      '.path-word img{width:60px;height:60px;object-fit:contain} .path-word b{font-size:1.35rem;color:#3a0577;display:block}' +
      '.path-word .fam{font-size:.82rem;color:#a52bfc;font-weight:700}' +
      '.path-steps{list-style:none;counter-reset:s;padding:0;margin:0;display:flex;flex-direction:column;gap:9px}' +
      '.path-steps li{counter-increment:s;display:flex;align-items:center;gap:10px;font-size:1.02rem}' +
      '.path-steps li::before{content:counter(s);flex:0 0 auto;width:28px;height:28px;border-radius:8px;background:#f0e9fa;color:#3a0577;font-weight:800;display:flex;align-items:center;justify-content:center;font-size:.9rem}' +
      '.path-steps img{width:34px;height:34px;object-fit:contain} .path-steps .em{font-size:1.3rem;width:30px;text-align:center}' +
      '.path-steps .dot{width:22px;height:22px;border-radius:50%;border:2px solid #999}' +
      '.path-here{display:flex;align-items:center;gap:10px;margin-top:6px;font-weight:800;color:#123512}' +
      '.path-here img{width:40px;height:40px;object-fit:contain}' +
      '.grid{display:grid;grid-template-columns:repeat(var(--c,6),1fr);gap:6px}' +
      '.cell{border:2px solid #33333322;border-radius:10px;padding:6px 4px;text-align:center;min-height:92px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px}' +
      '.cell img{width:52px;height:52px;object-fit:contain} .cell .cl{font-weight:800;font-size:.85rem}' +
      '.moment{break-inside:avoid;border:1px solid #ddd;border-radius:12px;padding:10px;margin:10px 0;display:flex;gap:12px;align-items:flex-start}' +
      '.moment img{width:180px;max-width:40%;border-radius:10px} .moment .d{color:#a52bfc;font-weight:700;font-size:.85rem} .moment .c{font-size:1.15rem;white-space:pre-wrap}' +
      '.foot{margin-top:16px;color:#888;font-size:.8rem}' +
      '</style></head><body>' + bodyHTML +
      '<p class="foot">Com’Together — Ma communication · imprimé depuis l’appli (100% local)</p>' +
      '<scr' + 'ipt>window.onload=function(){setTimeout(function(){window.print();},350);};</scr' + 'ipt>' +
      '</body></html>');
    w.document.close();
  }
  window.printDoc = printDoc;

  /* ===================== Réponse « SANS LECTURE » : pictos + photo + voix + texte =====================
   * Permet à la personne de compléter passeport & cahier SEULE ou avec aide, lectrice ou non. */
  function normAnswer(a) {
    if (a == null) return { text: "", pictos: [], photoKey: null, audioKey: null };
    if (typeof a === "string") return { text: a, pictos: [], photoKey: null, audioKey: null };
    return { text: a.text || "", pictos: Array.isArray(a.pictos) ? a.pictos : [], photoKey: a.photoKey || null, audioKey: a.audioKey || null };
  }
  function answerHasContent(a) { a = normAnswer(a); return !!(a.text || a.pictos.length || a.photoKey || a.audioKey); }
  function answerText(a) { a = normAnswer(a); return a.text || a.pictos.map(p => p.label).filter(Boolean).join(", "); }
  function speakAnswer(a) {
    a = normAnswer(a);
    if (a.audioKey) { playAudio(a.audioKey); return; }         // sa PROPRE voix d'abord
    const t = answerText(a); if (t && typeof speakOut === "function") speakOut(t);
  }
  // Construit un mini-éditeur (pictos / photo / voix / texte). Renvoie { el, get }.
  function richAnswer(initial, opts) {
    opts = opts || {};
    const val = normAnswer(initial);
    const wrap = el("div", "rich");
    const media = el("div", "rich-media");
    function drawMedia() {
      media.innerHTML = "";
      if (val.photoKey) {
        const ph = el("div", "rich-photo");
        const im = document.createElement("img"); urlFor(val.photoKey).then(u => { if (u) im.src = u; });
        const x = el("button", "rich-x", "×"); x.type = "button"; x.title = "Retirer la photo"; x.onclick = () => { val.photoKey = null; drawMedia(); };
        ph.append(im, x); media.appendChild(ph);
      }
      val.pictos.forEach((p, idx) => {
        const chip = el("div", "rich-chip");
        if (p.sym_path) { const im = document.createElement("img"); im.src = p.sym_path; im.alt = ""; im.onclick = () => { if (p.label) speakOut(p.label); }; chip.appendChild(im); }
        if (p.label) chip.appendChild(el("span", null, p.label));
        const x = el("button", "rich-x", "×"); x.type = "button"; x.onclick = () => { val.pictos.splice(idx, 1); drawMedia(); };
        chip.appendChild(x); media.appendChild(chip);
      });
      if (!media.children.length) media.appendChild(el("span", "rich-empty", "Choisis une image 🔎, une photo 📷 ou enregistre ta voix 🎤 — ou écris."));
    }
    drawMedia();
    const bar = el("div", "rich-bar");
    const bPic = el("button", "rich-btn", "🔎 Image"); bPic.type = "button";
    bPic.onclick = () => openLibrary((src, label) => { val.pictos.push({ sym_path: src, label: label || "" }); drawMedia(); });
    const fileIn = Object.assign(document.createElement("input"), { type: "file", accept: "image/*" }); fileIn.style.display = "none";
    const bPhoto = el("button", "rich-btn", "📷 Photo"); bPhoto.type = "button"; bPhoto.onclick = () => fileIn.click();
    fileIn.onchange = async () => { if (!fileIn.files[0]) return; const key = "pa_" + Date.now() + "_" + Math.round(performance.now()); await putMedia(key, fileIn.files[0]); urlCache.delete(key); val.photoKey = key; drawMedia(); };
    let rec = null, chunks = [];
    const bVoice = el("button", "rich-btn", "🎤 Voix"); bVoice.type = "button";
    const bPlay = el("button", "rich-btn", "▶ Écouter"); bPlay.type = "button"; bPlay.style.display = val.audioKey ? "" : "none";
    bPlay.onclick = () => { if (val.audioKey) playAudio(val.audioKey); };
    bVoice.onclick = async () => {
      if (rec && rec.state === "recording") { rec.stop(); return; }
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        rec = new MediaRecorder(stream); chunks = [];
        rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
        rec.onstop = async () => {
          const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" });
          const key = "pv_" + Date.now() + "_" + Math.round(performance.now());
          await putMedia(key, blob); urlCache.delete(key); val.audioKey = key;
          bPlay.style.display = ""; bVoice.textContent = "🎤 Refaire"; bVoice.classList.remove("is-rec");
          stream.getTracks().forEach(t => t.stop());
        };
        rec.start(); bVoice.textContent = "⏹ Stop"; bVoice.classList.add("is-rec");
      } catch (e) {
        if (window.toast) toast("Le micro n'est pas autorisé. Dans la barre d'adresse, touche le 🔒 (cadenas) puis autorise le micro pour cette page — et réessaie. Sinon, tu peux ajouter une image ou une photo.", "error", "🎤");
        else ctAlert("Micro non autorisé.");
      }
    };
    bar.append(bPic, bPhoto, bVoice, bPlay, fileIn);
    const txt = document.createElement(opts.multiline ? "textarea" : "input");
    if (opts.multiline) txt.rows = 2; else txt.type = "text";
    txt.className = "rich-text"; txt.value = val.text; txt.placeholder = opts.placeholder || "…ou écris (facultatif)";
    wrap.append(media, bar, txt);
    return { el: wrap, get: () => ({ text: txt.value.trim(), pictos: val.pictos, photoKey: val.photoKey, audioKey: val.audioKey }) };
  }
  // Rendu HTML illustré d'une réponse pour l'impression (pictos + photo + texte, en dataURL).
  async function answerPrintHTML(a) {
    a = normAnswer(a); let h = "";
    if (a.photoKey) { const u = await urlFor(a.photoKey); if (u) { try { h += "<img class='ans-photo' src='" + (await blobUrlToDataURL(u)) + "'>"; } catch (e) {} } }
    for (const p of a.pictos) { if (!p.sym_path) continue; try { h += "<img class='ans-pic' src='" + (await blobUrlToDataURL(new URL(p.sym_path, location.href).href)) + "'>"; } catch (e) {} }
    if (a.text) h += "<span class='v'>" + esc(a.text) + "</span>";
    if (a.audioKey && !a.text && !a.pictos.length && !a.photoKey) h += "<span class='v'>🎤 (message vocal)</span>";
    return h;
  }

  /* ===================== PASSEPORT DE COMMUNICATION ===================== */
  const PP_FIELDS = [
    ["nom", "Je m'appelle", "Prénom / nom"],
    ["presentation", "Qui je suis", "Mon âge, ce que j'aime, ma famille…"],
    ["comment", "Je communique avec", "cette appli, des gestes, le regard, des sons…"],
    ["oui", "Mon OUI, c'est", "ce que je fais pour dire oui"],
    ["non", "Mon NON, c'est", "ce que je fais pour dire non"],
    ["aide", "Ce qui m'aide", "laisse-moi du temps, phrases courtes, montre-moi…"],
    ["eviter", "Ce qui me gêne / à éviter", ""],
    ["aime", "J'aime", ""],
    ["aimepas", "Je n'aime pas", ""],
    ["personnes", "Mes personnes de confiance", ""],
    ["urgence", "En cas d'urgence / de besoin", "qui prévenir, quoi faire"],
  ];
  // Un picto par rubrique (support de conversation) : on tente plusieurs mots, on garde le 1er trouvé.
  const PP_ICON = {
    nom: ["je", "moi", "personne"], presentation: ["famille", "personne", "je"],
    comment: ["parler", "communiquer", "dire"], oui: ["oui"], non: ["non"],
    aide: ["aider", "aide"], eviter: ["arrêter", "stop", "attention"],
    aime: ["aimer", "content", "j'aime"], aimepas: ["je n'aime pas", "fâché", "non"],
    personnes: ["famille", "ami", "aider"], urgence: ["docteur", "téléphone", "aider"],
  };
  async function openPassport() {
    const data = (state.overrides.passport ||= {});
    const { card, close } = modal("Mon passeport de communication");
    const intro = el("p", "hint", "À remplir et à regarder ensemble. Chaque ligne se dit à voix haute (🔊). Tout reste sur l'appareil.");
    card.appendChild(intro);
    const smap = await ensureSymMap();
    const ppSym = (k) => { for (const w of (PP_ICON[k] || [])) { const s = symFor(smap, w); if (s) return s; } return null; };
    const editors = {}, icons = {};
    PP_FIELDS.forEach(([k, label, ph]) => {
      const f = el("div", "field");
      const labRow = el("div", "pp-labrow");
      const ico = ppSym(k); icons[k] = ico;
      if (ico) { const im = document.createElement("img"); im.className = "pp-ico"; im.src = ico; im.alt = ""; labRow.appendChild(im); }
      labRow.appendChild(Object.assign(el("label"), { textContent: label }));
      const say = el("button", "pp-say", "🔊"); say.type = "button"; say.title = "Dire cette ligne";
      say.addEventListener("click", () => { const a = editors[k].get(); if (answerHasContent(a)) speakAnswer(a); else speakOut(label); });
      labRow.appendChild(say);
      f.appendChild(labRow);
      const ed = richAnswer(data[k], { multiline: (k === "presentation" || k === "aide"), placeholder: ph || "…ou écris (facultatif)" });
      editors[k] = ed; f.appendChild(ed.el); card.appendChild(f);
    });
    const save = () => { PP_FIELDS.forEach(([k]) => data[k] = editors[k].get()); if (typeof saveOverrides === "function") saveOverrides(); };
    const actions = el("div", "modal-actions");
    const bSave = el("button", "btn-primary", "Enregistrer");
    bSave.addEventListener("click", () => { save(); close(); });
    const bPrint = el("button", "btn-secondary", "🖨️ Imprimer (illustré)");
    bPrint.addEventListener("click", async () => {
      save();
      const nom = answerText(data.nom);
      let html = "<h1>Passeport de communication</h1><p class='sub'>" + (esc(nom) || "Moi") + "</p>";
      for (const [k, label] of PP_FIELDS) {
        if (k === "nom" || !answerHasContent(data[k])) continue;
        let img = "";
        if (icons[k]) { try { img = "<img src='" + (await blobUrlToDataURL(new URL(icons[k], location.href).href)) + "'>"; } catch (e) {} }
        html += "<div class='pp'>" + img + "<div><span class='l'>" + esc(label) + "</span><span class='ans'>" + (await answerPrintHTML(data[k])) + "</span></div></div>";
      }
      printDoc("Passeport — " + (nom || "Moi"), html);
    });
    actions.append(bSave, bPrint); card.appendChild(actions);
  }

  /* ===================== CAHIER DE VIE ===================== */
  // Compat : anciens moments {caption,key} ↔ nouveau modèle {text,pictos,photoKey,audioKey}
  function normMoment(m) {
    return { id: m.id, date: m.date || "",
      text: m.text != null ? m.text : (m.caption || ""),
      pictos: Array.isArray(m.pictos) ? m.pictos : [],
      photoKey: m.photoKey || m.key || null, audioKey: m.audioKey || null };
  }
  function openCahier() {
    const list = (state.overrides.cahier ||= []);
    const { card, close } = modal("Mon cahier de vie");
    card.appendChild(el("p", "hint", "Une photo, des images ou ta voix pour chaque moment. Touche ▶ pour le raconter. Pas besoin de savoir lire ni écrire."));
    const wrap = el("div", "cahier-list"); card.appendChild(wrap);
    function draw() {
      wrap.innerHTML = "";
      if (!list.length) {
        const es = el("div", "empty-state");
        es.appendChild(el("div", "es-emoji", "📖"));
        es.appendChild(el("p", null, "Aucun moment pour l'instant. Ajoute une photo, des images ou ta voix ci-dessous — puis regardez-le ensemble."));
        wrap.appendChild(es);
      }
      list.slice().reverse().forEach(raw => {
        const m = normMoment(raw);
        const cardM = el("div", "moment-card");
        if (m.photoKey) { const im = document.createElement("img"); im.className = "moment-photo"; urlFor(m.photoKey).then(u => { if (u) im.src = u; }); cardM.appendChild(im); }
        const body = el("div", "moment-body");
        const top = el("div", "moment-top");
        top.appendChild(el("span", "moment-date", m.date || " "));
        if (m.audioKey || m.text || m.pictos.length) {
          const play = el("button", "moment-play", "▶"); play.type = "button"; play.title = "Écouter / raconter ce moment";
          play.addEventListener("click", () => speakAnswer(m));
          top.appendChild(play);
        }
        const del = el("button", "moment-del", "🗑️"); del.type = "button"; del.title = "Retirer ce moment";
        del.addEventListener("click", () => { const i = list.indexOf(raw); if (i >= 0) list.splice(i, 1); if (typeof saveOverrides === "function") saveOverrides(); draw(); });
        top.appendChild(del);
        body.appendChild(top);
        if (m.pictos.length) {
          const pr = el("div", "moment-pics");
          m.pictos.forEach(p => { if (p.sym_path) { const im = document.createElement("img"); im.src = p.sym_path; im.alt = p.label || ""; im.title = p.label || ""; im.onclick = () => { if (p.label) speakOut(p.label); }; pr.appendChild(im); } });
          body.appendChild(pr);
        }
        if (m.text) body.appendChild(el("div", "moment-text", m.text));
        cardM.appendChild(body); wrap.appendChild(cardM);
      });
    }
    draw();
    const add = el("div", "field cahier-add"); add.style.marginTop = "14px";
    add.append(Object.assign(el("label"), { textContent: "➕ Ajouter un moment" }));
    const ed = richAnswer(null, { multiline: true, placeholder: "…ou écris quelques mots (facultatif)" });
    add.appendChild(ed.el);
    const bAdd = el("button", "btn-primary", "Ajouter ce moment"); bAdd.style.marginTop = "8px";
    bAdd.addEventListener("click", () => {
      const a = ed.get(); if (!answerHasContent(a)) return;
      const d = new Date();
      list.push({ id: "m" + d.getTime(), date: d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }),
        text: a.text, pictos: a.pictos, photoKey: a.photoKey, audioKey: a.audioKey });
      if (typeof saveOverrides === "function") saveOverrides();
      close(); openCahier();   // ré-ouvre propre (nouvel éditeur vide)
    });
    add.appendChild(bAdd); card.appendChild(add);
    const actions = el("div", "modal-actions");
    const bPrint = el("button", "btn-secondary", "🖨️ Imprimer le cahier");
    bPrint.addEventListener("click", async () => {
      let html = "<h1>Mon cahier de vie</h1>";
      for (const raw of list) {
        const m = normMoment(raw);
        let img = "";
        if (m.photoKey) { const u = await urlFor(m.photoKey); if (u) { try { img = "<img src='" + (await blobUrlToDataURL(u)) + "'>"; } catch (e) {} } }
        let pics = ""; for (const p of m.pictos) { if (!p.sym_path) continue; try { pics += "<img class='ans-pic' src='" + (await blobUrlToDataURL(new URL(p.sym_path, location.href).href)) + "'>"; } catch (e) {} }
        html += "<div class='moment'>" + img + "<div><div class='d'>" + esc(m.date) + "</div>"
          + (pics ? "<div class='ans'>" + pics + "</div>" : "")
          + "<div class='c'>" + esc(m.text) + (m.audioKey && !m.text ? " 🎤" : "") + "</div></div></div>";
      }
      printDoc("Cahier de vie", html);
    });
    const bClose = el("button", "btn-secondary", "Fermer"); bClose.addEventListener("click", close);
    actions.append(bPrint, bClose); card.appendChild(actions);
  }
  async function blobUrlToDataURL(url) {
    const b = await (await fetch(url)).blob();
    return await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = rej; fr.readAsDataURL(b); });
  }

  /* ===================== IMPRIMER LE TABLEAU (grille papier) ===================== */
  function printBoard() {
    const board = document.getElementById("board");
    const tiles = board ? [...board.querySelectorAll(".tile:not(.back):not(.cat)")] : [];
    if (!tiles.length) { if (window.toast) toast("Ouvre d'abord un tableau (l'accueil ou une catégorie), puis touche « Imprimer ce tableau ».", null, "📋"); else ctAlert("Ouvre un tableau d'abord."); return; }
    const cols = Math.max(4, Math.min(8, parseInt(getComputedStyle(board).getPropertyValue("--cols")) || 6));
    let cells = "";
    tiles.forEach(t => {
      const img = t.querySelector("img"); const lab = (t.querySelector(".tile-label") || {}).textContent || "";
      cells += "<div class='cell'>" + (img ? "<img src='" + esc(img.src) + "'>" : "") + "<span class='cl'>" + esc(lab) + "</span></div>";
    });
    printDoc("Tableau à imprimer", "<h1>Mon tableau</h1><div class='grid' style='--c:" + cols + "'>" + cells + "</div>");
  }
  /* ===================== CHEMINS MOTEURS À IMPRIMER ===================== */
  function openMotorPaths() {
    const { card } = modal("🗺️ Chemins moteurs à imprimer");
    card.appendChild(el("p", "hint", "Choisis un ou plusieurs mots : on imprime la « recette » de gestes pour les atteindre. Idéal pour apprendre où vivent les mots."));
    const chosen = [];
    const all = (typeof window.placedWords === "function") ? window.placedWords() : [];

    const chips = el("div", "mp-chips");
    function drawChips() {
      chips.innerHTML = "";
      if (!chosen.length) { chips.appendChild(el("span", "hint", "Aucun mot choisi pour l'instant.")); return; }
      chosen.forEach((it, i) => {
        const c = el("span", "mp-chip");
        if (pictoFor(it)) { const im = document.createElement("img"); im.src = pictoFor(it); im.alt = ""; c.appendChild(im); }
        c.appendChild(el("span", null, labelFor(it)));
        const x = el("button", "mp-x", "×"); x.type = "button"; x.onclick = () => { chosen.splice(i, 1); drawChips(); };
        c.appendChild(x); chips.appendChild(c);
      });
    }
    drawChips();

    const search = Object.assign(document.createElement("input"), { type: "text", placeholder: "chercher un mot à ajouter…" });
    search.className = "rich-text"; search.style.cssText = "width:100%;margin:10px 0 6px;min-height:44px";
    const results = el("div", "lib-grid"); results.style.maxHeight = "38vh";
    search.addEventListener("input", () => {
      const q = normalize(search.value.trim()); results.innerHTML = "";
      if (q.length < 1) return;
      all.filter(it => normalize(labelFor(it)).includes(q)).slice(0, 60).forEach(it => {
        const b = el("button", "tile"); b.dataset.color = colorFor(it) || "nom"; b.style.minHeight = "76px";
        if (pictoFor(it)) { const im = document.createElement("img"); im.src = pictoFor(it); im.alt = ""; b.appendChild(im); }
        b.appendChild(el("span", "tile-label" + (window.labelClass ? labelClass(labelFor(it)) : ""), labelFor(it)));
        b.addEventListener("click", () => { if (!chosen.find(x => x.id === it.id)) chosen.push(it); drawChips(); });
        results.appendChild(b);
      });
      if (!results.children.length) results.appendChild(el("p", "hint", "Aucun mot placé ne correspond."));
    });
    card.append(el("div", null, ""), chips, search, results);

    // Mise en page
    const lay = el("div", "field"); lay.style.marginTop = "12px";
    lay.append(Object.assign(el("label"), { textContent: "Mise en page" }));
    const sel = document.createElement("select");
    [["multi", "Plusieurs par page (feuille)"], ["one", "Un par page (grande fiche)"]].forEach(([v, t]) => sel.appendChild(Object.assign(document.createElement("option"), { value: v, textContent: t })));
    lay.appendChild(sel); card.appendChild(lay);

    const actions = el("div", "modal-actions");
    const bPrint = el("button", "btn-primary", "🖨️ Imprimer les chemins");
    bPrint.addEventListener("click", async () => {
      if (!chosen.length) { if (window.toast) toast("Choisis d'abord au moins un mot à imprimer.", null, "✏️"); return; }
      const one = sel.value === "one";
      const toData = async (src) => { if (!src) return ""; try { return await blobUrlToDataURL(src.indexOf("data:") === 0 ? src : new URL(src, location.href).href); } catch (e) { return ""; } };
      let cards = "";
      for (const it of chosen) {
        const p = window.motorPathFor(it.id);
        const wpic = await toData(pictoFor(it));
        const wordImg = wpic ? "<img src='" + wpic + "'>" : "";
        if (!p) { cards += "<div class='path" + (one ? " one" : "") + "'><div class='path-word'>" + wordImg + "<div><b>" + esc(labelFor(it)) + "</b><span class='fam'>(mot non localisé)</span></div></div></div>"; continue; }
        let steps = "";
        for (const s of p.steps) {
          let vis = "";
          if (s.thumb) { const d = await toData(s.thumb); vis = d ? "<img src='" + d + "'>" : ""; }
          else if (s.color) vis = "<span class='dot' style='background:" + s.color + "'></span>";
          else if (s.ic) vis = "<span class='em'>" + s.ic + "</span>";
          steps += "<li>" + vis + "<span>" + esc(s.t) + "</span></li>";
        }
        const here = "<div class='path-here'>👉 " + wordImg + "<span>Toucher « " + esc(labelFor(it)) + " »</span></div>";
        cards += "<div class='path" + (one ? " one" : "") + "'>"
          + "<div class='path-word'>" + wordImg + "<div><b>" + esc(labelFor(it)) + "</b>"
          + (p.fam ? "<span class='fam'>famille " + esc(p.fam) + "</span>" : "") + "</div></div>"
          + "<ol class='path-steps'>" + steps + "</ol>" + here + "</div>";
      }
      printDoc("Chemins moteurs", "<h1>Chemins moteurs</h1><div class='paths " + (one ? "" : "multi") + "'>" + cards + "</div>");
    });
    actions.appendChild(bPrint); card.appendChild(actions);
  }

  window.CT = { decorateTile, openEditor, openViewer, openPopup, renderScenes, addScene, renderSpiralBoard, openSearch, openLibrary, openList, openPartnerTips, openStats, openPacks, openVariants, openMotorPaths, predict, warmDict: loadDict, playAudio, playBruitage, renderAnchors, openPassport, openCahier, printBoard, openDecoder, segmentFr, soundOf, openPronDialog, pictoFor, warmSym: ensureSymMap, openPhonemeBank, openRecordGuide, exportAllMedia, importAllMedia, onProfileSwitch, openModelingCoach, openBulkAdd };
})();
