/* Ma petite madeleine — Le quizz images.
   Chargé avant app/mpm-app.jsx, expose window.Quizz.
   Tout est dans une IIFE : les scripts babel partagent la portée globale,
   donc rien ne doit redéclarer les constantes de mpm-app.jsx. */
(function () {
  const { useState } = React;

  const Q = {
    ivoire: '#FBF6EC', ivoire2: '#FBF3E2', creme: '#E6D9BC', ligne: '#E2CFA2',
    navy: '#1C2B4F', navy5: '#38507C', navy3: '#7C8CAE', bleuPale: '#A8B4CC',
    or7: '#8F6518', or: '#B8862B', or5: '#D9AE5C', or1: '#F3E4C0',
    /* L'or chaud du niveau divine, entré dans la palette le 21 septembre 2026. */
    orChaud: '#D19043',
    vert: '#5B6A4C', vertClair: '#8FA878'
  };
  const SERIF_Q = "'Cormorant Garamond', Georgia, serif";
  const BODY_Q = "'EB Garamond', Georgia, serif";
  const SANS_Q = "Montserrat, 'Helvetica Neue', sans-serif";

  /* Les cinq niveaux. Sur le cartouche on ne garde que l'adjectif ; sur le
     diplôme le mot « madeleine » s'écrit en entier. */
  const NIVEAUX = ['Mini', 'Petite', 'Jolie', 'Superbe', 'Divine'];
  const nomNiveauComplet = k => (NIVEAUX[k] || '') + ' madeleine';
  /* Les vidéos de la mascotte aux diplômes, par rang de niveau (0 = Mini … 4 = Divine). */
  const VIDEOS_DIPLOME = {
    0: 'app/video/diplome-mini.mp4?v=30i',
    1: 'app/video/diplome-petite.mp4?v=30i',
    2: 'app/video/diplome-jolie.mp4?v=30i',
    3: 'app/video/diplome-superbe.mp4?v=30i',
    4: 'app/video/diplome-divine.mp4?v=30j'
  };

  /* [clef, article, mot, famille, proches, phrase, vrai] */
  const ALIMENTS = [
    ['abricot', 'un', 'abricot', 'fruit', ['peche', 'prune', 'pomme'], 'L’abricot est orange.', true],
    ['cerise', 'une', 'cerise', 'fruit', ['fraise', 'framboise', 'prune'], 'La cerise, c’est un légume.', false],
    ['citron', 'un', 'citron', 'fruit', ['melon', 'orange', 'peche'], 'Le citron est acide.', true],
    ['figue', 'une', 'figue', 'fruit', ['prune', 'abricot', 'raisin'], 'La figue est dure.', false],
    ['fraise', 'une', 'fraise', 'fruit', ['framboise', 'cerise', 'myrtille'], 'La fraise est rouge.', true],
    ['framboise', 'une', 'framboise', 'fruit', ['fraise', 'myrtille', 'cerise'], 'La framboise est salée.', false],
    ['melon', 'un', 'melon', 'fruit', ['citron', 'orange', 'pomme'], 'Le melon est un fruit d’été.', true],
    ['myrtille', 'une', 'myrtille', 'fruit', ['framboise', 'raisin', 'cerise'], 'La myrtille est jaune.', false],
    ['orange', 'une', 'orange', 'fruit', ['citron', 'pomme', 'melon'], 'L’orange est un légume.', false],
    ['peche', 'une', 'pêche', 'fruit', ['abricot', 'poire', 'pomme'], 'On achète la pêche en hiver.', false],
    ['poire', 'une', 'poire', 'fruit', ['pomme', 'peche', 'figue'], 'La poire pousse sur un arbre.', true],
    ['pomme', 'une', 'pomme', 'fruit', ['poire', 'peche', 'orange'], 'La pomme pousse dans la terre.', false],
    ['prune', 'une', 'prune', 'fruit', ['abricot', 'figue', 'cerise'], 'On fait de la confiture de prunes.', true],
    ['raisin', 'un', 'raisin', 'fruit', ['myrtille', 'cerise', 'figue'], 'Le raisin pousse en grappe.', true],
    ['aubergine', 'une', 'aubergine', 'legume', ['courgette', 'poivron', 'citrouille'], 'L’aubergine est violette.', true],
    ['avocat', 'un', 'avocat', 'legume', ['courgette', 'poivron', 'tomate'], 'L’avocat est vert à l’intérieur.', true],
    ['carotte', 'une', 'carotte', 'legume', ['poivron', 'radis', 'courgette'], 'La carotte est orange.', true],
    ['celeri', 'un', 'céleri', 'legume', ['poireau', 'courgette', 'haricot-vert'], 'Le céleri est un fruit.', false],
    ['chou-fleur', 'un', 'chou-fleur', 'legume', ['citrouille', 'courgette', 'celeri'], 'Le chou-fleur est bleu.', false],
    ['citrouille', 'une', 'citrouille', 'legume', ['aubergine', 'chou-fleur', 'poivron'], 'La citrouille est un légume très petit.', false],
    ['courgette', 'une', 'courgette', 'legume', ['aubergine', 'celeri', 'poireau'], 'La courgette est souvent rouge.', false],
    ['haricot-vert', 'des', 'haricots verts', 'legume', ['petits-pois', 'poireau', 'celeri'], 'Les haricots verts sont sucrés.', false],
    ['petits-pois', 'des', 'petits pois', 'legume', ['haricot-vert', 'radis', 'celeri'], 'Les petits pois sont ronds.', true],
    ['poireau', 'un', 'poireau', 'legume', ['celeri', 'courgette', 'haricot-vert'], 'Le poireau est un fruit sucré.', false],
    ['poivron', 'un', 'poivron rouge', 'legume', ['tomate', 'aubergine', 'carotte'], 'Le poivron rouge est bon cru et cuit.', true],
    ['pomme-de-terre', 'une', 'pomme de terre', 'legume', ['citrouille', 'aubergine', 'carotte'], 'La pomme de terre pousse dans la terre.', true],
    ['radis', 'un', 'radis', 'legume', ['carotte', 'tomate', 'poivron'], 'Le radis est un fruit rouge.', false],
    ['tomate', 'une', 'tomate', 'legume', ['poivron', 'radis', 'aubergine'], 'La tomate est rose.', false]
  ];

  /* L'aspect physique : une caractéristique par carte, le son dit la phrase
     entière. Les familles ne se mélangent jamais dans les propositions. */
  const PHYSIQUE = [
    ['yeux-gris', '', 'les yeux gris', 'yeux', ['yeux-bleus', 'yeux-verts', 'yeux-marron'], 'Ses yeux sont gris.', true],
    ['cheveux-boucles', '', 'les cheveux bouclés', 'cheveux-forme', ['cheveux-frises', 'cheveux-raides', 'cheveux-longs'], 'Léa a les cheveux bouclés.', true],
    ['chauve', '', 'chauve', 'visage', ['barbe', 'moustache', 'lunettes'], 'Cet homme est chauve.', true],
    ['mince', '', 'mince', 'silhouette', ['maigre', 'grosse', 'muscle'], 'Cette femme est mince.', true],
    ['jeune', '', 'jeune', 'age', ['vieux'], 'Paul est jeune.', true],
    ['vieux', '', 'vieux', 'age', ['jeune'], 'Cet homme est vieux.', true],
    ['yeux-marron', '', 'les yeux marron', 'yeux', ['yeux-bleus', 'yeux-verts', 'yeux-gris'], 'Elle a les yeux marron.', true],
    ['yeux-bleus', '', 'les yeux bleus', 'yeux', ['yeux-verts', 'yeux-gris', 'yeux-marron'], 'Il a les yeux bleus.', true],
    ['yeux-verts', '', 'les yeux verts', 'yeux', ['yeux-bleus', 'yeux-marron', 'yeux-gris'], 'Anna a les yeux verts.', true],
    ['taches-rousseur', '', 'des taches de rousseur', 'visage', ['lunettes', 'barbe', 'moustache'], 'Il a les taches de rousseur.', true],
    ['lunettes', '', 'des lunettes', 'visage', ['barbe', 'moustache', 'chauve'], 'Elle porte des lunettes.', true],
    ['cheveux-blonds', '', 'les cheveux blonds', 'cheveux-couleur', ['cheveux-chatains', 'cheveux-roux', 'cheveux-bruns'], 'Marie a les cheveux blonds.', true],
    ['cheveux-bruns', '', 'les cheveux bruns', 'cheveux-couleur', ['cheveux-chatains', 'cheveux-gris', 'cheveux-blonds'], 'Il a les cheveux bruns.', true],
    ['cheveux-chatains', '', 'les cheveux châtains', 'cheveux-couleur', ['cheveux-bruns', 'cheveux-blonds', 'cheveux-roux'], 'Ses cheveux sont châtains.', true],
    ['cheveux-roux', '', 'les cheveux roux', 'cheveux-couleur', ['cheveux-blonds', 'cheveux-chatains', 'cheveux-bruns'], 'Elle a les cheveux roux.', true],
    ['cheveux-gris', '', 'les cheveux gris', 'cheveux-couleur', ['cheveux-blancs', 'cheveux-chatains', 'cheveux-bruns'], 'Il a les cheveux gris.', true],
    ['cheveux-blancs', '', 'les cheveux blancs', 'cheveux-couleur', ['cheveux-gris', 'cheveux-blonds', 'cheveux-chatains'], 'Cette dame a les cheveux blancs.', true],
    ['cheveux-courts', '', 'les cheveux courts', 'cheveux-forme', ['cheveux-longs', 'cheveux-frises', 'cheveux-raides'], 'Ses cheveux sont courts.', true],
    ['cheveux-longs', '', 'les cheveux longs', 'cheveux-forme', ['cheveux-courts', 'cheveux-raides', 'cheveux-boucles'], 'Elle a les cheveux longs.', true],
    ['cheveux-raides', '', 'les cheveux raides', 'cheveux-forme', ['cheveux-frises', 'cheveux-boucles', 'cheveux-courts'], 'Il a les cheveux raides.', true],
    ['cheveux-frises', '', 'les cheveux frisés', 'cheveux-forme', ['cheveux-raides', 'cheveux-boucles', 'cheveux-longs'], 'Ses cheveux sont frisés.', true],
    ['barbe', '', 'une barbe', 'visage', ['moustache', 'lunettes', 'chauve'], 'Cet homme a une barbe.', true],
    ['moustache', '', 'une moustache', 'visage', ['barbe', 'lunettes', 'chauve'], 'Paul a une moustache.', true],
    ['grand', '', 'grand', 'silhouette', ['petit', 'muscle', 'maigre'], 'Il est très grand.', true],
    ['petit', '', 'petit', 'silhouette', ['grand', 'grosse', 'mince'], 'Son frère est petit.', true],
    ['grosse', '', 'grosse', 'silhouette', ['maigre', 'mince', 'petit'], 'Cette femme est grosse.', true],
    ['maigre', '', 'maigre', 'silhouette', ['grosse', 'mince', 'muscle'], 'Elle est très maigre.', true],
    ['muscle', '', 'musclé', 'silhouette', ['maigre', 'grand', 'mince'], 'Cet homme est musclé.', true]
  ];


  /* Quizz 3 de l'aspect physique : chaque question est individuelle — sa
     gravure numérotée, sa piste, et deux réponses écrites. Le son dit la
     phrase puis la question ; rien n'est écrit à l'écran, comme aux deux
     autres épreuves. Les deux cases changent de côté à chaque venue.
     La gravure est celle du portrait (les questions suivent l'ordre des 28
     cartes) ; seule la piste est nouvelle, rangée sous q3/1 … q3/28.
     [clef image, '', libellé, famille, [], texte dit, vrai, [réponses], bonne, piste] */
  const VERSION_Q3 = '20260923b';
  const QUESTIONS_PHYSIQUE = [
    ['yeux-gris', '', 'les yeux gris', 'q3', [], 'Ses yeux sont gris. De quelle couleur sont ses yeux ?', true, ['gris', 'roux'], 0, 'q3/1'],
    ['cheveux-boucles', '', 'les cheveux bouclés', 'q3', [], 'Léa a les cheveux bouclés. Quel est le contraire de raide ?', true, ['bouclé', 'blond'], 0, 'q3/2'],
    ['chauve', '', 'chauve', 'q3', [], 'Cet homme est chauve. Si cet homme n’a pas de cheveux, il est ?', true, ['chauve', 'maigre'], 0, 'q3/3'],
    ['mince', '', 'mince', 'q3', [], 'Cette femme est mince. Elle a une silhouette fine.', true, ['mince', 'grande'], 0, 'q3/4'],
    ['jeune', '', 'jeune', 'q3', [], 'Paul est jeune. Cet enfant a huit ans.', true, ['jeune', 'petit'], 0, 'q3/5'],
    ['vieux', '', 'vieux', 'q3', [], 'Cet homme est vieux. Mon grand-père a quatre-vingt-dix ans.', true, ['vieux', 'grand'], 0, 'q3/6'],
    ['yeux-marron', '', 'les yeux marron', 'q3', [], 'Elle a les yeux marron. Elle a les yeux bruns.', true, ['non', 'oui'], 0, 'q3/7'],
    ['yeux-bleus', '', 'les yeux bleus', 'q3', [], 'Il a les yeux bleus. Le ciel a aussi cette couleur.', true, ['bleu', 'blanc'], 0, 'q3/8'],
    ['yeux-verts', '', 'les yeux verts', 'q3', [], 'Anna a les yeux verts. L’avocat a la même couleur que ses yeux.', true, ['vert', 'chauve'], 0, 'q3/9'],
    ['taches-rousseur', '', 'des taches de rousseur', 'q3', [], 'Il a des taches de rousseur. Qu’est-ce qu’il a sur le nez et sur les joues ?', true, ['des taches de rousseur', 'des tasses de roucheur'], 0, 'q3/10'],
    ['lunettes', '', 'des lunettes', 'q3', [], 'Elle porte des lunettes. Elle en a besoin pour lire.', true, ['lunettes', 'languettes'], 0, 'q3/11'],
    ['cheveux-blonds', '', 'les cheveux blonds', 'q3', [], 'Marie a les cheveux blonds. Il est blond, elle est…', true, ['blonde', 'blondette'], 0, 'q3/12'],
    ['cheveux-bruns', '', 'les cheveux bruns', 'q3', [], 'Il a les cheveux bruns. Ses cheveux sont bleus.', true, ['non, bruns', 'non, roux'], 0, 'q3/13'],
    ['cheveux-chatains', '', 'les cheveux châtains', 'q3', [], 'Ses cheveux sont châtains. C’est entre blond et brun.', true, ['châtain', 'gris'], 0, 'q3/14'],
    ['cheveux-roux', '', 'les cheveux roux', 'q3', [], 'Elle a les cheveux roux. Ses cheveux sont roux, donc elle est…', true, ['rousse', 'rouxe'], 0, 'q3/15'],
    ['cheveux-gris', '', 'les cheveux gris', 'q3', [], 'Il a les cheveux gris. Thomas a les cheveux presque blancs.', true, ['gris', 'blond'], 0, 'q3/16'],
    ['cheveux-blancs', '', 'les cheveux blancs', 'q3', [], 'Cette dame a les cheveux blancs. Elle a quatre-vingt-six ans.', true, ['vieille', 'jeune'], 0, 'q3/17'],
    ['cheveux-courts', '', 'les cheveux courts', 'q3', [], 'Ses cheveux sont courts. Le contraire de cheveux longs.', true, ['cheveux courts', 'cheveux petits'], 0, 'q3/18'],
    ['cheveux-longs', '', 'les cheveux longs', 'q3', [], 'Elle a les cheveux longs. Ses cheveux descendent jusqu’au dos.', true, ['cheveux longs', 'cheveux courts'], 0, 'q3/19'],
    ['cheveux-raides', '', 'les cheveux raides', 'q3', [], 'Il a les cheveux raides. Il a de jolis cheveux bouclés.', true, ['faux', 'vrai'], 0, 'q3/20'],
    ['cheveux-frises', '', 'les cheveux frisés', 'q3', [], 'Ses cheveux sont frisés. Les cheveux avec de petites boucles serrées sont…', true, ['frisés', 'bouclés'], 0, 'q3/21'],
    ['barbe', '', 'une barbe', 'q3', [], 'Cet homme a une barbe. Un homme avec une barbe est…', true, ['un barbu', 'un barbet'], 0, 'q3/22'],
    ['moustache', '', 'une moustache', 'q3', [], 'Paul a une moustache. La moustache est sur le nez.', true, ['non', 'oui'], 0, 'q3/23', 'sous le nez'],
    ['grand', '', 'grand', 'q3', [], 'Il est très grand. Il mesure un mètre quatre-vingt-dix.', true, ['il est grand', 'il est long'], 0, 'q3/24'],
    ['petit', '', 'petit', 'q3', [], 'Son frère est petit. Il est haut comme trois pommes.', true, ['il est petit', 'il est court'], 0, 'q3/25'],
    ['grosse', '', 'grosse', 'q3', [], 'Cette femme est grosse. Elle a une silhouette ronde.', true, ['elle est grosse', 'elle est maigre'], 0, 'q3/26'],
    ['maigre', '', 'maigre', 'q3', [], 'Elle est très maigre. Les top models sont très fines.', true, ['elles sont maigres', 'elles sont raides'], 0, 'q3/27'],
    ['muscle', '', 'musclé', 'q3', [], 'Cet homme est musclé. Quand on fait beaucoup de sport.', true, ['musclé', 'frisé'], 0, 'q3/28']
  ];

  /* Ce que montre vraiment chaque portrait (dicté par l'autrice le 21 septembre).
     c : couleur des cheveux, f : longueur et texture, y : yeux, m : ce qui se
     remarque, s : silhouette, a : âge. Sert à écarter les leurres qui
     répondraient à la place de la bonne image. */
  const PORTRAITS = {
    'yeux-gris': { c: 'blonds', f: ['courts'], y: 'gris' },
    'cheveux-boucles': { c: 'chatains', f: ['longs', 'boucles'], y: 'marron' },
    'chauve': { c: null, f: [], y: 'bleus', m: ['chauve'] },
    'mince': { c: 'blonds', f: ['longs'], y: 'marron', s: 'mince' },
    'jeune': { c: 'bruns', f: ['courts', 'frises'], y: 'marron', a: 'jeune' },
    'vieux': { c: 'gris', f: ['courts'], y: 'bleus', a: 'vieux' },
    'yeux-marron': { c: 'chatains', f: ['longs'], y: 'marron' },
    'yeux-bleus': { c: 'blonds', f: ['courts'], y: 'bleus' },
    'yeux-verts': { c: 'blonds', f: ['longs'], y: 'verts' },
    /* Ses cheveux roux bouclés sont aussi courts : sans ça, il sortait en même
       temps que la carte « ses cheveux sont courts ». */
    'taches-rousseur': { c: 'roux', f: ['boucles', 'courts'], y: 'verts', m: ['rousseur'] },
    'lunettes': { c: 'chatains', f: ['longs'], y: 'marron', m: ['lunettes'] },
    'cheveux-blonds': { c: 'blonds', f: ['longs'], y: 'bleus' },
    'cheveux-bruns': { c: 'bruns', f: ['courts'], y: 'marron' },
    'cheveux-chatains': { c: 'chatains', f: ['courts'], y: 'bleus' },
    'cheveux-roux': { c: 'roux', f: ['longs'], y: 'gris', m: ['rousseur'] },
    'cheveux-gris': { c: 'gris', f: ['courts'], y: 'bleus' },
    'cheveux-blancs': { c: 'blancs', f: ['courts'], y: 'bleus' },
    'cheveux-courts': { c: 'chatains', f: ['courts'], y: 'marron' },
    'cheveux-longs': { c: 'blonds', f: ['longs'], y: 'bleus' },
    'cheveux-raides': { c: '?', f: ['courts', 'raides'], y: 'marron' },
    'cheveux-frises': { c: 'bruns', f: ['frises'], y: 'marron', m: ['rousseur'] },
    'barbe': { c: 'chatains', f: ['courts'], y: 'gris', m: ['barbe'] },
    'moustache': { c: 'chatains', f: ['courts'], y: 'marron', m: ['moustache'] },
    'grand': { c: 'chatains', f: ['courts'], y: 'marron', s: 'grand' },
    'petit': { c: 'chatains', f: ['courts'], y: 'gris', s: 'petit' },
    'grosse': { c: 'chatains', f: ['longs'], y: 'marron', s: 'grosse' },
    'maigre': { c: 'chatains', f: ['longs'], y: 'gris', s: 'maigre' },
    'muscle': { c: 'chatains', f: ['courts'], y: 'gris', s: 'muscle' }
  };

  const P_YEUX = { 'yeux-gris': 'gris', 'yeux-marron': 'marron', 'yeux-bleus': 'bleus', 'yeux-verts': 'verts' };
  const P_COUL = { 'cheveux-blonds': 'blonds', 'cheveux-bruns': 'bruns', 'cheveux-chatains': 'chatains', 'cheveux-roux': 'roux', 'cheveux-gris': 'gris', 'cheveux-blancs': 'blancs' };
  const P_FORME = { 'cheveux-courts': 'courts', 'cheveux-longs': 'longs', 'cheveux-raides': 'raides', 'cheveux-frises': 'frises', 'cheveux-boucles': 'boucles' };
  const P_MARQ = { 'lunettes': 'lunettes', 'barbe': 'barbe', 'moustache': 'moustache', 'chauve': 'chauve', 'taches-rousseur': 'rousseur' };
  const P_SIL = { 'grand': 'grand', 'petit': 'petit', 'grosse': 'grosse', 'maigre': 'maigre', 'mince': 'mince', 'muscle': 'muscle' };
  const P_AGE = { 'jeune': 'jeune', 'vieux': 'vieux' };

  /* Ce portrait pourrait-il répondre à cette phrase ? */
  function repondAussi(carte, p) {
    if (!p) return true;
    if (P_YEUX[carte]) return p.y === P_YEUX[carte];
    if (P_COUL[carte]) return p.c === P_COUL[carte] || p.c === '?';
    if (P_FORME[carte]) {
      const x = P_FORME[carte];
      if (x === 'courts' || x === 'longs') return (p.f || []).indexOf(x) >= 0;
      const t = (p.f || []).filter(v => v === 'raides' || v === 'frises' || v === 'boucles')[0];
      return !t || t === x;
    }
    if (P_MARQ[carte]) return (p.m || []).indexOf(P_MARQ[carte]) >= 0;
    if (P_SIL[carte]) return p.s === P_SIL[carte];
    if (P_AGE[carte]) return p.a === P_AGE[carte];
    return false;
  }

  /* Pour chaque carte, les portraits qui ne peuvent pas répondre à sa place. */
  const LEURRES_PHYSIQUE = {};
  Object.keys(PORTRAITS).forEach(k => {
    LEURRES_PHYSIQUE[k] = Object.keys(PORTRAITS).filter(o => o !== k && !repondAussi(k, PORTRAITS[o]));
  });

  /* Niveau Jolie, épreuve 1 : l'intrus (24 septembre 2026, séries refaites le 25). Quatre mots écrits,
     dans l'ordre donné par l'autrice ; seul l'intrus a sa gravure, qui paraît
     quand on l'a trouvé. [clef, '', intrus, 'intrus', [], les quatre dits, null, les quatre]
     L'ordre des quatre mots est celui de l'enregistrement de Laurence (vérifié série par
     série le 5 octobre) : le numéro de la case doit être la place du mot dans le son. */
  const INTRUS = [
    ['matin', ['lundi', 'mardi', 'matin', 'jeudi']],
    ['tambour', ['guitare', 'violon', 'flûte', 'tambour']],
    ['clair', ['orange', 'rouge', 'vert', 'clair']],
    ['printemps', ['février', 'avril', 'novembre', 'printemps']],
    ['bouquet', ['rose', 'tulipe', 'marguerite', 'bouquet']],
    ['ballon', ['gardien', 'entraîneur', 'joueur', 'ballon']],
    ['moineau', ['lapin', 'hamster', 'cochon d’Inde', 'moineau']],
    ['chameau', ['autruche', 'poule', 'canard', 'chameau']],
    ['compote', ['pomme', 'compote', 'poire', 'pêche']],
    ['train', ['train', 'montgolfière', 'hélicoptère', 'avion']],
    ['ceinture', ['manteau', 'ceinture', 'chemise', 'pantalon']],
    ['moto', ['VTT', 'moto', 'bicyclette', 'vélo']],
    ['table', ['tabouret', 'table', 'fauteuil', 'chaise']],
    ['assiette', ['couteau', 'cuillère', 'assiette', 'fourchette']],
    ['blanquette', ['tiramisu', 'blanquette', 'risotto', 'mozzarella']],
    ['noblesse', ['reine', 'princesse', 'noblesse', 'roi']],
    ['ventre', ['coude', 'ventre', 'cheville', 'genou']],
    ['laid', ['beau', 'joli', 'laid', 'mignon']],
    ['librairie', ['librairie', 'boulangerie', 'boucherie', 'épicerie']],
    ['poivre', ['persil', 'poivre', 'romarin', 'menthe']],
    ['lacets', ['bottes', 'chaussons', 'lacets', 'souliers']],
    ['depuis', ['hier', 'depuis', 'demain', 'aujourd’hui']],
    ['parfum', ['caramel', 'parfum', 'pistache', 'vanille']],
    ['odieux', ['sympa', 'gentil', 'odieux', 'amical']],
    ['quiche', ['chocolatine', 'croissant', 'quiche', 'chausson aux pommes']],
    ['coquillage', ['coquillage', 'caillou', 'galet', 'pierre']],
    ['rideau', ['serviette', 'torchon', 'nappe', 'rideau']],
    ['casserole', ['four', 'micro-ondes', 'casserole', 'frigo']]
  ].map(([clef, quatre]) => {
    const intrus = quatre.find(w => w.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ /g, '-') === clef) || quatre[2];
    return [clef, '', intrus, 'intrus', [], quatre.join(', ') + '.', null, quatre];
  });

  /* Niveau Jolie, épreuve 2 : la devinette (texte de Laurence, 5 octobre). On entend la
     définition, on touche la bonne image parmi quatre ; les trois autres sont tirées
     au hasard parmi toutes les images du niveau (choix de Laurence : on ne refait
     jamais la même partie). [clef, article, mot, 'devinette', [], phrase dite] */
  const LEURRES_JOLIE = ['serpent', 'igloo', 'canari', 'poignard', 'chien', 'poisson', 'arrosoir', 'bonnet', 'piece', 'casque'];
  const DEVINETTES = [
    ['matin', 'le', 'matin', 'Le début de la journée.'],
    ['tambour', 'un', 'tambour', 'On le frappe avec les mains ou des baguettes.'],
    ['serpent', 'un', 'serpent', 'Animal au corps très allongé et sans membres.'],
    ['printemps', 'le', 'printemps', 'Les feuilles repoussent sur les arbres.'],
    ['bouquet', 'un', 'bouquet', 'On l’achète chez le fleuriste.'],
    ['ballon', 'un', 'ballon', 'Indispensable au volley-ball.'],
    ['poisson', 'un', 'poisson', 'On le pêche.'],
    ['chameau', 'un', 'chameau', 'Il marche dans le désert.'],
    ['compote', 'la', 'compote', 'Les bébés en mangent souvent.'],
    ['train', 'un', 'train', 'On le prend dans une gare.'],
    ['ceinture', 'une', 'ceinture', 'Le pantalon aime cet accessoire en cuir.'],
    ['moto', 'une', 'moto', 'Un casque est obligatoire pour l’utiliser.'],
    ['table', 'une', 'table', 'On s’assoit autour pour manger.'],
    ['assiette', 'une', 'assiette', 'On mange dedans.'],
    ['blanquette', 'la', 'blanquette', 'Un plat de veau, avec une sauce blanche.'],
    ['chien', 'un', 'chien', 'Cet animal aboie et joue à la balle.'],
    ['piece', 'une', 'pièce', 'Rond, en métal, dans la poche ou le porte-monnaie.'],
    ['laid', '', 'laid', 'Contraire de beau.'],
    ['librairie', 'une', 'librairie', 'Le magasin qui vend les livres.'],
    ['poivre', 'le', 'poivre', 'L’ami du sel dans un plat.'],
    ['lacets', 'des', 'lacets', 'Petites cordes sur les sneakers.'],
    ['arrosoir', 'un', 'arrosoir', 'Il transporte bien l’eau pour les plantes.'],
    ['parfum', 'un', 'parfum', 'Une odeur merveilleuse en bouteille.'],
    ['bonnet', 'un', 'bonnet', 'En laine, il tient la tête au chaud.'],
    ['quiche', 'une', 'quiche', 'C’est une tarte, spécialité de Lorraine.'],
    ['coquillage', 'un', 'coquillage', 'On le ramasse sur la plage.'],
    ['rideau', 'un', 'rideau', 'Un long tissu devant la fenêtre.'],
    ['casserole', 'une', 'casserole', 'En métal, avec une poignée, pour cuire les pâtes.']
  ].map(([clef, art, mot, phrase]) => [clef, art, mot, 'devinette', [], phrase]);
  /* Niveau Jolie, épreuve 3 : les diminutifs (liste arrêtée avec Laurence le 24 octobre, sons du 26).
     On entend la question, quatre mots écrits, un seul juste ; la bonne réponse change de place.
     Juste : liséré vert et la gravure de la chose paraît. Raté : croix dorée, on passe.
     Les leurres sont inventés. [clef, '', bonne, 'diminutif', [], question, null, les quatre] */
  const DIMINUTIFS = [
    ['maisonnette', 'Quel est le diminutif de maison ?', ['maisonnet', 'maisonneau', '*maisonnette', 'maisonnot']],
    ['lionceau', 'Comment appelle-t-on le petit du lion ?', ['lionnet', '*lionceau', 'lionnette', 'lionnot']],
    ['fourchette', 'Le diminutif de fourche ?', ['fourcheau', 'fourchot', 'fourchillon', '*fourchette']],
    ['ilot', 'Le diminutif d’île ?', ['îlette', '*îlot', 'îleau', 'îlon']],
    ['caneton', 'Le petit du canard ?', ['canardeau', '*caneton', 'canardet', 'canardon']],
    ['coffret', 'Le diminutif de coffre ?', ['*coffret', 'coffrette', 'coffreau', 'coffrillon']],
    ['brindille', 'Le diminutif de brin ?', ['brinet', 'brineau', 'brinot', '*brindille']],
    ['chaton', 'Le petit du chat ?', ['chatet', 'chatot', 'chatelle', '*chaton']],
    ['ruelle', 'Le diminutif de rue ?', ['ruot', 'rueton', '*ruelle', 'ruillon']],
    ['cotelette', 'Le diminutif de côte ?', ['côtet', '*côtelette', 'côtot', 'côtelon']],
    ['louveteau', 'Le petit du loup ?', ['loupeau', 'loupette', 'loupiot', '*louveteau']],
    ['clochette', 'Le diminutif de cloche ?', ['clocheau', '*clochette', 'clochot', 'clochillon']],
    ['aileron', 'Le diminutif d’aile ?', ['*aileron', 'aileau', 'ailot', 'ailillon']],
    ['osselet', 'Le diminutif d’os ?', ['*osselet', 'osson', 'osseau', 'ossillon']],
    ['anon', 'Le petit de l’âne ?', ['ânet', '*ânon', 'ânelet', 'âneau']],
    ['tartelette', 'Le diminutif de tarte ?', ['tartot', 'tarteau', 'tartillon', '*tartelette']],
    ['camionnette', 'Le diminutif de camion ?', ['camionnet', 'camionneau', 'camionnot', '*camionnette']],
    ['menotte', 'Le diminutif de main ?', ['mainette', 'maineau', '*menotte', 'mainillon']],
    ['lapereau', 'Le petit du lapin ?', ['*lapereau', 'lapinet', 'lapinette', 'lapinon']],
    ['livret', 'Le diminutif de livre ?', ['livrette', 'livreau', '*livret', 'livrillon']],
    ['gouttelette', 'Le diminutif de goutte ?', ['gouttillon', 'goutteau', 'gouttot', '*gouttelette']],
    ['cabanon', 'Le diminutif de cabane ?', ['cabanette', 'cabaneau', '*cabanon', 'cabanot']],
    ['oisillon', 'Le petit de l’oiseau ?', ['oiseauton', 'oisot', '*oisillon', 'oisette']],
    ['jardinet', 'Le diminutif de jardin ?', ['*jardinet', 'jardinot', 'jardinette', 'jardineau']],
    ['moucheron', 'Le diminutif de mouche ?', ['mouchot', '*moucheron', 'moucheau', 'mouchillon']],
    ['fillette', 'Le diminutif de fille ?', ['*fillette', 'fillon', 'filleau', 'fillotte']],
    ['ourson', 'Le petit de l’ours ?', ['oursot', 'oursette', '*ourson', 'oursillon']],
    ['arbrisseau', 'Le diminutif d’arbre ?', ['arbret', '*arbrisseau', 'arbrette', 'arbrillon']]
  ].map(([clef, question, props]) => {
    const quatre = props.map(w => w.replace('*', ''));
    const bonne = props.find(w => w[0] === '*').slice(1);
    return [clef, '', bonne, 'diminutif', [], question, null, quatre];
  });

  /* Toutes les images du niveau : les 28 gravures de l'intrus et les images en plus. */
  const IMAGES_JOLIE = INTRUS.map(m => m[0]).concat(LEURRES_JOLIE.filter(c => !INTRUS.some(m => m[0] === c)));

  /* Un paquet : ses mots, son dossier, et les quizz qu'il sait jouer.
     « images » : on entend, on choisit la gravure.
     « familles » : fruit ou légume.  « vrai » : la phrase est-elle juste. */
  const PAQUETS = [
    { clef: 'aliments', nom: 'Les fruits et les légumes', sous: '28 mots', mots: ALIMENTS, modes: ['images', 'familles', 'vrai'], phrase: false, volume: 0.4 },
    { clef: 'physique', nom: 'L’aspect physique', sous: '28 caractéristiques', mots: PHYSIQUE, modes: ['images', 'portrait', 'question'], phrase: true, leurres: LEURRES_PHYSIQUE, largeurFixe: 2, questions: QUESTIONS_PHYSIQUE, volume: 0.6 },
    /* Niveau Jolie : l'intrus, quatre cases toujours (demandé par l'autrice). */
    { clef: 'jolie', nom: 'L’intrus', noms: ['L’intrus', 'La devinette', 'Les diminutifs'], sous: '28 séries', mots: INTRUS, devinettes: DEVINETTES, diminutifs: DIMINUTIFS, modes: ['intrus', 'devinette', 'diminutif'], phrase: true }
  ];

  const melanger = t => {
    const a = t.slice();
    for (let k = a.length - 1; k > 0; k--) {
      const j = Math.floor(Math.random() * (k + 1));
      const x = a[k]; a[k] = a[j]; a[j] = x;
    }
    return a;
  };

  /* Une couleur par niveau : c'est le fond du jeu, et c'est elle que porte la
     boîte du menu. */
  const FONDS = ['#748661', '#907070', '#03697D', 'rgb(140,122,153)', '#D19043'];
  /* La couleur du niveau à 80 % : c'est celle du bouton d'écoute. */
  const FONDS_80 = ['rgba(116,134,97,0.8)', 'rgba(144,112,112,0.8)', 'rgba(3,105,125,0.8)', 'rgba(140,122,153,0.8)', 'rgba(209,144,67,0.8)'];

  /* ————— Le moteur, repris trait pour trait de la maquette d'atelier ————— */
  class Jeu extends React.Component {
    constructor(props) {
      super(props);
      this.state = { ordre: [], i: 0, cases: [], etat: 'attente', choisi: null,
        points: 0, justes: 0, manquantes: {}, sonne: false, ecoutes: 0 };
      this.sondees = {};
    }

    get paquet() { return this.props.paquet; }
    get mots() {
      if (this.mode === 'devinette' && this.paquet.devinettes) return this.paquet.devinettes;
      if (this.mode === 'diminutif' && this.paquet.diminutifs) return this.paquet.diminutifs;
      return this.mode === 'question' && this.paquet.questions ? this.paquet.questions : this.paquet.mots;
    }
    get mode() { return this.paquet.modes[Math.min(this.props.quizz - 1, this.paquet.modes.length - 1)] || 'images'; }
    /* Le portrait et la question se jouent sur une seule gravure : cadre serré. */
    get serre() { return this.mode === 'portrait' || this.mode === 'question'; }
    /* Vingt-huit cartes, seize pour passer : la même porte pour tous les paquets. */
    seuil() { return 16; }

    componentDidMount() { this.nouveauPaquet(); }
    componentWillUnmount() { this.arreter(); }
    componentDidUpdate(avant) {
      if (avant.quizz !== this.props.quizz || avant.paquet !== this.props.paquet) this.nouveauPaquet();
    }

    arreter() {
      this.jeton = (this.jeton || 0) + 1;
      clearTimeout(this.minuteur);
      clearTimeout(this.pulsation);
      if (this.piste) {
        /* On détache l'écoute d'erreur avant de couper : une piste abandonnée
           ne doit plus rien déclencher, sinon deux mots se parlent dessus. */
        try { this.piste.onerror = null; this.piste.pause(); } catch (e) {}
        this.piste = null;
      }
      try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (e) {}
    }

    nouveauPaquet() {
      const ordre = melanger(this.mots.map((_, k) => k));
      this.setState({ ordre, i: 0, points: 0, justes: 0, etat: 'attente', choisi: null },
        () => { if (this.props.onPoints) this.props.onPoints(0); this.poserCarte(0, ordre); });
    }

    /* Deux propositions sur le premier tiers, trois, puis quatre : la montée
       validée le 20 septembre. Ne pas la recalculer. */
    largeur(rang) {
      /* Certains paquets se jouent toujours deux par deux : le portrait ne
         supporte pas la case large des questions à trois. */
      if (this.paquet.largeurFixe) return this.paquet.largeurFixe;
      const n = this.mots.length;
      if (rang < n * 0.3) return 2;
      if (rang < n * 0.65) return 3;
      return 4;
    }

    poserCarte(rang, ordre) {
      const liste = ordre || this.state.ordre;
      if (rang >= liste.length) {
        this.setState({ etat: 'fini' });
        /* Le compteur anonyme du bureau : l'épreuve, le niveau, passée ou non. */
        if (window.mpmCompter) window.mpmCompter('challenge', this.props.quizz, this.props.niveau, this.state.justes >= this.seuil());
        return;
      }
      const mots = this.mots;
      const bonne = mots[liste[rang]];
      /* Le quizz du portrait : on montre une gravure, on dit une phrase — la
         sienne une fois sur deux, sinon celle d'une carte qu'elle ne peut pas
         porter. La réponse est fermée : vrai ou faux. */
      if (this.mode === 'portrait') {
        const leurres = this.paquet.leurres || {};
        const faux = Object.keys(leurres).filter(c => (leurres[c] || []).indexOf(bonne[0]) >= 0);
        const vrai = !faux.length || Math.random() < 0.5;
        const clefDite = vrai ? bonne[0] : melanger(faux)[0];
        const dite = mots[mots.findIndex(m => m[0] === clefDite)] || bonne;
        this.setState({ i: rang, cases: [], etat: 'attente', choisi: null, ecoutes: 0, attendu: vrai, dite },
          () => this.dire(dite));
        return;
      }
      /* Chaque question porte sa gravure et sa piste ; seules les deux cases
         changent de côté d'une venue à l'autre. */
      if (this.mode === 'question') {
        this.sonder(bonne[0]);
        this.setState({ i: rang, cases: melanger([0, 1]), etat: 'attente', choisi: null, ecoutes: 0 },
          () => this.dire(bonne));
        return;
      }
      if (this.mode === 'intrus') {
        this.sonder(bonne[0]);
        this.setState({ i: rang, cases: [0, 1, 2, 3], etat: 'attente', choisi: null, ecoutes: 0 },
          () => this.dire(bonne));
        return;
      }
      /* Les diminutifs : les quatre mots changent de place à chaque question. */
      if (this.mode === 'diminutif') {
        this.sonder(bonne[0]);
        this.setState({ i: rang, cases: melanger(bonne[7]), etat: 'attente', choisi: null, ecoutes: 0 },
          () => this.dire(bonne));
        return;
      }
      /* La devinette : quatre images toujours, trois tirées au hasard dans tout le niveau. */
      if (this.mode === 'devinette') {
        const autres = melanger(IMAGES_JOLIE.filter(c => c !== bonne[0])).slice(0, 3);
        const tirage = melanger([bonne[0]].concat(autres));
        tirage.forEach(c => this.sonder(c));
        this.setState({ i: rang, cases: tirage, etat: 'attente', choisi: null, ecoutes: 0 },
          () => this.dire(bonne));
        return;
      }
      if (this.mode !== 'images') {
        this.setState({ i: rang, cases: [], etat: 'attente', choisi: null, ecoutes: 0 },
          () => this.dire(bonne));
        return;
      }
      const combien = this.largeur(rang);
      const parClef = {};
      mots.forEach((m, k) => { parClef[m[0]] = k; });
      const pris = [liste[rang]];
      if (this.paquet.leurres) {
        /* La bonne image est fixe ; les autres sont tirées au hasard parmi les
           portraits qui ne peuvent pas répondre à sa place. */
        const surs = this.paquet.leurres[bonne[0]] || [];
        melanger(surs).forEach(nom => {
          const k = parClef[nom];
          if (pris.length < combien && k !== undefined && pris.indexOf(k) < 0) pris.push(k);
        });
      } else {
        /* Les propositions restent dans la famille de la bonne réponse. */
        const famille = bonne[3];
        melanger(bonne[4]).forEach(p => {
          const k = parClef[p];
          if (pris.length < combien && k !== undefined && mots[k][3] === famille && pris.indexOf(k) < 0) pris.push(k);
        });
        melanger(mots.map((_, k) => k)).forEach(k => {
          if (pris.length < combien && mots[k][3] === famille && pris.indexOf(k) < 0) pris.push(k);
        });
      }
      const tirage = melanger(pris);
      tirage.forEach(k => this.sonder(mots[k][0]));
      this.setState({ i: rang, cases: tirage, etat: 'attente', choisi: null, ecoutes: 0 },
        () => this.dire(bonne));
    }

    /* On essaie la gravure hors du document : tant qu'elle manque, la case
       porte le fleuron ivoire et le mot en or. */
    sonder(clef) {
      if (this.sondees[clef]) return;
      this.sondees[clef] = 1;
      const img = new Image();
      img.onerror = () => this.setState(st => (st.manquantes[clef] ? null
        : { manquantes: Object.assign({}, st.manquantes, { [clef]: 1 }) }));
      img.src = this.chemin(clef, 'image');
    }

    chemin(clef, genre) {
      const d = this.paquet.clef;
      const suite = this.mode === 'devinette' ? '-2' : this.mode === 'diminutif' ? '-3' : '';
      if (genre === 'son') return 'app/audio/quizz/' + d + suite + '/' + clef;
      const dossier = d === 'jolie' && LEURRES_JOLIE.indexOf(clef) >= 0 ? 'jolie/leurres' : this.mode === 'diminutif' ? d + '-3' : d;
      return 'app/img/quizz/' + dossier + '/' + clef + '.jpg';
    }

    /* Le mp3 d'abord, le wav en secours, la synthèse vocale en dernier — le
       temps que toutes les prises arrivent. */
    dire(mot) {
      this.arreter();
      const jeton = (this.jeton || 0) + 1;
      this.jeton = jeton;
      this.setState({ sonne: true });
      this.pulsation = setTimeout(() => this.setState({ sonne: false }), 1400);
      const parPhrase = this.paquet.phrase || this.mode === 'vrai';
      const dit = parPhrase ? mot[5] : (mot[1] + ' ' + mot[2]).trim();
      /* L'épreuve des questions garde les gravures des portraits, mais ses
         pistes lui sont propres : elles disent la phrase et la question. */
      const base = this.mode === 'question' && mot[9]
        ? 'app/audio/quizz/' + this.paquet.clef + '/' + mot[9]
        : this.mode === 'vrai' && !this.paquet.phrase
          ? 'app/audio/quizz/phrases/' + mot[0]
          : this.chemin(mot[0], 'son');
      /* Chaque paquet dit dans quel format il est rangé : on ne va pas chercher
         un mp3 qui n'existe pas — c'est ce qui faisait parler deux cartes
         l'une sur l'autre. */
      const formats = this.paquet.son === 'wav' ? ['.wav', '.mp3'] : ['.mp3', '.wav'];
      /* Les prises refaites changent de numéro de version : sinon le téléphone
         garde l'ancienne en mémoire (sons pris « cache d'abord »). */
      const version = this.mode === 'question' ? '?v=' + VERSION_Q3 : '';
      const a = new Audio(base + formats[0] + version);
      a.volume = this.paquet.volume || 0.8;
      this.piste = a;
      let secours = false;
      a.onerror = () => {
        /* Une piste abandonnée ne parle plus : ni son wav, ni voix de secours. */
        if (this.jeton !== jeton || this.piste !== a) return;
        if (!secours) { secours = true; a.src = base + formats[1] + version; a.load(); const q = a.play(); if (q && q.catch) q.catch(() => {}); return; }
        try {
          const s = window.speechSynthesis;
          if (!s) return;
          const v = new SpeechSynthesisUtterance(dit);
          v.lang = 'fr-FR'; v.rate = 0.9;
          s.cancel(); s.speak(v);
        } catch (e) {}
      };
      const p = a.play();
      /* Le navigateur refuse le son tant qu'on n'a pas touché l'écran : le rond
         du centre dit alors « Go ! », et le premier toucher lance la carte. */
      if (p && p.catch) p.catch(e => {
        if (e && e.name === 'NotAllowedError' && this.jeton === jeton) {
          clearTimeout(this.pulsation);
          this.setState({ go: true, sonne: false });
        }
      });
      /* Si la carte a tourné pendant le chargement, on se tait. */
      if (p && p.then) p.then(() => { if (this.jeton !== jeton) { try { a.pause(); } catch (e) {} } }, () => {});
    }

    reecouter() {
      if (this.state.go) { this.partir(); return; }
      if (this.state.ecoutes >= 1 || this.state.etat !== 'attente') return;
      /* Au quizz du portrait, c'est la phrase entendue qu'on rejoue, pas celle
         de la gravure. */
      const mot = this.mode === 'portrait' && this.state.dite
        ? this.state.dite
        : this.mots[this.state.ordre[this.state.i]];
      if (!mot) return;
      this.setState(s => ({ ecoutes: (s.ecoutes || 0) + 1 }));
      this.dire(mot);
    }

    /* Juste : on savoure. Raté : une croix dorée, et on passe — la réponse
       n'est pas donnée, c'est un quizz. */
    conclure(juste, choisi) {
      this.setState(s => ({
        etat: juste ? 'juste' : 'faux', choisi,
        points: juste ? s.points + 1 : s.points,
        justes: juste ? s.justes + 1 : s.justes
      }), () => { if (this.props.onPoints) this.props.onPoints(this.state.points); });
      clearTimeout(this.minuteur);
      /* À l'intrus, on laisse le temps de lire les quatre mots. */
      const pause = juste ? (this.mode === 'intrus' ? 2600 : this.mode === 'diminutif' ? 2000 : 1500) : 1000;
      this.minuteur = setTimeout(() => this.poserCarte(this.state.i + 1), pause);
    }

    partir() {
      const mot = this.mode === 'portrait' && this.state.dite
        ? this.state.dite
        : this.mots[this.state.ordre[this.state.i]];
      this.setState({ go: false });
      if (mot) this.dire(mot);
    }

    repondre(k) {
      if (this.state.go) { this.partir(); return; }
      if (this.state.etat !== 'attente') return;
      if (this.mode === 'devinette') {
        const m = this.mots[this.state.ordre[this.state.i]];
        this.conclure(!!m && k === m[0], k);
        return;
      }
      this.conclure(k === this.state.ordre[this.state.i], k);
    }

    repondreChoix(f) {
      if (this.state.go) { this.partir(); return; }
      if (this.state.etat !== 'attente') return;
      this.conclure(this.estJuste(f), f);
    }

    estJuste(f) {
      const mot = this.mots[this.state.ordre[this.state.i]];
      if (!mot) return false;
      if (this.mode === 'question') return f === mot[8];
      if (this.mode === 'intrus' || this.mode === 'diminutif') return f === mot[2];
      if (this.mode === 'portrait') return (f === 'vrai') === !!this.state.attendu;
      return (f === 'vrai' || f === 'faux') ? mot[6] === (f === 'vrai') : mot[3] === f;
    }

    /* ————— Les deux cases du quizz de révision ————— */
    /* complement : quelques mots qui paraissent, plus petits, sous la bonne
       réponse une fois trouvée (« non » → « sous le nez »). */
    caseChoix(f, libelle, taille, complement) {
      const s = this.state;
      const bonne = this.estJuste(f);
      const choisie = s.choisi === f;
      const mot = this.mots[s.ordre[s.i]];
      let fond = '#FFFFFF', ombre = 'none', opac = 1;
      if (s.etat === 'juste' && bonne) { fond = '#F2F4EC'; ombre = 'inset 0 0 0 4px ' + Q.vert; }
      else if (s.etat === 'faux' && choisie) { fond = '#F2F1EE'; opac = 0.6; }
      else if (s.etat !== 'attente') { opac = 0.32; }
      const montre = !this.serre && s.etat === 'juste' && bonne && mot && !s.manquantes[mot[0]];
      return (
        <button type="button" onPointerDown={() => this.repondreChoix(f)} style={{
          position: 'relative', aspectRatio: this.serre ? 'auto' : '4 / 3',
          minHeight: this.serre ? 64 : 0, padding: 0, overflow: 'hidden', border: 0,
          cursor: s.etat === 'attente' ? 'pointer' : 'default', background: fond, boxShadow: ombre, opacity: opac,
          transition: 'opacity 220ms ease-out, background 180ms ease-out, box-shadow 180ms ease-out'
        }}>
          <div style={{ position: 'absolute', inset: 0, transition: 'opacity 240ms ease-out', opacity: montre ? 1 : 0,
            backgroundImage: montre ? "url('" + this.chemin(mot[0], 'image') + "')" : 'none',
            backgroundSize: 'cover', backgroundPosition: 'center' }} />
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
            padding: 12, boxSizing: 'border-box', textAlign: 'center', transition: 'opacity 200ms ease-out',
            opacity: montre ? 0 : 1, fontFamily: SERIF_Q, fontWeight: 600, fontSize: taille || 30, lineHeight: 1.1,
            flexDirection: 'column', gap: 0, paddingTop: complement ? 4 : 12, paddingBottom: complement ? 8 : 12,
            color: s.etat !== 'attente' && !bonne ? '#8A93A6' : Q.navy }}>
            <span>{libelle}</span>
            {complement && s.etat === 'juste' && bonne ? (
              <span style={{ fontSize: 18, fontWeight: 500, fontStyle: 'italic', lineHeight: 1, marginTop: -2 }}>{complement}</span>
            ) : null}
          </div>
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
            pointerEvents: 'none', transition: 'opacity 200ms ease-out',
            opacity: s.etat === 'faux' && choisie ? 1 : 0, fontFamily: SERIF_Q, fontWeight: 600,
            fontSize: 104, lineHeight: 1, color: Q.or, textShadow: '0 2px 16px rgba(251,246,236,0.95)' }}>✕</div>
        </button>
      );
    }

    /* L'entête validée : Sortir à gauche, le nom du paquet au centre, le
       cartouche Niveau + madeleine à droite. Pas de points ici. */
    entete() {
      return (
        <div style={{ background: Q.navy, padding: '14px 20px 18px', display: 'flex',
          flexDirection: 'column', gap: 14 }}>
          {/* Trois colonnes : le titre reste au centre mais ne peut plus mordre sur
              Sortir ni sur le cartouche (téléphone de Laurence, 5 octobre). */}
          <div style={{ display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr) auto', alignItems: 'center', gap: 10 }}>
            <button type="button" onPointerDown={() => this.props.onSortir && this.props.onSortir()}
              style={{ justifySelf: 'start', minHeight: 44, padding: '0 14px', borderRadius: 4,
                border: '1px solid ' + (FONDS[this.props.niveau] || '#748661'), background: 'transparent', color: '#EBE3D2',
                fontFamily: BODY_Q, fontSize: 18, cursor: 'pointer' }}>Sortir</button>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontFamily: SERIF_Q, fontWeight: 600, fontSize: 'clamp(18px, 5vw, 23px)', lineHeight: 1.1,
                color: Q.ivoire, textWrap: 'balance', textAlign: 'center' }}>{(this.paquet.noms && this.paquet.noms[this.props.quizz - 1]) || this.paquet.nom}</div>
            </div>
            <div style={{ justifySelf: 'end', display: 'flex', alignItems: 'center', gap: 7, minHeight: 44,
              boxSizing: 'border-box', padding: '5px 9px 6px 11px', borderRadius: 4,
              border: '0.5px solid ' + (FONDS[this.props.niveau] || '#748661'), background: 'transparent' }}>
              <span style={{ display: 'block', textAlign: 'left' }}>
                <span style={{ display: 'block', fontFamily: SANS_Q, fontWeight: 500, fontSize: 9,
                  letterSpacing: '0.26em', textTransform: 'uppercase', color: Q.bleuPale }}>Niveau</span>
                <span style={{ display: 'block', fontFamily: SERIF_Q, fontStyle: 'italic', fontWeight: 600,
                  fontSize: 20, lineHeight: 1.15, color: Q.or1, whiteSpace: 'nowrap',
                  textAlign: 'center' }}>{NIVEAUX[this.props.niveau] || ''}</span>
              </span>
              <img src="assets/icons/madeleine-gold.png" alt=""
                style={{ width: 24, height: 'auto', display: 'block', flex: 'none' }} />
            </div>
          </div>
        </div>
      );
    }

    /* Le cadre validé : filet d'or, coins à 6 px, ombre de papier, et le fond
       vert de jardin que l'autrice a choisi. */
    cadre(enfants) {
      return (
        <div style={{ padding: this.serre ? '10px 12px 12px' : '14px 12px 22px',
          display: 'flex', justifyContent: 'center' }}>
          <div style={{ width: '100%', maxWidth: 560, border: '1px solid ' + Q.or5, borderRadius: 6,
            boxShadow: '0 2px 26px rgba(28,43,79,0.09)', overflow: 'hidden',
            backgroundColor: FONDS[this.props.niveau] || '#748661' }}>{enfants}</div>
        </div>
      );
    }

    render() {
      const mots = this.mots;
      const total = mots.length;
      const seuil = this.seuil();
      const s = this.state;
      const bonne = s.ordre.length ? s.ordre[s.i] : null;
      const fini = s.etat === 'fini';
      const reussi = s.justes >= seuil;
      const mode = this.mode;

      /* Une seule famille de teinte, du bleu pâle à l'encre. */
      const teinte = k => {
        const t = total > 1 ? k / (total - 1) : 0;
        const a = [215, 222, 235], b = [22, 34, 60];
        return 'rgb(' + a.map((v, n) => Math.round(v + (b[n] - v) * t)).join(',') + ')';
      };
      const jalons = Array.from({ length: total }, (_, k) => {
        const fait = k < s.justes;
        const porte = k + 1 === seuil;
        /* Un pixel toutes les deux barres : la pente dit que ça se durcit. */
        const base = 13 + Math.round(k / 2);
        const h = porte ? base + 13 : fait ? base + 8 : base;
        return <div key={k} style={{ flex: 'none', width: 7, borderRadius: 1, height: h,
          transition: 'height 240ms ease-out', background: porte ? Q.vertClair : teinte(k) }} />;
      });
      const place = total ? ((seuil - 0.5) / total) * 100 : 50;
      const hautPorte = 13 + Math.round((seuil - 1) / 2) + 13;

      /* Le bouton d'écoute : sans contour doré, à la couleur du niveau à 80 %
         (23 septembre). Ovale couché sous la gravure des épreuves à portrait,
         ovale debout au centre du bloc d'images. */
      const teinteEcoute = FONDS_80[this.props.niveau] || FONDS_80[0];
      const rond = (pose, ovale) => (
        <button type="button" onPointerDown={() => this.reecouter()} aria-label={s.go ? 'Commencer' : 'Écouter encore une fois'}
          style={Object.assign({
            width: ovale === 'debout' ? 64 : ovale ? 112 : 46, height: ovale === 'debout' ? 112 : ovale ? 64 : 80, borderRadius: '50%', border: 0, background: teinteEcoute,
            color: '#F3E4C0', fontFamily: BODY_Q, fontSize: ovale ? 31 : 24, lineHeight: 1, padding: 0, display: 'flex',
            alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 10px rgba(28,43,79,0.18)',
            transition: 'opacity 200ms ease-out', position: 'relative',
            opacity: !s.go && (s.ecoutes >= 1 || s.etat !== 'attente') ? 0.4 : 1,
            cursor: !s.go && (s.ecoutes >= 1 || s.etat !== 'attente') ? 'default' : 'pointer' }, pose || {})}>
          <span style={{ position: 'absolute', inset: 0, borderRadius: '50%', border: '2px solid ' + teinteEcoute,
            pointerEvents: 'none', animation: s.sonne || s.go ? 'q-onde 1100ms ease-out infinite' : 'none',
            opacity: s.sonne || s.go ? 1 : 0 }} />
          {s.go
            ? <span style={{ position: 'relative', fontFamily: "'Cormorant Garamond', serif", fontStyle: 'italic', fontWeight: 600, fontSize: ovale ? 26 : 19, letterSpacing: '0.02em', whiteSpace: 'nowrap' }}>Go&nbsp;!</span>
            : <span style={{ position: 'relative', top: -1 }}>↻</span>}
        </button>
      );
      /* À l'intrus, l'ovale est debout mais garde sa taille : 64 × 112 (5 octobre). */
      const medaillon = rond({ position: 'absolute', left: '50%', top: '50%',
        transform: 'translate(-50%, -50%)', zIndex: 3 }, mode === 'intrus' || mode === 'devinette' || mode === 'diminutif' ? 'debout' : false);

      const grille = enfants => (
        <div style={{ position: 'relative', width: '100%', margin: '0 auto' }}>
          <div style={{ display: 'grid', gap: 2, background: Q.or5, border: '2px solid ' + Q.or5,
            borderRadius: 5, overflow: 'hidden', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>{enfants}</div>
          {medaillon}
        </div>
      );

      let jeu = null;
      if (mode === 'images' || mode === 'devinette') {
        const devinette = mode === 'devinette';
        const dite = devinette && bonne !== null ? mots[bonne] : null;
        jeu = grille(s.cases.map((k, rang) => {
          /* À la devinette, la case porte une clef d'image ; la bonne garde son article. */
          const m = devinette ? (dite && k === dite[0] ? dite : [k, '', k]) : mots[k];
          /* À trois propositions, la dernière tient les deux colonnes. */
          const large = s.cases.length === 3 && rang === 2;
          const estBonne = devinette ? !!dite && k === dite[0] : k === bonne;
          const estChoisie = k === s.choisi;
          const reveler = s.etat === 'juste' && estBonne;
          const eteinte = s.etat === 'faux' && estChoisie;
          let fond = '#FFFFFF', ombre = 'none', opac = 1;
          if (reveler) { fond = '#F2F4EC'; ombre = 'inset 0 0 0 4px ' + Q.vert; }
          else if (eteinte) { fond = '#F2F1EE'; opac = 0.6; }
          else if (s.etat !== 'attente') { opac = 0.32; }
          const sansGravure = !!s.manquantes[m[0]];
          return (
            <button key={m[0]} type="button" onPointerDown={() => this.repondre(k)} style={{
              position: 'relative', minHeight: 0, padding: 0, overflow: 'hidden', border: 0,
              gridColumn: large ? 'span 2' : 'auto', aspectRatio: large ? '8 / 3' : '4 / 3',
              cursor: s.etat === 'attente' ? 'pointer' : 'default', background: fond, boxShadow: ombre, opacity: opac,
              transition: 'opacity 220ms ease-out, background 180ms ease-out, box-shadow 180ms ease-out'
            }}>
              <div style={{ position: 'absolute', inset: 0,
                backgroundImage: sansGravure ? 'none' : "url('" + this.chemin(m[0], 'image') + "')",
                backgroundSize: 'cover', backgroundPosition: 'center' }} />
              {sansGravure && !reveler ? (
                <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
                  alignItems: 'center', justifyContent: 'center', gap: 8, color: Q.or7 }}>
                  <span style={{ fontFamily: SERIF_Q, fontSize: 30, color: '#C4AE7E' }}>⚜</span>
                  <span style={{ fontFamily: SANS_Q, fontWeight: 500, fontSize: 12, letterSpacing: '0.2em',
                    textTransform: 'uppercase' }}>{m[2]}</span>
                </div>
              ) : null}
              {reveler ? (
                <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
                  alignItems: 'center', justifyContent: 'center', gap: 4, padding: 10, boxSizing: 'border-box',
                  textAlign: 'center', background: 'rgba(251,246,236,0.94)' }}>
                  <div style={{ fontFamily: SERIF_Q, fontStyle: 'italic', fontWeight: 600, fontSize: 27, color: Q.or }}>Bravo</div>
                  <div style={{ fontFamily: SERIF_Q, fontWeight: 600, fontSize: 30, lineHeight: 1.1,
                    textWrap: 'balance', color: Q.navy }}>{(m[1] + ' ' + m[2]).trim()}</div>
                </div>
              ) : null}
              <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
                justifyContent: 'center', pointerEvents: 'none', transition: 'opacity 200ms ease-out',
                opacity: eteinte ? 1 : 0, fontFamily: SERIF_Q, fontWeight: 600, fontSize: 104, lineHeight: 1,
                color: Q.or, textShadow: '0 2px 16px rgba(251,246,236,0.95)' }}>✕</div>
            </button>
          );
        }));
      } else if (mode === 'intrus') {
        /* Au son (4 octobre) : les cases disent 1, 2, 3, 4. Trouvé, les quatre
           mots paraissent et l'intrus montre sa gravure ; raté, une croix et on passe. */
        const m = bonne !== null ? mots[bonne] : null;
        const quatre = m ? m[7] : ['', '', '', ''];
        const ouvert = s.etat === 'juste';
        jeu = grille(quatre.map((w, k) => {
          const estIntrus = m && w === m[2];
          const choisie = s.choisi === w;
          const eteinte = s.etat === 'faux' && choisie;
          const image = ouvert && estIntrus && !s.manquantes[m[0]];
          /* Une couleur par case, celles des niveaux (5 octobre) ; trouvé, tout repasse au blanc. */
          const COULEURS = ['#748661', '#907070', 'rgb(140,122,153)', '#D19043'];
          /* Trouvé, les cases gardent leur couleur (5 octobre : le blanc faisait « pas fini »). */
          let fond = COULEURS[k], ombre = 'none', opac = 1;
          if (ouvert && estIntrus) { ombre = 'inset 0 0 0 4px ' + Q.vert; }
          else if (eteinte) { opac = 0.6; }
          else if (s.etat === 'faux') { opac = 0.32; }
          const taille = w.length > 13 ? 22 : w.length > 9 ? 26 : 30;
          return (
            <button key={k} type="button" onPointerDown={() => this.repondreChoix(w)} style={{
              /* Les numéros tournent comme une horloge : le 3 sous le 2, le 4 sous le 1 (5 octobre). */
              gridRow: k < 2 ? 1 : 2, gridColumn: k === 0 || k === 3 ? 1 : 2,
              position: 'relative', aspectRatio: '4 / 3', minHeight: 0, padding: 0, overflow: 'hidden', border: 0,
              cursor: s.etat === 'attente' ? 'pointer' : 'default', background: fond, boxShadow: ombre, opacity: opac,
              transition: 'opacity 220ms ease-out, background 180ms ease-out, box-shadow 180ms ease-out' }}>
              {/* La gravure tient le haut de la case, entière ; le mot a sa propre bande dessous (5 octobre). */}
              <div style={{ position: 'absolute', top: 10, left: 10, right: 10, bottom: '30%', transition: 'opacity 240ms ease-out', opacity: image ? 1 : 0,
                backgroundColor: '#FBF6EC', border: '1px solid ' + Q.or5, borderRadius: 3, boxSizing: 'border-box',
                backgroundImage: image ? "url('" + this.chemin(m[0], 'image') + "')" : 'none',
                backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }} />
              <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
                transition: 'opacity 200ms ease-out', opacity: ouvert ? 0 : 1,
                fontFamily: BODY_Q, fontWeight: 500, fontSize: 50, lineHeight: 1, color: '#F3E4C0',
                /* Un filet encre fin et un ombré doux : le chiffre crème se détache de la couleur (5 octobre). */
                WebkitTextStroke: '1px rgba(20,31,58,0.55)',
                textShadow: '0 1px 2px rgba(20,31,58,0.55), 0 2px 10px rgba(20,31,58,0.35)' }}>{k + 1}</div>
              <div style={{ position: 'absolute', left: 0, right: 0, padding: '0 10px 4px', boxSizing: 'border-box',
                top: image ? 'auto' : 0, height: image ? '30%' : 'auto', bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
                textAlign: 'center', textWrap: 'balance', transition: 'opacity 200ms ease-out', opacity: ouvert ? 1 : 0,
                background: 'transparent',
                fontFamily: SERIF_Q, fontWeight: 600, fontSize: image ? Math.min(taille, 24) : taille, lineHeight: 1.1,
                color: k === 3 ? Q.navy : '#F3E4C0',
                /* Sur l'ocre, le crème ne se lit pas : le mot s'écrit à l'encre (5 octobre). */
                WebkitTextStroke: k === 3 ? '0' : '0.6px rgba(20,31,58,0.5)',
                textShadow: k === 3 ? '0 1px 6px rgba(251,246,236,0.55)' : '0 1px 2px rgba(20,31,58,0.55), 0 2px 10px rgba(20,31,58,0.35)' }}>{ouvert ? w : ''}</div>
              <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
                pointerEvents: 'none', transition: 'opacity 200ms ease-out', opacity: eteinte ? 1 : 0,
                fontFamily: SERIF_Q, fontWeight: 600, fontSize: 104, lineHeight: 1, color: Q.or,
                textShadow: '0 2px 16px rgba(251,246,236,0.95)' }}>✕</div>
            </button>
          );
        }));
      } else if (mode === 'diminutif') {
        /* Les quatre mots sont écrits dans les cases de couleur de l'intrus. Trouvé : liséré vert,
           la gravure paraît au-dessus du mot ; raté : croix dorée, on passe sans rien montrer. */
        const m = bonne !== null ? mots[bonne] : null;
        /* 27 octobre (Laurence) : plus de cases de couleur forte. Un seul pastel pour les quatre cases,
           qui change à chaque question : jaune, vert, bleu, rose, mauve, pêche. Mots à l'encre navy. */
        const PASTELS = ['#F6E9B8', '#DDE8CF', '#DAE3F0', '#F2DEDB', '#E6DEEC', '#F6E1CB'];
        const pastel = PASTELS[(s.i || 0) % PASTELS.length];
        jeu = grille(s.cases.map((w, k) => {
          const juste = !!m && w === m[2];
          const eteinte = s.etat === 'faux' && s.choisi === w;
          const image = s.etat === 'juste' && juste && !s.manquantes[m[0]];
          let ombre = 'none', opac = 1;
          if (s.etat === 'juste' && juste) ombre = 'inset 0 0 0 4px ' + Q.vert;
          else if (eteinte) opac = 0.6;
          else if (s.etat === 'faux') opac = 0.32;
          /* 27 octobre (Laurence : « on n'y voit rien ») : mots en blanc, plus gros, à la mesure de la case.
             Le mot le plus long tient d'un bord à l'autre ; les courts montent jusqu'à 46 px. */
          /* Une seule taille pour les quatre mots de la question, réglée sur le plus long (27 octobre). */
          const long = Math.max(5, ...s.cases.map(x => x.length));
          const ajuste = 'min(46px, ' + (180 / long).toFixed(1) + 'cqw)';
          const ajusteImage = 'min(28px, ' + (170 / long).toFixed(1) + 'cqw)';
          return (
            <button key={w} type="button" onPointerDown={() => this.repondreChoix(w)} style={{
              gridRow: k < 2 ? 1 : 2, gridColumn: k === 0 || k === 3 ? 1 : 2, containerType: 'inline-size',
              position: 'relative', aspectRatio: '4 / 3', minHeight: 0, padding: 0, overflow: 'hidden', border: 0,
              cursor: s.etat === 'attente' ? 'pointer' : 'default', background: s.etat === 'juste' && juste ? '#FFFFFF' : pastel, boxShadow: ombre, opacity: opac,
              transition: 'opacity 220ms ease-out, box-shadow 180ms ease-out, background 240ms ease-out' }}>
              {/* Trouvé (27 octobre) : la case passe au blanc, la gravure sans cadre et plus grande. */}
              <div style={{ position: 'absolute', top: 6, left: 6, right: 6, bottom: '24%', transition: 'opacity 240ms ease-out', opacity: image ? 1 : 0,
                backgroundImage: image ? "url('" + this.chemin(m[0], 'image') + "')" : 'none',
                backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }} />
              <div style={{ position: 'absolute', left: 0, right: 0, padding: '0 6px 4px', boxSizing: 'border-box',
                top: image ? 'auto' : 0, height: image ? '24%' : 'auto', bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
                textAlign: 'center', whiteSpace: 'nowrap',
                fontFamily: SERIF_Q, fontWeight: 700, fontSize: image ? ajusteImage : ajuste, lineHeight: 1.1,
                color: Q.navy }}>{w}</div>
              <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
                pointerEvents: 'none', transition: 'opacity 200ms ease-out', opacity: eteinte ? 1 : 0,
                fontFamily: SERIF_Q, fontWeight: 600, fontSize: 104, lineHeight: 1, color: Q.or,
                textShadow: '0 2px 16px rgba(251,246,236,0.95)' }}>✕</div>
            </button>
          );
        }));
      } else if (mode === 'familles') {
        jeu = grille([this.caseChoix('fruit', 'un fruit'), this.caseChoix('legume', 'un légume')]
          .map((e, k) => React.cloneElement(e, { key: k })));
      } else if (mode === 'portrait') {
        /* Une gravure au centre, le rond d'écoute sous elle, puis deux cases :
           à gauche Vrai, à droite Faux. */
        const m = bonne !== null ? mots[bonne] : null;
        const sansGravure = m ? !!s.manquantes[m[0]] : true;
        jeu = (
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
            <div style={{ position: 'relative', height: 'min(190px, 26vh)', aspectRatio: '4 / 3',
              width: 'auto', maxWidth: '100%',
              border: '2px solid ' + Q.or5, borderRadius: 5, overflow: 'hidden', background: '#FFFFFF' }}>
              <div style={{ position: 'absolute', inset: 0,
                backgroundImage: m && !sansGravure ? "url('" + this.chemin(m[0], 'image') + "')" : 'none',
                backgroundSize: 'cover', backgroundPosition: 'center' }} />
              {sansGravure ? (
                <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
                  justifyContent: 'center', fontFamily: SERIF_Q, fontSize: 40, color: '#C4AE7E' }}>⚜</div>
              ) : null}
            </div>
            <div style={{ marginTop: -36, zIndex: 3 }}>{rond(null, true)}</div>
            <div style={{ display: 'grid', gap: 2, width: '100%', background: Q.or5,
              border: '2px solid ' + Q.or5, borderRadius: 5, overflow: 'hidden',
              gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
              {[this.caseChoix('vrai', 'Vrai'), this.caseChoix('faux', 'Faux')]
                .map((e, k) => React.cloneElement(e, { key: k }))}
            </div>
          </div>
        );
      } else if (mode === 'question') {
        /* Même présentation que l'épreuve du portrait : la gravure, le rond
           d'écoute posé dessous, puis les deux réponses. */
        const m = bonne !== null ? mots[bonne] : null;
        const sansGravure = m ? !!s.manquantes[m[0]] : true;
        const choix = s.cases.length === 2 ? s.cases : [0, 1];
        const libelles = m ? m[7] : ['', ''];
        const longueur = Math.max(libelles[0].length, libelles[1].length);
        const taille = longueur > 16 ? 21 : longueur > 10 ? 25 : 30;
        jeu = (
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
            <div style={{ position: 'relative', height: 'min(190px, 26vh)', aspectRatio: '4 / 3',
              width: 'auto', maxWidth: '100%',
              border: '2px solid ' + Q.or5, borderRadius: 5, overflow: 'hidden', background: '#FFFFFF' }}>
              <div style={{ position: 'absolute', inset: 0,
                backgroundImage: m && !sansGravure ? "url('" + this.chemin(m[0], 'image') + "')" : 'none',
                backgroundSize: 'cover', backgroundPosition: 'center' }} />
              {sansGravure ? (
                <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
                  justifyContent: 'center', fontFamily: SERIF_Q, fontSize: 40, color: '#C4AE7E' }}>⚜</div>
              ) : null}
            </div>
            <div style={{ marginTop: -36, zIndex: 3 }}>{rond(null, true)}</div>
            <div style={{ display: 'grid', gap: 2, width: '100%', background: Q.or5,
              border: '2px solid ' + Q.or5, borderRadius: 5, overflow: 'hidden',
              gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
              {choix.map(o => React.cloneElement(this.caseChoix(o, libelles[o], taille, m && o === m[8] ? m[10] : null), { key: o }))}
            </div>
          </div>
        );
      } else {
        jeu = grille([this.caseChoix('vrai', 'Vrai'), this.caseChoix('faux', 'Faux')]
          .map((e, k) => React.cloneElement(e, { key: k })));
      }

      /* ————— Le diplôme : le dernier quizz du niveau est franchi —————
         La mascotte y parle avec la voix de Laurence (14 octobre), une phrase par niveau ;
         tant qu'une vidéo manque, la madeleine dorée garde sa place. Fond blanc de la
         vidéo fondu dans celui du diplôme (mix-blend-mode), un toucher la fait répéter. */
      const dernierQuizz = this.props.quizz >= this.props.quizzParNiveau;
      const diplome = fini && reussi && dernierQuizz;
      const rangN = this.props.niveau;
      const dernierNiveau = rangN >= NIVEAUX.length - 1;

      if (diplome) {
        return this.cadre(
          <React.Fragment>
          {this.entete()}
          <div style={{ padding: '30px 22px 34px' }}>
            <div style={{ position: 'relative', border: '1px solid ' + Q.or, background: '#FFFDF8',
              padding: '34px 22px 30px', textAlign: 'center' }}>
              <div style={{ position: 'absolute', inset: 5, border: '1px solid ' + Q.ligne, pointerEvents: 'none' }} />
              <div style={{ position: 'relative' }}>
                <div style={{ fontFamily: SANS_Q, fontWeight: 500, fontSize: 10, letterSpacing: '0.3em',
                  textTransform: 'uppercase', color: Q.or7 }}>
                  Diplôme — niveau {rangN + 1} sur {NIVEAUX.length}
                </div>
                {VIDEOS_DIPLOME[rangN]
                  ? <video src={VIDEOS_DIPLOME[rangN]} autoPlay playsInline preload="auto"
                      aria-label={'La mascotte : ' + nomNiveauComplet(rangN)}
                      onPointerDown={e => { const v = e.currentTarget; v.currentTime = 0; v.play().catch(() => {}); }}
                      style={{ width: 180, height: 180, display: 'block', margin: '10px auto 4px',
                        mixBlendMode: 'multiply', cursor: 'pointer' }} />
                  : <img src="assets/icons/madeleine-gold.png" alt=""
                      style={{ width: 78, height: 'auto', display: 'block', margin: '18px auto 12px' }} />}
                <div style={{ fontFamily: SERIF_Q, fontStyle: 'italic', fontWeight: 600, fontSize: 44,
                  lineHeight: 1.05, color: Q.navy, textWrap: 'balance' }}>{nomNiveauComplet(rangN)}</div>
                <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 14,
                  color: Q.or, margin: '18px 0' }}>
                  <span style={{ width: 54, height: 1, background: Q.or5 }} />
                  <span style={{ fontFamily: SERIF_Q, fontSize: 18 }}>⚜</span>
                  <span style={{ width: 54, height: 1, background: Q.or5 }} />
                </div>
                <div style={{ fontFamily: SERIF_Q, fontWeight: 600, fontSize: 27, lineHeight: 1.2, color: Q.or7 }}>Félicitations</div>
                <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 26 }}>
                  <button type="button" onPointerDown={() => (dernierNiveau ? this.nouveauPaquet() : this.props.onNiveauSuivant())}
                    style={{ minHeight: 54, padding: '0 30px', borderRadius: 4, cursor: 'pointer',
                      border: '1px solid ' + Q.or5, background: Q.or, color: '#FFFFFF',
                      fontFamily: BODY_Q, fontSize: 21, fontWeight: 600 }}>
                    {dernierNiveau ? 'Recommencer' : 'Suite'}
                  </button>
                </div>
              </div>
            </div>
          </div>
          </React.Fragment>
        );
      }

      if (fini) {
        return this.cadre(
          <React.Fragment>
          {this.entete()}
          <div style={{ padding: '30px 22px 34px' }}>
            <div style={{ position: 'relative', border: '1px solid ' + Q.or, background: '#FFFDF8',
              padding: '32px 22px 30px', textAlign: 'center' }}>
            <div style={{ position: 'absolute', inset: 5, border: '1px solid ' + Q.ligne, pointerEvents: 'none' }} />
            <div style={{ position: 'relative' }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
              color: Q.or, marginBottom: 22 }}>
              <span style={{ width: 70, height: 1, background: Q.or5 }} />
              <span style={{ fontFamily: SERIF_Q, fontSize: 20 }}>⚜</span>
              <span style={{ width: 70, height: 1, background: Q.or5 }} />
            </div>
            <div style={{ fontFamily: SERIF_Q, fontWeight: 600, fontStyle: 'italic', fontSize: 40,
              lineHeight: 1.1, color: Q.navy }}>{reussi ? 'Bravo !' : 'On y retourne'}</div>
            <div style={{ fontFamily: BODY_Q, fontWeight: 600, fontSize: 66, lineHeight: 1,
              color: Q.or, margin: '16px 0 4px' }}>{s.justes} / {total}</div>
            {/* Côte à côte s'il y a la place, l'un sous l'autre sur un téléphone étroit
                (« Recommencer » débordait sur le Huawei de Laurence, 5 octobre). */}
            <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'stretch', justifyContent: 'center',
              gap: 10, marginTop: 30 }}>
              <button type="button" onPointerDown={() => this.nouveauPaquet()}
                style={{ flex: '1 1 170px', whiteSpace: 'nowrap', minHeight: 54, padding: '0 16px', borderRadius: 4, cursor: 'pointer',
                  border: '1px solid ' + Q.ligne, background: 'transparent', color: Q.or7,
                  fontFamily: BODY_Q, fontSize: 19 }}>
                Recommencer
              </button>
              {reussi ? (
                <button type="button" onPointerDown={() => this.props.onQuizzSuivant()}
                  style={{ flex: '1 1 170px', whiteSpace: 'nowrap', minHeight: 54, padding: '0 16px', borderRadius: 4, cursor: 'pointer',
                    fontFamily: BODY_Q, fontSize: 20, fontWeight: 600,
                    border: '1px solid ' + Q.or5, background: Q.or, color: Q.navy }}>
                  Épreuve suivante
                </button>
              ) : null}
            </div>
            </div>
            </div>
          </div>
          </React.Fragment>
        );
      }

      return this.cadre(
        <React.Fragment>
        {this.entete()}
        <div style={{ padding: this.serre ? '14px 22px 10px' : '16px 22px 30px' }}>
          <div style={{ position: 'relative', height: 62, marginBottom: 18 }}>
            <div style={{ position: 'absolute', left: 0, top: 0, fontFamily: SANS_Q, fontWeight: 500,
              fontSize: 11, letterSpacing: '0.2em', textTransform: 'uppercase', color: Q.or1,
              whiteSpace: 'nowrap' }}>{(s.i + 1)}/{total}</div>
            <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, display: 'flex',
              alignItems: 'flex-end', justifyContent: 'space-between' }}>{jalons}</div>
            <div style={{ position: 'absolute', bottom: hautPorte + 3, left: place.toFixed(2) + '%',
              transform: 'translateX(-50%)', fontFamily: SERIF_Q, fontSize: 19, lineHeight: 1,
              color: s.justes >= seuil ? Q.or7 : Q.or, transition: 'color 240ms ease-out',
              pointerEvents: 'none' }}>⚜</div>
            <div style={{ fontFamily: SANS_Q, fontWeight: 500, fontSize: 10, letterSpacing: '0.26em',
              textTransform: 'uppercase', color: Q.or1, textAlign: 'right' }}>
              Challenge {this.props.quizz}/{this.props.quizzParNiveau}
            </div>
          </div>
          {jeu}
        </div>
        </React.Fragment>
      );
    }
  }

  /* ————— Le menu : les cinq niveaux ————— */
  function MenuQuizz({ onNiveau }) {
    /* Chaque niveau porte la couleur de son fond de jeu. Les deux derniers
       attendent le choix de l'autrice. */
    const fonds = FONDS;
    /* Toutes les écritures en crème : cinq boîtes, une seule encre. */
    const encres = ['#F3E4C0', '#F3E4C0', '#F3E4C0', '#F3E4C0', '#F3E4C0'];
    return (
      <div style={{ padding: '8px 14px 12px', display: 'flex', flexDirection: 'column', gap: 7 }}>
        {NIVEAUX.map((nom, k) => (
          <button key={nom} type="button" onPointerDown={() => onNiveau(k)}
            style={{ position: 'relative', display: 'block', width: '100%', textAlign: 'left',
              padding: 0, border: '1px solid ' + Q.or5, borderRadius: 5, cursor: 'pointer',
              background: fonds[k], overflow: 'hidden' }}>
            <span style={{ position: 'absolute', inset: 4, border: '1px solid ' + Q.ligne,
              borderRadius: 3, pointerEvents: 'none' }} />
            <span style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 11,
              padding: '8px 14px', minHeight: 62, boxSizing: 'border-box' }}>
              <span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 7 }}>
                <img src="assets/icons/madeleine-gold.png" alt=""
                  style={{ width: 24, height: 'auto', display: 'block' }} />
                <span style={{ fontFamily: SANS_Q, fontWeight: 500, fontSize: 9, letterSpacing: '0.18em',
                  color: encres[k] }}>{['I', 'II', 'III', 'IV', 'V'][k]}</span>
              </span>
              <span style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
                <span style={{ fontFamily: SANS_Q, fontWeight: 500, fontSize: 9, letterSpacing: '0.26em',
                  textTransform: 'uppercase', color: encres[k] }}>Challenge</span>
                <span style={{ fontFamily: SERIF_Q, fontStyle: 'italic', fontWeight: 600, fontSize: 23,
                  lineHeight: 1.05, color: '#FBF6EC' }}>{nom} madeleine</span>
              </span>
            </span>
          </button>
        ))}
      </div>
    );
  }

  /* ————— Le module : le menu, puis le jeu ————— */
  function Quizz({ onSortir }) {
    const [niveau, setNiveau] = useState(null);
    const [quizz, setQuizz] = useState(1);
    const parNiveau = 3;
    /* La forme est la même aux cinq niveaux ; c'est le contenu qui durcit.
       Un paquet par niveau : mini = les aliments, petite = l'aspect physique.
       Les trois suivants attendent les mots de l'autrice. */
    const paquet = PAQUETS[Math.min(niveau, PAQUETS.length - 1)];

    if (niveau === null) return <MenuQuizz onNiveau={k => { setNiveau(k); setQuizz(1); }} />;

    return (
      <Jeu paquet={paquet} niveau={niveau} quizz={quizz} quizzParNiveau={parNiveau}
        onQuizzSuivant={() => setQuizz(q => (q >= parNiveau ? q : q + 1))}
        onNiveauSuivant={() => { setNiveau(n => Math.min(n + 1, NIVEAUX.length - 1)); setQuizz(1); }}
        onSortir={onSortir} />
    );
  }

  /* L'accueil a besoin du titre du bandeau : on l'expose à côté du module. */
  Quizz.PAQUETS = PAQUETS;
  Quizz.NIVEAUX = NIVEAUX;
  window.Quizz = Quizz;
  window.QuizzJeu = Jeu;
  window.QuizzMenu = MenuQuizz;
})();
