humalis 1.0.4 → 1.2.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.
@@ -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' },`,
@@ -910,6 +913,9 @@ function styles(formulaires = false, blocs = false, theme, codes = []) {
910
913
  `colonne: { gap: 12 },`,
911
914
  `grille: { gap: 16 },`,
912
915
  `menu: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 12 },`,
916
+ // « menu vertical » : une colonne ; « menu repliable » n'a pas de style propre sur le téléphone.
917
+ `menuVertical: { flexDirection: 'column', flexWrap: 'nowrap', alignItems: 'stretch' },`,
918
+ `menuRepliable: {},`,
913
919
  ]
914
920
  : []),
915
921
  ];
@@ -1046,10 +1052,12 @@ l'écraser.
1046
1052
  /** « changer theme par sombre » sur le téléphone : le réglage de l'application, pour cette session. */
1047
1053
  const THEME_MOBILE = `import { Appearance } from 'react-native';
1048
1054
 
1049
- export type Theme = 'clair' | 'sombre' | 'automatique';
1055
+ export type Theme = 'clair' | 'sombre' | 'automatique' | 'bascule';
1050
1056
 
1051
1057
  /** Change le thème de l'application (le choix n'est pas gardé après la fermeture). */
1052
- export function definirTheme(theme: Theme): void {
1058
+ export function definirTheme(demande: Theme): void {
1059
+ // « basculer theme » : l'inverse du thème affiché.
1060
+ const theme = demande === 'bascule' ? (Appearance.getColorScheme() === 'dark' ? 'clair' : 'sombre') : demande;
1053
1061
  Appearance.setColorScheme(theme === 'clair' ? 'light' : theme === 'sombre' ? 'dark' : 'unspecified');
1054
1062
  }
1055
1063
  `;
@@ -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);
@@ -1,4 +1,4 @@
1
- import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i18n/messages.js';
1
+ import { verificationsDuChamp, messageEnCode, libellesEnLangues, messageEnLangues } from '../../i18n/messages.js';
2
2
  /**
3
3
  * Un écran de la représentation intermédiaire → une page du site statique :
4
4
  * son contenu de départ, écrit dans le HTML (décision T2), et son script,
@@ -9,6 +9,8 @@ import { verificationsDuChamp, messageEnCode, libellesEnLangues } from '../../i1
9
9
  * script doivent donner exactement la même page.
10
10
  */
11
11
  import { sousActions } from '../../ri/actions.js';
12
+ import { colorerCodeHumalis } from '../commun/colorerCode.js';
13
+ import { attributsFrappe } from '../commun/mouvement.js';
12
14
  import { arbreWidget, arbreComposite, initiales } from './widgets.js';
13
15
  import { iconeHtml, etoilesHtml } from './html-elements.js';
14
16
  import { appelGraphique, htmlGraphique } from './graphique.js';
@@ -320,9 +322,13 @@ class GenerateurPage {
320
322
  this.nomsPris.add(resultat);
321
323
  return resultat;
322
324
  }
325
+ /** « Copier » dans chaque langue du site, la langue de base (celle du HTML de départ) en premier. */
326
+ messageCopier() {
327
+ return (this.site.langues && messageEnLangues('Copier', this.site.langues.map((l) => l.code))) || [{ code: 'fr', texte: 'Copier' }];
328
+ }
323
329
  // ─── La page ────────────────────────────────────────────────────────────
324
330
  generer() {
325
- const { titre, description } = titreEtDescription(this.ecran);
331
+ const { titre, description } = titreEtDescription(this.ecran, this.site.langues);
326
332
  // Les fonctions et le dessin d'abord : on y lit les variables partagées
327
333
  // que la page utilise vraiment.
328
334
  const fonctions = new Ecrivain();
@@ -600,7 +606,7 @@ ${prise}${code}}
600
606
  break;
601
607
  case 'copie':
602
608
  // « copier code » : le résultat est dit à la personne, sauf « sans message ».
603
- e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? ', null' : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`});`);
609
+ e.ligne(`await copier(${this.enTexte(action.valeur)}${action.message === null ? ', null' : action.texte ? `, ${x(action.texte)}` : action.message === 'Copié' ? '' : `, ${chaine(action.message)}`});`);
604
610
  break;
