humalis 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +85 -201
  2. package/LICENSE.en.md +86 -0
  3. package/NOTICE +6 -3
  4. package/README.en.md +1 -1
  5. package/README.md +1 -1
  6. package/dist/analyse/accessibilite.js +13 -5
  7. package/dist/analyse/analyse.js +43 -3
  8. package/dist/analyse/ecran.js +1 -1
  9. package/dist/analyse/langues.js +30 -10
  10. package/dist/analyse/medias.js +4 -1
  11. package/dist/analyse/navigation.js +35 -0
  12. package/dist/analyse/niveaux.js +33 -0
  13. package/dist/analyse/noms.js +1 -1
  14. package/dist/analyse/site.js +30 -8
  15. package/dist/cibles/commun/colorerCode.js +59 -0
  16. package/dist/cibles/commun/mouvement.js +110 -4
  17. package/dist/cibles/mobile/projet.js +7 -2
  18. package/dist/cibles/mobile/rendu.js +6 -3
  19. package/dist/cibles/statique/elements-js.js +1 -1
  20. package/dist/cibles/statique/page.js +99 -6
  21. package/dist/cibles/statique/projet.js +31 -0
  22. package/dist/cibles/statique/valeurs.js +5 -1
  23. package/dist/cibles/statique/widgets.js +1 -1
  24. package/dist/cibles/web/css-ecran.js +22 -1
  25. package/dist/cibles/web/ecran.js +161 -16
  26. package/dist/cibles/web/interactions.js +50 -3
  27. package/dist/cibles/web/projet.js +29 -2
  28. package/dist/cibles/web/theme.js +11 -2
  29. package/dist/cibles/web/valeurs.js +17 -0
  30. package/dist/cibles/web/widgets.js +1 -1
  31. package/dist/i18n/messages.js +2 -0
  32. package/dist/lexer/lexer.js +106 -3
  33. package/dist/navigateur.js +5274 -0
  34. package/dist/parser/interactions.js +6 -2
  35. package/dist/parser/langues.js +1 -1
  36. package/dist/parser/medias.js +6 -1
  37. package/dist/parser/miseEnPage.js +1 -1
  38. package/dist/parser/parser.js +85 -3
  39. package/dist/parser/theme.js +10 -1
  40. package/dist/ri/construire.js +24 -1
  41. package/dist/vocabulaire/table-ecran.js +3 -3
  42. package/dist/vocabulaire/table-interactions.js +16 -1
  43. package/dist/vocabulaire/table.js +4 -4
  44. package/docs/guide.en.md +170 -2
  45. package/docs/guide.md +171 -1
  46. package/llms.txt +14 -1
  47. package/package.json +7 -4
@@ -55,8 +55,18 @@ function textesDesOptions(instruction, vus) {
55
55
  vus.push(serie.texteNom ? plat(serie.texteNom) : litteralDe(serie.nom, serie.ligne));
56
56
  }
57
57
  }