605
611
  case 'partage': {
606
612
  // La feuille de partage du système ; sans elle, le lien (ou le texte) est copié.
@@ -892,13 +898,40 @@ ${prise}${code}}
892
898
  case 'graphique':
893
899
  e.ligne(`${appelGraphique(noeud, (v) => expressionJs(v, this.contexte))},`);
894
900
  break;
901
+ case 'codeDocumentation':
902
+ const frappe = attributsFrappe(noeud);
903
+ const marques = frappe ? `, 'data-humalis-frappe': '${frappe.frappe}', 'data-humalis-frappe-cle': '${frappe.cle}'` : '';
904
+ e.ligne(`creer('div', { class: 'humalis-bloc-code-conteneur' }, creer('pre', { class: 'humalis-bloc-code${frappe ? ` ${frappe.classe}` : ''}', 'data-langage': ${chaine(noeud.langage ?? '')}${marques} }, (() => { const code = document.createElement('code'); code.innerHTML = ${chaine(colorerCodeHumalis(noeud.code, noeud.langage))}; return code; })()), creer('button', { type: 'button', onclick: () => copier(${chaine(noeud.code)}) }, ${messageEnCode(this.messageCopier(), chaine)})),`);
905
+ break;
906
+ case 'tableauDocumentation': {
907
+ const cellule = (valeur, balise) => `creer('${balise}', {}, ${this.contenuAccentJs(valeur, 'texte')})`;
908
+ const rang = (valeurs, balise) => `creer('tr', {}, ${valeurs.map((v) => cellule(v, balise)).join(', ')})`;
909
+ e.ligne(`creer('div', { class: 'humalis-tableau-conteneur' }, creer('table', {}, creer('caption', {}, ${this.contenuAccentJs(noeud.legende, 'texte')}), creer('thead', {}, ${rang(noeud.colonnes, 'th')}), creer('tbody', {}, ${noeud.rangees.map((r) => rang(r, 'td')).join(', ')}))),`);
910
+ break;
911
+ }
895
912
  case 'bloc': {
913
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
914
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
915
+ e.ligne(`creer('${balise}', { class: 'humalis-liste' },`);
916
+ e.indenter(() => noeud.corps.forEach((enfant) => {
917
+ if (enfant.type === 'repetition') {
918
+ const debut = `...${expressionJs(enfant.liste, this.contexte)}.flatMap((${nomJs(enfant.variable)}) => `;
919
+ e.bloc(`${debut}[`, () => this.ecrireGroupe(e, enfant.corps, "creer('li', {}, ...", '),'), ']),');
920
+ }
921
+ else
922
+ this.ecrireGroupe(e, [enfant], "creer('li', {}, ...", '),');
923
+ }));
924
+ e.ligne('),');
925
+ break;
926
+ }
896
927
  if (COMPOSITES.has(noeud.genre)) {
897
928
  this.ecrireEnfantJs(e, arbreComposite(this.aideComposite(noeud, this.environnement())));
898
929
  break;
899
930
  }