58
- /** Les textes que la personne voit : contenus d'éléments, titres de blocs, messages, aperçu. */
59
- function textesVisibles(instructions, vus) {
58
+ /** « si langue est anglais : » (ou « n'est pas ») : ses deux branches sont chacune écrites dans une seule langue. */
59
+ const testeLaLangue = (instruction) => {
60
+ if (instruction.type !== 'Condition')
61
+ return false;
62
+ const test = instruction.test;
63
+ return test.type === 'Comparaison' && test.gauche.type === 'Reference' && test.gauche.nom === 'langue' && test.droite?.type === 'Reference' && langueDuMot(test.droite.nom) !== undefined;
64
+ };
65
+ /**
66
+ * Les textes que la personne voit : contenus d'éléments, titres de blocs, messages, aperçu.
67
+ * Ceux d'une branche « si langue est … » vont aussi dans `uneLangue` : ils n'ont pas à être traduits.
68
+ */
69
+ function textesVisibles(instructions, vus, uneLangue) {
60
70
  const ajouter = (e) => {
61
71
  if (e?.type === 'Chaine')
62
72
  vus.push(e);
@@ -86,12 +96,21 @@ function textesVisibles(instructions, vus) {
86
96
  ajouter(instruction.titre);
87
97
  for (const cle of ['corps', 'alors', 'sinon']) {
88
98
  const enfants = instruction[cle];
89
- if (Array.isArray(enfants))
90
- textesVisibles(enfants, vus);
99
+ if (!Array.isArray(enfants))
100
+ continue;
101
+ if (cle !== 'corps' && uneLangue && testeLaLangue(instruction)) {
102
+ const dedans = [];
103
+ textesVisibles(enfants, dedans, uneLangue);
104
+ for (const texte of dedans)
105
+ uneLangue.add(texte);
106
+ vus.push(...dedans);
107
+ }
108
+ else
109
+ textesVisibles(enfants, vus, uneLangue);
91
110
  }
92
111
  if (instruction.type === 'Selon')
93
112
  for (const cas of instruction.cas)
94
- textesVisibles(cas.corps, vus);
113
+ textesVisibles(cas.corps, vus, uneLangue);
95
114
  }
96
115
  }
97
116
  /** Tous les littéraux de texte d'un arbre, où qu'ils soient. */
@@ -235,17 +254,18 @@ export function analyserLangues(programme, diagnostics) {
235
254
  }
236
255
  // Les textes visibles : ceux des pages, des options et du bloc « site ».
237
256
  const visibles = [];
257
+ const uneLangue = new Set();
238
258
  const pages = programme.pages;
239
259
  for (const page of pages) {
240
- textesVisibles(page.corps, visibles);
260
+ textesVisibles(page.corps, visibles, uneLangue);
241
261
  if (page.apercu)
242
- textesVisibles([page.apercu], visibles);
262
+ textesVisibles([page.apercu], visibles, uneLangue);
243
263
  }
244
264
  for (const disposition of [programme.entete, programme.pied])
245
265
  if (disposition)
246
- textesVisibles(disposition.corps, visibles);
266
+ textesVisibles(disposition.corps, visibles, uneLangue);
247
267
  for (const composant of programme.composants)
248
- textesVisibles(composant.corps, visibles);
268
+ textesVisibles(composant.corps, visibles, uneLangue);
249
269
  for (const cle of ['nom', 'description']) {
250
270
  const valeur = programme.site?.[cle];
251
271
  if (valeur !== undefined)
@@ -298,7 +318,7 @@ export function analyserLangues(programme, diagnostics) {
298
318
  }
299
319
  // Les textes visibles sans version dans une langue déclarée.
300
320
  for (const texte of visibles) {
301
- if (!aDesLettres(texte.parties))
321
+ if (!aDesLettres(texte.parties) || uneLangue.has(texte) || texte.sansTraduction)
302
322
  continue;
303
323
  const cle = texteSource(texte.parties);
304
324
  for (const langue of autres) {
@@ -27,6 +27,9 @@ export function verifierMouvement(styles, genre, dansFormulaire, ligne, s) {
27
27
  s.erreur('« au defilement » suit le nom d’une animation : « animation montee au defilement ».', ligne);
28
28
  return false;
29
29
  }
30
+ if (animation.cascade && !['bloc', 'section', 'ligne', 'colonne', 'grille'].includes(genre)) {
31
+ s.erreur('« en cascade » fait apparaître les enfants d’un bloc l’un après l’autre : il s’écrit sur un bloc (section, bloc, ligne, colonne ou grille).', ligne);
32
+ }
30
33
  if (genre === 'champ' || genre === 'formulaire' || dansFormulaire) {
31
34
  s.erreur('pas d’animation sur un champ, ni dans un formulaire : un champ qui bouge pendant qu’on écrit gêne la saisie.', ligne);
32
35
  }
@@ -191,7 +194,7 @@ function verifierReglagesGraphique(g, s) {
191
194
  s.erreur(couleur.erreur, ligne);
192
195
  continue;
193
196
  }
194
- const mode = ['clair', 'sombre'].find((m) => contraste(couleur[m], FOND_PAGE[m]) < 3);
197
+ const mode = s.modes.find((m) => contraste(couleur[m], FOND_PAGE[m]) < 3);
195
198
  if (mode) {
196
199
  const rapport = contraste(couleur[mode], FOND_PAGE[mode]).toFixed(1).replace('.', ',');
197
200
  s.avertissement(`la couleur « ${couleur.nom} » se voit mal sur le fond de la page en mode ${mode} (contraste ${rapport} pour 1, il en faut au moins 3) : choisissez une couleur plus ${mode === 'clair' ? 'foncée' : 'claire'}.`, ligne);
@@ -1,5 +1,6 @@
1
1
  import { simplifier } from './elements.js';
2
2
  import { proposerNom } from './suggestions.js';
3
+ import { pascal } from '../cibles/web/noms.js';
3
4
  const SCHEMAS_PERMIS = ['http', 'https', 'mailto', 'tel'];
4
5
  /** Vrai si l'adresse est un schéma permis pour un lien externe (http, https, mailto, tel). */
5
6
  export function estAdresseExterne(valeur) {
@@ -88,6 +89,16 @@ export function analyserNavigation(programme, diagnostics) {
88
89
  }
89
90
  }
90
91
  }
92
+ // Une page devient un composant React nommé comme elle (« bac_a_sable » → BacASable) :
93
+ // un composant du même nom serait caché par la page qui l'affiche.
94
+ const composants = new Map([...programme.composants, ...programme.composantsReact].flatMap((c) => [[c.nom, c.nom], [pascal(c.nom), c.nom]]));
95
+ for (const page of programme.pages) {
96
+ const produit = pascal(page.nom);
97
+ const composant = composants.get(produit);
98
+ if (composant !== undefined) {
99
+ erreur(`la page « ${page.nom} » et le composant « ${composant} » auraient le même nom dans le code produit (${produit}) : renommez l'un des deux (par exemple le composant « ${composant}Bloc »).`, page.ligne);
100
+ }
101
+ }
91
102
  analyserSousPages(programme, erreur);
92
103
  analyserAncres(programme, diagnostics, erreur);
93
104
  }
@@ -228,4 +239,28 @@ function analyserAncres(programme, diagnostics, erreur) {
228
239
  verifier(page, i.cible, i.ligne);
229
240
  });
230
241
  }
242
+ // L'en-tête et le pied sont sur chaque page : « carte#desserts » se vérifie comme ailleurs ;
243
+ // « #desserts » vise la page affichée, qui change : il faut au moins une page qui l'ait.
244
+ for (const disposition of [programme.entete, programme.pied]) {
245
+ if (!disposition)
246
+ continue;
247
+ const de = disposition.genre === 'entete' ? "de l'en-tête" : 'du pied de page';
248
+ parcourir(disposition.corps, (i) => {
249
+ if (i.type !== 'Element' || i.cible === undefined)
250
+ return;
251
+ const lue = lireCible(i.cible);
252
+ if (lue.ancre === undefined)
253
+ return;
254
+ if (lue.page !== undefined && lue.page !== '') {
255
+ const visee = pages.get(lue.page);
256
+ if (visee)
257
+ verifier(visee, i.cible, i.ligne);
258
+ return;
259
+ }
260
+ const montrees = programme.pages.filter((p) => !(disposition.genre === 'entete' ? p.sansEntete : p.sansPied) && p.parametres.length === 0);
261
+ if (!montrees.some((p) => ancres.get(p.nom)?.includes(lue.ancre))) {
262
+ erreur(`l'ancre « ${lue.ancre} » ${de} n'existe dans aucune page : écrivez avec ancre "${lue.ancre}" sur l'élément ou le bloc où le lien doit mener, ou écrivez le nom de la page (par exemple "accueil#${lue.ancre}").`, i.ligne);
263
+ }
264
+ });
265
+ }
231
266
  }
@@ -0,0 +1,33 @@
1
+ export function niveauxDesSousTitres(instructions, parent = 1) {
2
+ let dernier = parent;
3
+ const parcourir = (enfants) => {
4
+ for (const instruction of enfants) {
5
+ if (instruction.type === 'Element' && instruction.element === 'titre')
6
+ dernier = 1;
7
+ else if (instruction.type === 'Element' && instruction.element === 'soustitre') {
8
+ if (instruction.niveau === undefined) {
9
+ const niveau = Math.min(parent + 1, 6);
10
+ if (niveau !== 2)
11
+ instruction.niveau = niveau;
12
+ dernier = niveau;
13
+ }
14
+ else
15
+ dernier = instruction.niveau;
16
+ }
17
+ else if (instruction.type === 'Bloc')
18
+ niveauxDesSousTitres(instruction.corps, dernier);
19
+ else {
20
+ // Les branches d'une condition, le corps d'une boucle ou d'un formulaire : le même niveau.
21
+ for (const cle of ['corps', 'alors', 'sinon']) {
22
+ const suite = instruction[cle];
23
+ if (Array.isArray(suite) && instruction.type !== 'Evenement')
24
+ parcourir(suite);
25
+ }
26
+ if (instruction.type === 'Selon')
27
+ for (const cas of instruction.cas)
28
+ parcourir(cas.corps);
29
+ }
30
+ }
31
+ };
32
+ parcourir(instructions);
33
+ }
@@ -12,7 +12,7 @@ export function avertirNomsDuLangage(programme) {
12
12
  const diagnostics = [];
13
13
  const avertir = (nom, ligne) => {
14
14
  const canonique = formeCanonique(nom);
15
- if (canonique && MOTS_CONTEXTUELS.has(canonique)) {
15
+ if (canonique && canonique !== 'liste' && MOTS_CONTEXTUELS.has(canonique)) {
16
16
  diagnostics.push({
17
17
  gravite: 'avertissement',
18
18
  message: `la variable « ${nom} » porte le nom d'un mot du langage ; c'est permis, mais cela peut prêter à confusion.`,
@@ -1,16 +1,36 @@
1
+ import { langueDuMot } from '../i18n/langues.js';
1
2
  /** Le texte d'un littéral sans « {…} », sinon `undefined`. */
2
3
  export function texteLitteral(expression) {
3
4
  if (expression?.type !== 'Chaine' || !expression.parties.every((p) => p.type === 'texte'))
4
5
  return undefined;
5
6
  return expression.parties.map((p) => p.valeur).join('');
6
7
  }
7
- /** Le premier « titre » ou « texte » écrit dans la page, même dans un bloc de mise en page. */
8
- function premier(genre, instructions) {
8
+ /**
9
+ * « si langue est anglais » : vrai si le test est vrai dans la langue `code`,
10
+ * faux sinon ; `undefined` pour un test qui ne porte pas sur la langue.
11
+ */
12
+ function testDeLangue(test, code) {
13
+ if (test.type !== 'Comparaison' || test.gauche.type !== 'Reference' || test.gauche.nom !== 'langue' || test.droite?.type !== 'Reference')
14
+ return undefined;
15
+ if (test.operateur !== 'egal' && test.operateur !== 'different')
16
+ return undefined;
17
+ const langue = langueDuMot(test.droite.nom);
18
+ if (!langue)
19
+ return undefined;
20
+ return (langue.code === code) !== (test.operateur === 'different') !== Boolean(test.negation);
21
+ }
22
+ /**
23
+ * Le premier « titre » ou « texte » écrit dans la page, même dans un bloc de mise en page.
24
+ * Avec des langues, une branche « si langue est … » compte : celle de la langue de base.
25
+ */
26
+ function premier(genre, instructions, langueDeBase) {
9
27
  for (const instruction of instructions) {
10
28
  if (instruction.type === 'Element' && instruction.element === genre)
11
29
  return instruction.contenu;
12
- if (instruction.type === 'Bloc') {
13
- const trouve = premier(genre, instruction.corps);
30
+ const vraie = instruction.type === 'Condition' && langueDeBase !== undefined ? testDeLangue(instruction.test, langueDeBase) : undefined;
31
+ const corps = instruction.type === 'Bloc' ? instruction.corps : instruction.type === 'Condition' && vraie !== undefined ? (vraie ? instruction.alors : instruction.sinon) : undefined;
32
+ if (corps) {
33
+ const trouve = premier(genre, corps, langueDeBase);
14
34
  if (trouve)
15
35
  return trouve;
16
36
  }
@@ -18,10 +38,10 @@ function premier(genre, instructions) {
18
38
  return undefined;
19
39
  }
20
40
  /** Le titre et la description propres à la page : son aperçu, sinon son premier titre et son premier texte. */
21
- export function apercuDeLaPage(page) {
41
+ export function apercuDeLaPage(page, langueDeBase) {
22
42
  return {
23
- titre: page.apercu?.titre ?? premier('titre', page.corps),
24
- description: page.apercu?.description ?? premier('texte', page.corps),
43
+ titre: page.apercu?.titre ?? premier('titre', page.corps, langueDeBase),
44
+ description: page.apercu?.description ?? premier('texte', page.corps, langueDeBase),
25
45
  };
26
46
  }
27
47
  export function analyserPublication(programme, diagnostics) {
@@ -43,10 +63,12 @@ export function analyserPublication(programme, diagnostics) {
43
63
  avertir(`l'image de partage « ${site.image} » est sur un autre site : si elle disparaît, l'aperçu des liens du site la perd. Préférez un fichier du projet.`, site.lignes.image ?? site.ligne);
44
64
  }
45
65
  const titres = new Map();
66
+ const premiereLangue = programme.langues?.langues[0];
67
+ const langueDeBase = premiereLangue ? langueDuMot(premiereLangue.mot)?.code : undefined;
46
68
  for (const page of programme.pages) {
47
69
  if (page.nom === 'introuvable')
48
70
  continue;
49
- const { titre, description } = apercuDeLaPage(page);
71
+ const { titre, description } = apercuDeLaPage(page, langueDeBase);
50
72
  const ligneApercu = page.apercu?.ligne ?? page.ligne;
51
73
  const titreLu = texteLitteral(titre);
52
74
  const descriptionLue = texteLitteral(description);
@@ -0,0 +1,59 @@
1
+ import { analyser } from '../../lexer/lexer.js';
2
+ const echapper = (texte) => texte.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
3
+ /** Colore un exemple Humalis valide ; un exemple incomplet reste lisible sans couleur. */
4
+ export function colorerCodeHumalis(code, langage) {
5
+ if (langage !== 'humalis')
6
+ return echapper(code);
7
+ try {
8
+ const tokens = analyser(code);
9
+ const lignes = code.split('\n');
10
+ const debuts = [];
11
+ let offset = 0;
12
+ for (const ligne of lignes) {
13
+ debuts.push(offset);
14
+ offset += ligne.length + 1;
15
+ }
16
+ const plages = [];
17
+ for (const token of tokens) {
18
+ if (!['MOT_CLE', 'CHAINE', 'NOMBRE'].includes(token.type))
19
+ continue;
20
+ const debut = debuts[token.ligne - 1] + token.colonne - 1;
21
+ let fin = debut + (token.original ?? token.valeur).length;
22
+ if (token.type === 'CHAINE') {
23
+ fin = debut + 1;
24
+ while (fin < code.length) {
25
+ if (code[fin] === '"' && code[fin - 1] !== '\\') {
26
+ fin++;
27
+ break;
28
+ }
29
+ fin++;
30
+ }
31
+ }
32
+ plages.push({ debut, fin, classe: token.type === 'MOT_CLE' ? 'mot' : token.type === 'CHAINE' ? 'texte' : 'nombre' });
33
+ }
34
+ for (let index = 0; index < lignes.length; index++) {
35
+ const ligne = lignes[index];
36
+ for (let colonne = 0; colonne < ligne.length; colonne++) {
37
+ const debut = debuts[index] + colonne;
38
+ if (ligne[colonne] === '#' && !plages.some((p) => p.debut <= debut && debut < p.fin)) {
39
+ plages.push({ debut, fin: debuts[index] + ligne.length, classe: 'commentaire' });
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ plages.sort((a, b) => a.debut - b.debut);
45
+ let rendu = '';
46
+ let position = 0;
47
+ for (const plage of plages) {
48
+ if (plage.debut < position)
49
+ continue;
50
+ rendu += echapper(code.slice(position, plage.debut));
51
+ rendu += `<span class="humalis-code-${plage.classe}">${echapper(code.slice(plage.debut, plage.fin))}</span>`;
52
+ position = plage.fin;
53
+ }
54
+ return rendu + echapper(code.slice(position));
55
+ }
56
+ catch {
57
+ return echapper(code);
58
+ }
59
+ }
@@ -31,8 +31,20 @@ function classesUtilisees(codes) {
31
31
  trouvees.add(`${m[1]}-${m[2]}`);
32
32
  return trouvees;
33
33
  }
34
+ /**
35
+ * Les attributs d'un bloc « code … avec animation frappe » : sa classe, le moment de la frappe et une
36
+ * clé stable (la ligne et une empreinte du code), pour ne pas refaire la frappe quand la page se redessine.
37
+ */
38
+ export function attributsFrappe(bloc) {
39
+ if (!bloc.frappe)
40
+ return undefined;
41
+ let empreinte = 0;
42
+ for (const lettre of bloc.code)
43
+ empreinte = (empreinte * 31 + lettre.charCodeAt(0)) >>> 0;
44
+ return { classe: 'humalis-animation-frappe', frappe: bloc.frappe, cle: `${bloc.ligne}-${empreinte.toString(36)}` };
45
+ }
34
46
  /** Vrai si une page anime un élément « au defilement » (l'observateur doit alors être écrit). */
35
- export const animeAuDefilement = (codes) => codes.some((c) => c.includes('humalis-animation-defilement'));
47
+ export const animeAuDefilement = (codes) => codes.some((c) => c.includes('humalis-animation-defilement') || c.includes('humalis-animation-frappe'));
36
48
  /** Le CSS du mouvement utilisé, rangé dans le bloc « no-preference » ; une chaîne vide s'il n'y en a pas. */
37
49
  export function cssMouvement(codes) {
38
50
  const utilisees = classesUtilisees(codes);
@@ -53,6 +65,15 @@ export function cssMouvement(codes) {
53
65
  regles.push('.humalis-animation-defilement:not([data-humalis-vu]) { animation: none; }');
54
66
  regles.push('.humalis-js .humalis-animation-defilement:not([data-humalis-vu]) { opacity: 0; }');
55
67
  }
68
+ else if (option === 'animation' && valeur === 'frappe') {
69
+ // « code … avec animation frappe » : le vrai code reste en place mais transparent (lecteurs d'écran,
70
+ // copie, taille du bloc) ; le script pose par-dessus une copie qui s'écrit lettre par lettre.
71
+ regles.push('.humalis-animation-frappe { position: relative; }');
72
+ regles.push('.humalis-js .humalis-animation-frappe:not([data-humalis-tape="fini"]) > code:not(.humalis-frappe-copie), .humalis-js .humalis-animation-frappe:not([data-humalis-tape="fini"]) > code:not(.humalis-frappe-copie) * { color: transparent; }');
73
+ regles.push('.humalis-frappe-copie { position: absolute; inset: 0; padding: inherit; overflow: hidden; pointer-events: none; }');
74
+ regles.push('.humalis-frappe-copie::after { content: ""; display: inline-block; width: 0.55em; height: 1.1em; margin-left: 1px; vertical-align: text-bottom; background: currentColor; animation: humalis-curseur 1s steps(1) infinite; }');
75
+ regles.push('@keyframes humalis-curseur { 50% { opacity: 0; } }');
76
+ }
56
77
  else if (option === 'duree' && DUREES[valeur]) {
57
78
  regles.push(`${selecteur} { --humalis-duree: ${DUREES[valeur]}; }`);
58
79
  }
@@ -71,6 +92,16 @@ export function cssMouvement(codes) {
71
92
  regles.push(`${selecteur}:hover, ${selecteur}:focus-within, ${selecteur}:active { transform: scale(1.03); }`);
72
93
  }
73
94
  }
95
+ // « en cascade » : le bloc reste en place, ses enfants s'animent l'un après l'autre (80 ms d'écart).
96
+ if (utilisees.has('animation-cascade')) {
97
+ regles.push('.humalis-animation-cascade.humalis-animation-cascade { animation: none; }');
98
+ for (const valeur of Object.keys(ENTREES).filter((v) => utilisees.has(`animation-${v}`))) {
99
+ regles.push(`.humalis-animation-cascade.humalis-animation-${valeur} > * { animation: humalis-${valeur} var(--humalis-duree, 300ms) ease-out var(--humalis-delai, 0ms) backwards; }`);
100
+ }
101
+ for (let rang = 2; rang <= 12; rang++)
102
+ regles.push(`.humalis-animation-cascade > :nth-child(${rang}) { --humalis-delai: ${(rang - 1) * 80}ms; }`);
103
+ regles.push('.humalis-animation-cascade:not([data-humalis-vu]) > * { animation: none; }');
104
+ }
74
105
  if (regles.length === 0)
75
106
  return '';
76
107
  return `\n/* Le mouvement : seulement si l'appareil ne demande pas de réduire les animations. */\n${REQUETE_MOUVEMENT} {\n${regles.map((r) => ` ${r}`).join('\n')}\n}\n`;
@@ -127,10 +158,73 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
127
158
  if (!('IntersectionObserver' in window)) return;
128
159
  const reduire = matchMedia('(prefers-reduced-motion: reduce)');
129
160
  const montrer = (element) => element.setAttribute('data-humalis-vu', '');
161
+ // « code … avec animation frappe » : une copie du code s'écrit par-dessus le vrai, puis s'efface.
162
+ // Chaque bloc garde où il en est : redessiné en cours de frappe, il reprend au même endroit ;
163
+ // déjà tapé (un retour sur la page), il est montré entier tout de suite.
164
+ const etats = new Map();
165
+ const taper = (pre) => {
166
+ const code = pre.querySelector(':scope > code');
167
+ const cle = pre.getAttribute('data-humalis-frappe-cle');
168
+ const etat = etats.get(cle) ?? { ecrites: 0, fini: false };
169
+ etats.set(cle, etat);
170
+ if (!code || reduire.matches || etat.fini) {
171
+ pre.setAttribute('data-humalis-tape', 'fini');
172
+ return;
173
+ }
174
+ const copie = code.cloneNode(true);
175
+ copie.className = 'humalis-frappe-copie';
176
+ copie.setAttribute('aria-hidden', 'true');
177
+ const morceaux = [];
178
+ const parcours = document.createTreeWalker(copie, NodeFilter.SHOW_TEXT);
179
+ while (parcours.nextNode()) {
180
+ const noeud = parcours.currentNode;
181
+ morceaux.push([noeud, noeud.data]);
182
+ noeud.data = '';
183
+ }
184
+ pre.append(copie);
185
+ pre.setAttribute('data-humalis-tape', 'en-cours');
186
+ // Les lettres déjà tapées sur le bloc d'avant reviennent d'un coup.
187
+ let deja = etat.ecrites;
188
+ for (const morceau of morceaux) {
189
+ const part = morceau[1].slice(0, deja);
190
+ morceau[0].data = part;
191
+ deja -= part.length;
192
+ }
193
+ // Une lettre toutes les 30 ms au plus, et pas plus de 5 secondes en tout ; une pause en fin de ligne.
194
+ const total = morceaux.reduce((somme, morceau) => somme + morceau[1].length, 0);
195
+ const pas = Math.max(8, Math.min(30, 5000 / Math.max(total, 1)));
196
+ let rang = 0;
197
+ let position = 0;
198
+ for (let reste = etat.ecrites; rang < morceaux.length && reste > 0; ) {
199
+ const pris = Math.min(reste, morceaux[rang][1].length);
200
+ reste -= pris;
201
+ if (pris === morceaux[rang][1].length) rang++;
202
+ else position = pris;
203
+ }
204
+ const suivante = () => {
205
+ if (!copie.isConnected) return;
206
+ while (rang < morceaux.length && position >= morceaux[rang][1].length) {
207
+ rang++;
208
+ position = 0;
209
+ }
210
+ if (rang >= morceaux.length || reduire.matches) {
211
+ etat.fini = true;
212
+ copie.remove();
213
+ pre.setAttribute('data-humalis-tape', 'fini');
214
+ return;
215
+ }
216
+ const lettre = morceaux[rang][1][position++];
217
+ morceaux[rang][0].data += lettre;
218
+ etat.ecrites++;
219
+ setTimeout(suivante, lettre === '\\n' ? pas * 6 : pas);
220
+ };
221
+ suivante();
222
+ };
130
223
  const observateur = new IntersectionObserver((entrees) => {
131
224
  for (const entree of entrees) {
132
225
  if (!entree.isIntersecting) continue;
133
- montrer(entree.target);
226
+ if (entree.target.hasAttribute('data-humalis-frappe')) taper(entree.target);
227
+ else montrer(entree.target);
134
228
  observateur.unobserve(entree.target);
135
229
  }
136
230
  });
@@ -139,6 +233,12 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
139
233
  if (reduire.matches) montrer(element);
140
234
  else observateur.observe(element);
141
235
  }
236
+ for (const pre of document.querySelectorAll('[data-humalis-frappe]:not([data-humalis-tape])')) {
237
+ if (pre.getAttribute('data-humalis-frappe') === 'defilement' && !reduire.matches) {
238
+ pre.setAttribute('data-humalis-tape', 'attente');
239
+ observateur.observe(pre);
240
+ } else taper(pre);
241
+ }
142
242
  };
143
243
  document.documentElement.classList.add('humalis-js');
144
244
  suivre();
@@ -148,9 +248,15 @@ export const OBSERVATEUR_DEFILEMENT = `function observerLeDefilement() {
148
248
  }`;
149
249
  /** src/humalis/mouvement.ts du web et du desktop : le même observateur, typé. */
150
250
  export const MOUVEMENT_WEB = `// Produit par Humalis : les animations « au defilement » se jouent quand l'élément entre à l'écran.
151
- ${OBSERVATEUR_DEFILEMENT.replace('function observerLeDefilement() {', 'export function observerLeDefilement(): void {').replace('(element) =>', '(element: Element) =>')}
251
+ ${OBSERVATEUR_DEFILEMENT.replace('function observerLeDefilement() {', 'export function observerLeDefilement(): void {')
252
+ .replace('(element) =>', '(element: Element) =>')
253
+ .replace('const etats = new Map();', 'const etats = new Map<string | null, { ecrites: number; fini: boolean }>();')
254
+ .replace('(pre) =>', '(pre: Element) =>')
255
+ .replace('code.cloneNode(true);', 'code.cloneNode(true) as HTMLElement;')
256
+ .replace('const morceaux = [];', 'const morceaux: [Text, string][] = [];')
257
+ .replace('const noeud = parcours.currentNode;', 'const noeud = parcours.currentNode as Text;')}
152
258
  `;
153
259
  /** Le prérendu capture la page après le script : il retire les marques du mouvement (rien de caché sans JavaScript). */
154
260
  export const NETTOYAGE_PRERENDU = ` // Le script du mouvement marque la page (humalis-js, data-humalis-vu) : le HTML prérendu ne garde aucune de ces marques.
155
- transformHtml: (html) => html.replace(/(<html[^>]*class="[^"]*)\\bhumalis-js\\b/, '$1').replace(/ data-humalis-vu=""/g, ''),
261
+ transformHtml: (html) => html.replace(/(<html[^>]*class="[^"]*)\\bhumalis-js\\b/, '$1').replace(/ data-humalis-vu=""/g, '').replace(/ data-humalis-tape="[a-z-]*"/g, '').replace(/<code class="humalis-frappe-copie"[\\s\\S]*?<\\/code>/g, ''),
156
262
  `;
@@ -885,6 +885,9 @@ function styles(formulaires = false, blocs = false, theme, codes = []) {
885
885
  `titre: { fontSize: 28, fontWeight: '700', color: '${encre}', marginVertical: 12 },`,
886
886
  `sousTitre: { fontSize: 22, fontWeight: '700', color: '${encre}', marginVertical: 10 },`,
887
887
  `texte: { fontSize: 16, lineHeight: 24, color: '${encre}', marginVertical: 6 },`,
888
+ ...(codes.some((c) => c.includes('s.accent')) ? [theme?.couleurs.some((c) => c.nom === 'marque')
889
+ ? `accent: { color: '${theme.couleurs.find((c) => c.nom === 'marque')[mode === 'clair' ? 'clair' : 'sombre']}' },`
890
+ : `accent: { fontWeight: '700' },`] : []),
888
891
  `bouton: { alignSelf: 'flex-start', paddingVertical: 8, paddingHorizontal: 16, marginVertical: 4, borderWidth: 1, borderColor: '${encre}', borderRadius: 6 },`,
889
892
  `texteBouton: { fontSize: 16, color: '${encre}' },`,
890
893
  `lien: { alignSelf: 'flex-start', fontSize: 16, color: '${bleu}', marginVertical: 12, textDecorationLine: 'underline' },`,
@@ -1046,10 +1049,12 @@ l'écraser.
1046
1049
  /** « changer theme par sombre » sur le téléphone : le réglage de l'application, pour cette session. */
1047
1050
  const THEME_MOBILE = `import { Appearance } from 'react-native';
1048
1051
 
1049
- export type Theme = 'clair' | 'sombre' | 'automatique';
1052
+ export type Theme = 'clair' | 'sombre' | 'automatique' | 'bascule';
1050
1053
 
1051
1054
  /** Change le thème de l'application (le choix n'est pas gardé après la fermeture). */
1052
- export function definirTheme(theme: Theme): void {
1055
+ export function definirTheme(demande: Theme): void {
1056
+ // « basculer theme » : l'inverse du thème affiché.
1057
+ const theme = demande === 'bascule' ? (Appearance.getColorScheme() === 'dark' ? 'clair' : 'sombre') : demande;
1053
1058
  Appearance.setColorScheme(theme === 'clair' ? 'light' : theme === 'sombre' ? 'dark' : 'unspecified');
1054
1059
  }
1055
1060
  `;
@@ -13,7 +13,7 @@ const SANS_STYLE = new Set(['sur', 'degrade', 'colle', 'animation', 'duree', 'de
13
13
  * et « effet » sont sans effet sur le téléphone.
14
14
  */
15
15
  function ouvrirAnime(options) {
16
- const animation = options.find((o) => o.nom === 'animation' && o.valeur !== 'defilement');
16
+ const animation = options.find((o) => o.nom === 'animation' && o.valeur !== 'defilement' && o.valeur !== 'cascade');
17
17
  if (!animation)
18
18
  return '';
19
19
  const duree = MILLISECONDES.duree[options.find((o) => o.nom === 'duree')?.valeur ?? 'moyenne'] ?? 300;
@@ -136,12 +136,15 @@ function elementMobile(el) {
136
136
  const options = stylesDe(el.options);
137
137
  // Le texte posé sur un fond prend la couleur d'encre de ce fond.
138
138
  const encres = el.options.filter((o) => o.nom === 'fond').map((o) => camel(`encre_${o.valeur}`));
139
+ const contenu = ['titre', 'soustitre', 'texte'].includes(el.genre)
140
+ ? el.contenu.replace(/<span className="humalis-accent">/g, '<Text style={s.accent}>').replace(/<code className="humalis-code-ligne">/g, '<Text style={{ fontFamily: "monospace" }}>').replace(/<\/(span|code)>/g, '</Text>')
141
+ : el.contenu;
139
142
  switch (el.genre) {
140
143
  case 'titre':
141
144
  case 'soustitre':
142
- return `<Text ${id} accessibilityRole="header" ${style([el.niveau === 1 ? 'titre' : 'sousTitre', ...options, ...encres])}${evenements}>${el.contenu}</Text>`;
145
+ return `<Text ${id} accessibilityRole="header" ${style([el.niveau === 1 ? 'titre' : 'sousTitre', ...options, ...encres])}${evenements}>${contenu}</Text>`;
143
146
  case 'texte':
144
- return `<Text ${id} ${style(['texte', ...options, ...encres])}${evenements}>${el.contenu}</Text>`;
147
+ return `<Text ${id} ${style(['texte', ...options, ...encres])}${evenements}>${contenu}</Text>`;
145
148
  case 'bouton': {
146
149
  // Le fond, la bordure et la position vont au bouton ; la couleur et la taille, à son texte.
147
150
  const duBouton = el.options.filter((o) => ['fond', 'bordure', 'position'].includes(o.nom)).map(nomStyle);
@@ -60,7 +60,7 @@ ${formes}
60
60
  parties.push(`
61
61
  // ─── L'avatar : la photo, ou les initiales ──────────────────────────────
62
62
 
63
- /** « Emch Kongo » donne « EK ». */
63
+ /** « Camille Martin » donne « CM ». */
64
64
  function initiales(nom) {
65
65
  const mots = String(nom).trim().split(/\\s+/).filter(Boolean);
66
66
  return ((mots[0]?.[0] ?? '') + (mots.length > 1 ? (mots[mots.length - 1][0] ?? '') : '')).toUpperCase();