900
931
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
901
- this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { class: ${chaine(classesDuBloc(noeud))} }, ...`, '),');
932
+ // « menu repliable » : le bouton « Menu » en premier enfant (l'écouteur est dans humalis.js).
933
+ const bouton = estRepliable(noeud) ? `creer('button', { type: 'button', class: 'humalis-menu-bouton', 'aria-expanded': 'false', 'aria-label': 'Menu' }, creer('span', { class: 'humalis-menu-icone', 'aria-hidden': 'true' })), ` : '';
934
+ this.ecrireGroupe(e, noeud.corps, `creer('${balise}', { class: ${chaine(classesDuBloc(noeud))} }, ${bouton}...`, '),');
902
935
  // « arrive en bas de » ce bloc : la sentinelle observée et le vrai bouton « Voir plus », tant que la condition est vraie.
903
936
  const bas = noeud.id !== undefined ? this.basDesBlocs.get(noeud.id) : undefined;
904
937
  if (bas) {
@@ -963,7 +996,7 @@ ${prise}${code}}
963
996
  const contenu = (lucideIcone ? `icone(${chaine(lucideIcone)}, 'humalis-icone'), ` : '') +
964
997
  (this.textesChanges.has(noeud.id)
965
998
  ? `textes[${chaine(noeud.id)}] ?? ${contenuJs(noeud.contenu, this.contexte)}`
966
- : contenuJs(noeud.contenu, this.contexte));
999
+ : this.contenuAccentJs(noeud.contenu, noeud.genre));
967
1000
  switch (noeud.genre) {
968
1001
  case 'titre':
969
1002
  case 'soustitre':
@@ -1137,13 +1170,49 @@ ${prise}${code}}
1137
1170
  case 'graphique':
1138
1171
  lignes.push(htmlGraphique(noeud, (v) => this.initial(v, env), (v) => this.texteInitial(v, env)));
1139
1172
  break;
1173
+ case 'codeDocumentation':
1174
+ const frappe = attributsFrappe(noeud);
1175
+ const marques = frappe ? ` data-humalis-frappe="${frappe.frappe}" data-humalis-frappe-cle="${frappe.cle}"` : '';
1176
+ lignes.push(`<div class="humalis-bloc-code-conteneur"><pre class="humalis-bloc-code${frappe ? ` ${frappe.classe}` : ''}" data-langage="${echapperTexte(noeud.langage ?? '')}"${marques}><code>${colorerCodeHumalis(noeud.code, noeud.langage)}</code></pre><button type="button">${echapperTexte(this.messageCopier()[0].texte)}</button></div>`);
1177
+ break;
1178
+ case 'tableauDocumentation': {
1179
+ const cellule = (v, balise) => `<${balise}>${this.contenuAccentHtml(v, 'texte', env)}</${balise}>`;
1180
+ lignes.push('<div class="humalis-tableau-conteneur"><table>');
1181
+ lignes.push(`<caption>${this.contenuAccentHtml(noeud.legende, 'texte', env)}</caption><thead><tr>${noeud.colonnes.map((v) => cellule(v, 'th')).join('')}</tr></thead><tbody>`);
1182
+ for (const rangee of noeud.rangees)
1183
+ lignes.push(`<tr>${rangee.map((v) => cellule(v, 'td')).join('')}</tr>`);
1184
+ lignes.push('</tbody></table></div>');
1185
+ break;
1186
+ }
1140
1187
  case 'bloc': {
1188
+ if (noeud.genre === 'liste' || noeud.genre === 'listeNumerotee') {
1189
+ const balise = noeud.genre === 'liste' ? 'ul' : 'ol';
1190
+ lignes.push(`<${balise} class="humalis-liste">`);
1191
+ for (const enfant of noeud.corps) {
1192
+ if (enfant.type === 'repetition') {
1193
+ const valeurs = evaluer(enfant.liste, env);
1194
+ for (const valeur of Array.isArray(valeurs) ? valeurs : []) {
1195
+ lignes.push('<li>');
1196
+ this.ecrireGroupeHtml(enfant.corps, env.enfant().definir(enfant.variable, valeur), lignes);
1197
+ lignes.push('</li>');
1198
+ }
1199
+ continue;
1200
+ }
1201
+ lignes.push('<li>');
1202
+ this.ecrireGroupeHtml([enfant], env, lignes);
1203
+ lignes.push('</li>');
1204
+ }
1205
+ lignes.push(`</${balise}>`);
1206
+ break;
1207
+ }
1141
1208
  if (COMPOSITES.has(noeud.genre)) {
1142
1209
  this.ecrireEnfantHtml(arbreComposite(this.aideComposite(noeud, env)), env, lignes);
1143
1210
  break;
1144
1211
  }
1145
1212
  const balise = BALISES_BLOC[noeud.genre] ?? 'div';
1146
1213
  lignes.push(`<${balise}${attributs([['class', classesDuBloc(noeud)]])}>`);
1214
+ if (estRepliable(noeud))
1215
+ lignes.push('<button type="button" class="humalis-menu-bouton" aria-expanded="false" aria-label="Menu"><span class="humalis-menu-icone" aria-hidden="true"></span></button>');
1147
1216
  this.ecrireGroupeHtml(noeud.corps, env, lignes);
1148
1217
  lignes.push(`</${balise}>`);
1149
1218
  const bas = noeud.id !== undefined ? this.basDesBlocs.get(noeud.id) : undefined;
@@ -1192,6 +1261,27 @@ ${prise}${code}}
1192
1261
  return undefined;
1193
1262
  }
1194
1263
  }
1264
+ contenuAccentJs(valeur, genre) {
1265
+ if (valeur.type !== 'texte' || !['titre', 'soustitre', 'texte'].includes(genre))
1266
+ return contenuJs(valeur, this.contexte);
1267
+ const parties = (liste) => liste.map((p) => 'texte' in p
1268
+ ? p.accent ? `creer('span', { class: 'humalis-accent' }, ${chaine(p.texte)})` : p.code ? `creer('code', { class: 'humalis-code-ligne' }, ${chaine(p.texte)})` : chaine(p.texte)
1269
+ : contenuJs({ type: 'texte', parties: [p] }, this.contexte)).join(', ');
1270
+ const accent = (liste) => liste.some((p) => 'texte' in p && (p.accent || p.code));
1271
+ if (!accent(valeur.parties) && !valeur.versions?.some((v) => accent(v.parties)))
1272
+ return contenuJs(valeur, this.contexte);
1273
+ if (valeur.versions)
1274
+ return `...(${valeur.versions.slice(1).map((v) => `langue() === ${chaine(v.code)} ? [${parties(v.parties)}] : `).join('')}[${parties(valeur.versions[0].parties)}])`;
1275
+ return parties(valeur.parties);
1276
+ }
1277
+ contenuAccentHtml(valeur, genre, env) {
1278
+ if (valeur.type !== 'texte' || !['titre', 'soustitre', 'texte'].includes(genre) || !valeur.parties.some((p) => 'texte' in p && (p.accent || p.code)))
1279
+ return echapperTexte(texteDe(valeur, env, this.contexte.champs));
1280
+ return valeur.parties.map((p) => {
1281
+ const texte = echapperTexte('texte' in p ? p.texte : texteDe({ type: 'texte', parties: [p] }, env, this.contexte.champs));
1282
+ return 'texte' in p && p.accent ? `<span class="humalis-accent">${texte}</span>` : 'texte' in p && p.code ? `<code class="humalis-code-ligne">${texte}</code>` : texte;
1283
+ }).join('');
1284
+ }
1195
1285
  texteInitial(valeur, env) {
1196
1286
  try {
1197
1287
  return texteDe(valeur, env, this.contexte.champs);
@@ -1456,7 +1546,7 @@ ${prise}${code}}
1456
1546
  const classes = this.classes(noeud) || undefined;
1457
1547
  const commun = (avant = []) => attributs([...avant, ['data-humalis', noeud.id], ['class', classes], ...(noeud.annonce ? [['aria-live', 'polite'], ['aria-atomic', 'true']] : []), ...attributsRaccourci(noeud)]);
1458
1548
  const lucideHtml = noeud.reglages?.icone ? chercherIcone(noeud.reglages.icone)?.lucide : undefined;
1459
- const contenu = (lucideHtml ? iconeHtml(lucideHtml, 'humalis-icone') : '') + echapperTexte(texteDe(noeud.contenu, env, this.contexte.champs));
1549
+ const contenu = (lucideHtml ? iconeHtml(lucideHtml, 'humalis-icone') : '') + this.contenuAccentHtml(noeud.contenu, noeud.genre, env);
1460
1550
  switch (noeud.genre) {
1461
1551
  case 'titre':
1462
1552
  case 'soustitre': {
@@ -1485,6 +1575,8 @@ ${prise}${code}}
1485
1575
  }
1486
1576
  }
1487
1577
  }
1578
+ /** « menu repliable » : un bouton « Menu » le replie sur un petit écran. */
1579
+ const estRepliable = (noeud) => noeud.options.some((o) => o.nom === 'menu' && o.valeur === 'repliable');
1488
1580
  /** Les blocs d'éléments d'écran, décrits par widgets.ts. */
1489
1581
  const COMPOSITES = new Set(['onglets', 'deroulant', 'accordeon', 'carrousel', 'filAriane']);
1490
1582
  /** La balise de chaque bloc : une section, l'en-tête et le pied de la page, sinon une boîte. */
@@ -1512,6 +1604,7 @@ const OUTILS_PAGE = [
1512
1604
  'afficherMessage',
1513
1605
  'planifier',
1514
1606
  'mouvementReduit',
1607
+ 'themeSombre',
1515
1608
  'copier',
1516
1609
  'partager',
1517
1610
  'derniereRequete',
@@ -133,6 +133,10 @@ ${fonctions.join('\n')}`);
133
133
  const emploie = (motif) => scriptsDesPages.some((contenu) => motif.test(contenu));
134
134
  const interactions = { pressePapier: emploie(/\b(copier|partager)\(/), fenetres: emploie(/\bfenetre\(/), questions: emploie(/\bconfirmer\(/), bas: emploie(/\bonbas:/) };
135
135
  fichiers.set('humalis/humalis.js', humalisJs(options.source, pagesFichiers, serveur, session, bascule ? modeTheme ?? '' : undefined, usage, application.langues, interactions));
136
+ // « menu repliable » : un seul écouteur pour tous les menus, ceux du HTML de départ comme ceux redessinés.
137
+ if ([...fichiers.values()].some((contenu) => contenu.includes('humalis-menu-bouton'))) {
138
+ fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n${MENU_REPLIABLE_JS}`);
139
+ }
136
140
  // L'observateur des animations « au defilement », seulement si une page en a : il pose humalis-js au chargement.
137
141
  if (animeAuDefilement([...fichiers.values()])) {
138
142
  fichiers.set('humalis/humalis.js', `${fichiers.get('humalis/humalis.js')}\n// Les animations « au defilement » attendent que l'élément entre à l'écran.\n${OBSERVATEUR_DEFILEMENT}\nobserverLeDefilement();\n`);
@@ -291,6 +295,7 @@ function humalisJs(source, pages, serveur, session, theme, elements, langues, in
291
295
  'afficherMessage',
292
296
  'planifier',
293
297
  'mouvementReduit',
298
+ 'themeSombre',
294
299
  'cliquer',
295
300
  'focaliser',
296
301
  'retirerPremier',
@@ -544,6 +549,14 @@ ${interactions.fenetres ? ' // Une fenêtre fermée rend le focus à ce qui l
544
549
  // Le réglage change pendant la visite : la page est redessinée.
545
550
  animationsReduites?.addEventListener('change', () => actualiser());
546
551
 
552
+ /** « theme est sombre » : le thème affiché (choix du visiteur ou du projet, sinon le système). */
553
+ const schemaSombre = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
554
+ const themeSombre = () => {
555
+ const choisi = document.documentElement.dataset.theme;
556
+ return choisi ? choisi === 'sombre' : (schemaSombre?.matches ?? false);
557
+ };
558
+ schemaSombre?.addEventListener('change', () => actualiser());
559
+
547
560
  /** Les minuteries de la page existent tant que leur condition est vraie ; « arreter » les arrête pour de bon. */
548
561
  function reglerMinuteries() {
549
562
  for (const m of page.minuteries ?? []) {
@@ -1112,6 +1125,11 @@ const themeJs = (defaut) => `
1112
1125
  const THEME_PAR_DEFAUT = ${defaut ? `'${defaut}'` : "''"};
1113
1126
 
1114
1127
  function definirTheme(theme) {
1128
+ // « basculer theme » : l'inverse du thème affiché (choix du visiteur ou du projet, sinon le système).
1129
+ if (theme === 'bascule') {
1130
+ const affiche = document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'sombre' : 'clair');
1131
+ theme = affiche === 'sombre' ? 'clair' : 'sombre';
1132
+ }
1115
1133
  try {
1116
1134
  if (theme === 'automatique') localStorage.removeItem('${CLE_THEME}');
1117
1135
  else localStorage.setItem('${CLE_THEME}', theme);
@@ -1121,5 +1139,33 @@ const themeJs = (defaut) => `
1121
1139
  const choisi = theme === 'automatique' ? THEME_PAR_DEFAUT : theme;
1122
1140
  if (choisi) document.documentElement.dataset.theme = choisi;
1123
1141
  else delete document.documentElement.dataset.theme;
1142
+ // « theme est sombre » est relu : la page est redessinée.
1143
+ actualiser();
1124
1144
  }
1125
1145
  `;
1146
+ /** « menu repliable » : le bouton « Menu » ouvre et ferme les liens ; un lien choisi referme le menu. */
1147
+ const MENU_REPLIABLE_JS = `// Les menus repliables : le bouton « Menu » d'un petit écran.
1148
+ document.addEventListener('click', (evenement) => {
1149
+ const cible = evenement.target instanceof Element ? evenement.target : null;
1150
+ const menu = cible?.closest('.humalis-menu-repliable');
1151
+ if (!menu || !cible.closest('.humalis-menu-bouton, a')) return;
1152
+ const ouvrir = cible.closest('.humalis-menu-bouton') !== null && !menu.hasAttribute('data-ouvert');
1153
+ menu.toggleAttribute('data-ouvert', ouvrir);
1154
+ menu.querySelector(':scope > .humalis-menu-bouton')?.setAttribute('aria-expanded', String(ouvrir));
1155
+ });
1156
+ // Un clic à côté d'un menu ouvert, ou la touche Échap, le referme.
1157
+ const fermerLesMenus = (garder) => {
1158
+ for (const menu of document.querySelectorAll('.humalis-menu-repliable[data-ouvert]')) {
1159
+ if (garder && menu.contains(garder)) continue;
1160
+ menu.removeAttribute('data-ouvert');
1161
+ menu.querySelector(':scope > .humalis-menu-bouton')?.setAttribute('aria-expanded', 'false');
1162
+ }
1163
+ };
1164
+ document.addEventListener('click', (evenement) => fermerLesMenus(evenement.target instanceof Node ? evenement.target : null), true);
1165
+ document.addEventListener('keydown', (evenement) => {
1166
+ if (evenement.key !== 'Escape') return;
1167
+ const ouvert = document.querySelector('.humalis-menu-repliable[data-ouvert] > .humalis-menu-bouton');
1168
+ fermerLesMenus(null);
1169
+ ouvert?.focus();
1170
+ });
1171
+ `;
@@ -133,6 +133,9 @@ export function expressionJs(valeur, contexte) {
133
133
  case 'mouvementReduit':
134
134
  // « réduire les animations » du système (humalis.js), relu à chaque dessin.
135
135
  return 'mouvementReduit()';
136
+ case 'themeSombre':
137
+ // Le thème affiché (humalis.js), relu à chaque dessin.
138
+ return 'themeSombre()';
136
139
  case 'etatRequete':
137
140
  case 'erreurRequete':
138
141
  // Les sources ne sont pas encore produites pour le site statique : l'analyse de la cible les refuse avant.
@@ -371,8 +374,9 @@ export function evaluer(valeur, env) {
371
374
  }
372
375
  case 'utilisateur':
373
376
  case 'etatRequete':
374
- // Le HTML de départ n'écrit aucun mouvement.
377
+ // Le HTML de départ n'écrit aucun mouvement, et ne connaît pas le thème du visiteur.
375
378
  case 'mouvementReduit':
379
+ case 'themeSombre':
376
380
  return false;
377
381
  case 'erreurRequete':
378
382
  return '';
@@ -34,9 +34,39 @@ export function reglagesUtilises(codes) {
34
34
  export function cssEcran(codes) {
35
35
  const utilises = reglagesUtilises(codes);
36
36
  const regles = [];
37
+ if (codes.some((c) => c.includes('humalis-accent')))
38
+ regles.push('.humalis-accent { color: var(--humalis-marque, currentColor); }');
39
+ if (codes.some((c) => c.includes('humalis-code-ligne')))
40
+ regles.push('.humalis-code-ligne { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; padding: 0.1em 0.25em; border-radius: 0.2em; background: rgb(127 127 127 / 0.12); }');
41
+ if (codes.some((c) => c.includes('humalis-bloc-code')))
42
+ regles.push('.humalis-bloc-code-conteneur { position: relative; max-width: 100%; overflow-x: auto; } .humalis-bloc-code { overflow-x: auto; padding: 1rem; border-radius: 0.5rem; background: rgb(127 127 127 / 0.12); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } .humalis-bloc-code-conteneur button { position: absolute; top: 0.5rem; right: 0.5rem; }');
43
+ if (codes.some((c) => c.includes('humalis-code-mot')))
44
+ regles.push('.humalis-code-mot { color: #7348c3; font-weight: 600; } .humalis-code-texte { color: #147d50; } .humalis-code-commentaire { color: #68717d; } .humalis-code-nombre { color: #b25215; }');
45
+ if (codes.some((c) => c.includes('humalis-tableau-conteneur')))
46
+ regles.push('.humalis-tableau-conteneur { max-width: 100%; overflow-x: auto; } .humalis-tableau-conteneur table { border-collapse: collapse; width: 100%; } .humalis-tableau-conteneur th, .humalis-tableau-conteneur td { border: 1px solid rgb(127 127 127 / 0.3); padding: 0.5rem; text-align: left; } .humalis-tableau-conteneur caption { text-align: left; font-weight: 700; margin-bottom: 0.5rem; }');
47
+ if (codes.some((c) => c.includes('humalis-liste')))
48
+ regles.push('.humalis-liste { padding-inline-start: 1.5rem; } .humalis-liste > li { margin-block: 0.35rem; }');
37
49
  // L'en-tête et le pied communs : hors de la page, sur toute la largeur.
38
- if (codes.some((c) => /humalis-entete\b/.test(c)))
50
+ if (codes.some((c) => /humalis-entete\b/.test(c))) {
39
51
  regles.push('.humalis-entete { padding: 0.75rem 1rem; border-bottom: 1px solid rgb(138 138 144 / 0.4); }');
52
+ // « Aller au contenu » : hors de l'écran, il apparaît quand le clavier l'atteint.
53
+ regles.push('.humalis-aller-au-contenu { position: absolute; left: 0.5rem; top: -4rem; z-index: 1000; padding: 0.5rem 1rem; border-radius: 0.5rem; background: Canvas; color: CanvasText; }');
54
+ regles.push('.humalis-aller-au-contenu:focus { top: 0.5rem; }');
55
+ }
56
+ // « menu vertical » : les liens en colonne, qui ne passent jamais sur une deuxième colonne
57
+ // (un sommaire de hauteur limitée défile au lieu de s'étaler).
58
+ if (codes.some((c) => c.includes('humalis-menu-vertical')))
59
+ regles.push('.humalis-menu-vertical { flex-direction: column; flex-wrap: nowrap; align-items: stretch; }');
60
+ // « menu repliable » : sur un petit écran, les liens attendent le bouton « Menu ».
61
+ if (codes.some((c) => c.includes('humalis-menu-repliable'))) {
62
+ regles.push('.humalis-menu-bouton { display: none; }');
63
+ // L'icône burger : trois traits (le trait du milieu et ses deux ombres), une croix quand le menu est ouvert.
64
+ regles.push('.humalis-menu-bouton { align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; padding: 0; }');
65
+ regles.push('.humalis-menu-icone { position: relative; display: block; width: 1.125rem; height: 2px; border-radius: 2px; background: currentColor; box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor; }');
66
+ regles.push('.humalis-menu-bouton[aria-expanded="true"] .humalis-menu-icone { box-shadow: none; transform: rotate(45deg); }');
67
+ regles.push('.humalis-menu-bouton[aria-expanded="true"] .humalis-menu-icone::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: currentColor; transform: rotate(-90deg); }');
68
+ regles.push('@media (max-width: 720px) { .humalis-menu-repliable { flex-direction: column; align-items: stretch; } .humalis-menu-bouton { display: inline-flex; align-self: flex-start; } .humalis-menu-repliable:not([data-ouvert]) > :not(.humalis-menu-bouton) { display: none; } }');
69
+ }
40
70
  if (codes.some((c) => /humalis-pied\b/.test(c)))
41
71
  regles.push('.humalis-pied { padding: 1.5rem 1rem; margin-top: 2rem; border-top: 1px solid rgb(138 138 144 / 0.4); }');
42
72
  if (utilises.size === 0 && regles.length === 0)