diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index 0512019..162f7e8 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -9,21 +9,22 @@ // contraste se calcule sur les couleurs calculées, qui ne sont les couleurs // peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une // épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de -// visibilité, de contraste et d'opacité passent par les deux thèmes : le -// protocole de débogage de Chromium (CDP) émule prefers-color-scheme. +// visibilité, de contraste et d'opacité passent par les deux thèmes, que +// l'attribut data-theme de choisit (§ 19.2). Le contraste est celui de +// palette.js, seule formule des WCAG du dépôt. // // Le projet navigateur n'importe pas node:assert/strict : côté navigateur, // Vite le remplace par un module qui lève une erreur à tout accès. Il emploie // expect, et prend test et describe chez Vitest plutôt que dans // test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node. import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { cdp } from 'vitest/browser'; import { flushSync, mount, unmount } from 'svelte'; import App from './App.svelte'; +import { contraste } from './palette.js'; import { VERSION } from '../version.genere.js'; const TRANSPARENT = 'rgba(0, 0, 0, 0)'; -const THEMES = ['light', 'dark']; +const THEMES = ['clair', 'sombre']; // Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la // forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une @@ -38,21 +39,6 @@ function composantes(couleur) { return [Number(m[1]), Number(m[2]), Number(m[3]), alpha]; } -// Luminance relative d'une couleur sRGB, au sens des WCAG. -function luminance([r, g, b]) { - const lineaire = (c) => { - const s = c / 255; - return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * lineaire(r) + 0.7152 * lineaire(g) + 0.0722 * lineaire(b); -} - -function contraste(a, b) { - const la = luminance(a); - const lb = luminance(b); - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); -} - // L'élément, puis chacun de ses ancêtres jusqu'à la racine du document. function ascendance(element) { const chaine = []; @@ -89,20 +75,21 @@ function designation(element) { return classe ? `${element.localName}.${classe}` : element.localName; } -// Exécute mesure() sous le thème demandé et rend sa valeur. L'émulation est -// levée ensuite, même quand mesure() échoue : le test suivant retrouve la -// préférence propre du navigateur. +// Exécute mesure() sous le thème demandé, posé par data-theme sur , et +// rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son +// absence, même quand mesure() lève : le test suivant part du même document. async function sousTheme(theme, mesure) { - await cdp().send('Emulation.setEmulatedMedia', { - features: [{ name: 'prefers-color-scheme', value: theme }], - }); + const racine = document.documentElement; + const avant = racine.getAttribute('data-theme'); + racine.setAttribute('data-theme', theme); try { - expect(matchMedia('(prefers-color-scheme: dark)').matches, theme).toBe( - theme === 'dark', - ); - return mesure(); + return await mesure(); } finally { - await cdp().send('Emulation.setEmulatedMedia', { features: [] }); + if (avant === null) { + racine.removeAttribute('data-theme'); + } else { + racine.setAttribute('data-theme', avant); + } } } @@ -210,8 +197,8 @@ describe("bandeau d'application", () => { }); couleursParTheme.push(couleurs); } - // Une émulation sans effet, ou un thème sombre qui ne redéfinit aucune - // couleur, ferait mesurer deux fois le thème clair. + // Un attribut que la feuille ne lit pas, ou un thème sombre qui ne + // redéfinit aucune couleur, ferait mesurer deux fois le thème clair. expect( couleursParTheme[1], `mêmes couleurs dans les deux thèmes : ${couleursParTheme[0].join(' ; ')}`, diff --git a/src/interface/jetons.css b/src/interface/jetons.css index ee04242..c91cc15 100644 --- a/src/interface/jetons.css +++ b/src/interface/jetons.css @@ -1,42 +1,144 @@ /* © 2026 TechnoLibre (http://www.technolibre.ca) License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) */ -/* Jetons de l'interface : couleurs, typographie, espacements, rayons. Le thème - suit prefers-color-scheme ; chaque thème redéfinit les mêmes jetons de - couleur, et les composants ne lisent que les jetons (§ 13.3). +/* Jetons de l'interface, un seul fichier (§ 13.3) : couleurs, polices, + échelle typographique, espacements, rayons, ombres. Les composants ne lisent + que ces jetons ; aucune couleur n'est écrite ailleurs. - Chaque couleur de texte tient au moins 4,5:1 sur le fond où elle est peinte, - et aucune n'emploie de canal alpha : la couleur relue dans le style calculé - est alors la couleur peinte (§ 14.5). */ + Deux thèmes, posés par l'attribut data-theme de , que l'application + règle (§ 19.2) : :root[data-theme='clair'] et :root[data-theme='sombre'] + définissent les mêmes jetons de couleur ; sans attribut, le clair + s'applique, son bloc portant aussi le sélecteur :root. Le sélecteur du + sombre est plus spécifique que :root seul : posé, il l'emporte. La feuille + ne lit pas la préférence du système. + + La palette se valide par calcul (§ 12.7, § 14.5) : marques.js nomme, pour + chaque marque porteuse d'information et chaque texte, le jeton qui la peint + et ceux sur lesquels elle se peint ; palette.js mesure 3:1 pour une marque, + 4,5:1 pour un texte, dans les deux thèmes, en couleurs d'origine puis sous + les trois dichromaties. Aucun jeton de texte ou de marque n'a de canal + alpha : la couleur relue dans le style calculé est alors la couleur peinte. + + La teinte de --couleur-conflit est réservée (§ 12.7) : tout autre jeton + chromatique s'en tient à 30° au moins, et aucun ne porte sa valeur. + + La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le + fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. */ :root { - color-scheme: light; - - --couleur-page-fond: #f6f7f9; - --couleur-page-texte: #1c2128; - --couleur-bandeau-fond: #1f3a5f; - --couleur-bandeau-texte: #ffffff; - --couleur-bandeau-filet: #8fb3d9; - --police-interface: system-ui, sans-serif; + + /* Échelle typographique, du plus petit au plus grand. */ + --taille-texte-petit: 0.8125rem; --taille-texte-bandeau: 0.9375rem; + --taille-texte: 1rem; + --taille-titre: 1.25rem; + --taille-titre-grand: 1.5rem; --espace-1: 0.25rem; --espace-2: 0.5rem; --espace-3: 0.75rem; --espace-4: 1rem; + --espace-5: 1.5rem; + --espace-6: 2rem; --rayon-1: 0.25rem; + --rayon-2: 0.5rem; + + /* Ombres : une couleur opaque par thème, --couleur-ombre. */ + --ombre-1: 0 1px 2px var(--couleur-ombre); + --ombre-2: 0 2px 6px var(--couleur-ombre); } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; +:root, +:root[data-theme='clair'] { + color-scheme: light; - --couleur-page-fond: #121519; - --couleur-page-texte: #e3e6ea; - --couleur-bandeau-fond: #1d2c3f; - --couleur-bandeau-texte: #f2f4f7; - --couleur-bandeau-filet: #5b7fa6; - } + /* Page. */ + --couleur-page-fond: #f6f7f9; + --couleur-page-texte: #1c2128; + --couleur-page-texte-secondaire: #4b5563; + --couleur-page-filet: #c3c9d1; + --couleur-ombre: #b4bcc6; + + /* Bandeau d'application. */ + --couleur-bandeau-fond: #172b47; + --couleur-bandeau-texte: #ffffff; + --couleur-bandeau-filet: #8fb3d9; + + /* Cadre de mode (§ 8.4). */ + --couleur-cadre-lecture: #5c6878; + --couleur-cadre-ecriture: #2e7d32; + --couleur-cadre-bloque: #7b3fa0; + + /* Messages. */ + --couleur-refus: #6a1b9a; + --couleur-avis: #7a5000; + + /* Désignation. Le focus, la sélection, la mise en évidence et le fantôme + passent sur une chaise vide comme sur une chaise pleine : leur clarté se + tient entre le fond et le plein de la chaise, à 3:1 de chacun. */ + --couleur-focus: #8269e1; + --couleur-selection: #1a66f0; + --couleur-mise-en-evidence: #00897b; + + /* Plan : sol, mobilier, marques. */ + --couleur-sol: #eceef1; + --couleur-table-fond: #ffffff; + --couleur-table-trait: #4a5059; + --couleur-chaise-fond: #ffffff; + --couleur-chaise-trait: #4a5059; + --couleur-chaise-pleine: #1f1f1f; + --couleur-hachure: #1f1f1f; + --couleur-marque: #767676; + --couleur-conflit: #b3261e; + --couleur-pastille-fond: #2b2f36; + --couleur-pastille-texte: #ffffff; + --couleur-poignee: #4a5059; + --couleur-gel: #1565c0; + --couleur-liste-texte: #1c2128; + --couleur-echelle: #1c2128; + --couleur-fantome: #b040f0; +} + +:root[data-theme='sombre'] { + color-scheme: dark; + + --couleur-page-fond: #121519; + --couleur-page-texte: #e3e6ea; + --couleur-page-texte-secondaire: #a3acb8; + --couleur-page-filet: #3a414b; + --couleur-ombre: #000000; + + --couleur-bandeau-fond: #1d2c3f; + --couleur-bandeau-texte: #f2f4f7; + --couleur-bandeau-filet: #5b7fa6; + + --couleur-cadre-lecture: #9aa5b4; + --couleur-cadre-ecriture: #81c784; + --couleur-cadre-bloque: #ce93d8; + + --couleur-refus: #d7a6f0; + --couleur-avis: #f0c060; + + --couleur-focus: #8764e1; + --couleur-selection: #4070f0; + --couleur-mise-en-evidence: #00897b; + + --couleur-sol: #1a1d21; + --couleur-table-fond: #262b31; + --couleur-table-trait: #9aa3ae; + --couleur-chaise-fond: #15181c; + --couleur-chaise-trait: #b0b8c2; + --couleur-chaise-pleine: #e8e8e8; + --couleur-hachure: #ededed; + --couleur-marque: #7a7a7a; + --couleur-conflit: #ff8a80; + --couleur-pastille-fond: #e6e8eb; + --couleur-pastille-texte: #15181c; + --couleur-poignee: #c5ccd6; + --couleur-gel: #90caf9; + --couleur-liste-texte: #e3e6ea; + --couleur-echelle: #e3e6ea; + --couleur-fantome: #b040f0; } diff --git a/src/interface/marques.js b/src/interface/marques.js new file mode 100644 index 0000000..87b03c3 --- /dev/null +++ b/src/interface/marques.js @@ -0,0 +1,74 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Registre des marques porteuses d'information et des textes (§ 14.5, +// § 7.5, § 7.6, § 7.7). Chaque entrée nomme le jeton qui la peint et chaque +// jeton sur lequel elle se peint ; verifierPalette (palette.js) mesure le +// seuil de chaque couple dans les deux thèmes, sous les trois dichromaties, +// et les épreuves de rendu atteignent la marque par data-marque, qui porte +// son nom. +// +// Les remplissages d'une chaise sont son fond, son remplissage plein et les +// deux couleurs de la hachure — des traits --couleur-hachure sur le fond de +// la chaise : une marque posée sur une chaise se mesure sur chacun. + +const c = (nom) => `--couleur-${nom}`; + +const REMPLISSAGES_CHAISE = ['chaise-fond', 'chaise-pleine', 'hachure']; + +function entree(nom, jeton, fonds, seuil) { + return Object.freeze({ nom, jeton: c(jeton), fonds: Object.freeze(fonds.map(c)), seuil }); +} + +const marque = (nom, jeton, fonds) => entree(nom, jeton, fonds, 3); +const texte = (nom, jeton, fonds) => entree(nom, jeton, fonds, 4.5); + +/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la + * désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle + * se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. */ +export const MARQUES = Object.freeze([ + // Sur la chaise (§ 7.6) : cadenas posés sur ses remplissages ; l'anneau + // l'entoure, sur le sol et le bord du plateau qu'elle touche. + marque('cadenasFerme', 'marque', REMPLISSAGES_CHAISE), + marque('cadenasOuvert', 'marque', REMPLISSAGES_CHAISE), + marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + marque('hachure', 'hachure', ['chaise-fond']), + marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']), + // Le conflit (§ 7.5) : contour épaissi sur le sol, trame sur le plateau, + // pastille chiffrée près de la table. + marque('contourConflit', 'conflit', ['sol', 'table-fond']), + marque('trameConflit', 'conflit', ['table-fond']), + marque('pastille', 'pastille-fond', ['sol', 'table-fond']), + marque('poignee', 'poignee', ['sol']), + marque('gel', 'gel', ['sol', 'table-fond']), + // La désignation se pose sur la page, le sol, le plateau et une chaise + // vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond. + marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + marque('selection', 'selection', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + // Le cadre de mode (§ 8.4) entoure le contenu de l'événement, sur la page. + marque('cadreLecture', 'cadre-lecture', ['page-fond']), + marque('cadreEcriture', 'cadre-ecriture', ['page-fond']), + marque('cadreBloque', 'cadre-bloque', ['page-fond']), + marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + // Le mobilier sur le sol : le trait de la table, celui de la chaise, qui + // touche aussi le plateau. + marque('contourTable', 'table-trait', ['sol']), + marque('contourChaise', 'chaise-trait', ['sol', 'table-fond']), + // L'échelle graphique (§ 7.7) vit dans la surface du plan, sur le sol ou la + // page ; le fantôme d'un glissement passe sur le sol, les tables et les + // chaises. + marque('echelle', 'echelle', ['sol', 'page-fond']), + marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]), +]); + +/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus, + * avis, listes du plan sur le sol, pastille. */ +export const TEXTES = Object.freeze([ + texte('page', 'page-texte', ['page-fond']), + texte('secondaire', 'page-texte-secondaire', ['page-fond']), + texte('bandeau', 'bandeau-texte', ['bandeau-fond']), + texte('refus', 'refus', ['page-fond']), + texte('avis', 'avis', ['page-fond']), + texte('listes', 'liste-texte', ['sol']), + texte('pastille', 'pastille-texte', ['pastille-fond']), +]); diff --git a/src/interface/marques.test.js b/src/interface/marques.test.js new file mode 100644 index 0000000..2d06db1 --- /dev/null +++ b/src/interface/marques.test.js @@ -0,0 +1,132 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le registre des marques porteuses d'information et des textes (§ 14.5, +// § 7.5, § 7.6, § 7.7), et la palette de jetons.css validée par calcul sur +// lui (§ 12.7) : chaque entrée tient son seuil sur chacun de ses fonds, dans +// les deux thèmes, en couleurs d'origine puis sous les trois dichromaties. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, test } from '../../test/lanceur.js'; +import { MARQUES, TEXTES } from './marques.js'; +import { lireBlocs, lireJetons, verifierPalette } from './palette.js'; + +const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8'); +const THEMES = ['clair', 'sombre']; + +const NOMS_MARQUES = [ + 'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein', + 'contourConflit', 'trameConflit', 'pastille', 'poignee', 'gel', 'miseEnEvidence', + 'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus', + 'contourTable', 'contourChaise', 'echelle', 'fantome', +]; + +// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la +// hachure — ses traits sur le fond de la chaise. +const REMPLISSAGES_CHAISE = ['--couleur-chaise-fond', '--couleur-chaise-pleine', '--couleur-hachure']; + +// Fonds minimaux des marques de désignation et du fantôme. « chaise » vaut +// chacun des remplissages d'une chaise : la marque se pose sur une chaise +// vide, pourvue ou à pourvoir. +const c = (nom) => `--couleur-${nom}`; +const FONDS_REQUIS = { + focus: [...['page-fond', 'bandeau-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE], + selection: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE], + miseEnEvidence: [...['page-fond', 'sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE], + fantome: [...['sol', 'table-fond'].map(c), ...REMPLISSAGES_CHAISE], +}; + +const parNom = (registre, nom) => registre.find((e) => e.nom === nom); + +describe('registre des marques', () => { + test('les vingt marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => { + assert.equal(MARQUES.length, NOMS_MARQUES.length); + assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES); + assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length); + for (const marque of MARQUES) { + assert.deepEqual(Object.keys(marque).sort(), ['fonds', 'jeton', 'nom', 'seuil'], marque.nom); + assert.equal(marque.seuil, 3, marque.nom); + assert.ok(marque.fonds.length > 0, marque.nom); + assert.equal(new Set(marque.fonds).size, marque.fonds.length, `${marque.nom} : fond en double`); + } + }); + + test('le registre est figé', () => { + assert.ok(Object.isFrozen(MARQUES) && Object.isFrozen(TEXTES)); + assert.ok(MARQUES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds))); + assert.ok(TEXTES.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds))); + }); + + test('le focus, la sélection, la mise en évidence et le fantôme se mesurent sur chaque fond où ils se peignent, chaque remplissage de chaise compris', () => { + for (const nom of ['focus', 'selection', 'miseEnEvidence', 'fantome']) { + const marque = parNom(MARQUES, nom); + assert.notEqual(marque, undefined, nom); + for (const fond of FONDS_REQUIS[nom]) { + assert.ok(marque.fonds.includes(fond), `${nom} : ${fond}`); + } + } + }); + + test('une marque posée sur une chaise se mesure sur chacun de ses remplissages', () => { + const surChaise = ['cadenasFerme', 'cadenasOuvert', 'anneau']; + for (const nom of surChaise) { + for (const fond of REMPLISSAGES_CHAISE) { + assert.ok(parNom(MARQUES, nom).fonds.includes(fond), `${nom} : ${fond}`); + } + } + assert.deepEqual(parNom(MARQUES, 'hachure').fonds, ['--couleur-chaise-fond']); + }); + + test('chaque jeton et chaque fond du registre existe dans les deux thèmes', () => { + const entrees = [...MARQUES, ...TEXTES]; + assert.ok(entrees.length > 20); + for (const theme of THEMES) { + const jetons = lireJetons(JETONS, theme); + for (const entree of entrees) { + for (const nom of [entree.jeton, ...entree.fonds]) { + assert.ok(jetons.has(nom), `${theme} : ${entree.nom} → ${nom}`); + } + } + } + }); +}); + +describe('registre des textes', () => { + test('page, secondaire, bandeau, refus, avis, listes du plan, pastille ; seuil 4,5', () => { + assert.deepEqual(TEXTES.map((t) => t.nom), ['page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille']); + assert.ok(TEXTES.every((t) => t.seuil === 4.5 && t.fonds.length > 0)); + assert.deepEqual(parNom(TEXTES, 'listes').fonds, ['--couleur-sol']); + assert.deepEqual(parNom(TEXTES, 'pastille').fonds, ['--couleur-pastille-fond']); + }); +}); + +describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => { + test('aucun jeton de texte ou de marque, ni aucun de leurs fonds, n’a de canal alpha', () => { + const noms = new Set([...MARQUES, ...TEXTES].flatMap((e) => [e.jeton, ...e.fonds])); + assert.ok(noms.size > 20); + const blocs = lireBlocs(JETONS); + for (const theme of THEMES) { + const selecteur = `:root[data-theme='${theme}']`; + const brutes = new Map( + blocs.filter((b) => b.selecteurs.includes(selecteur)).flatMap((b) => [...b.declarations]), + ); + const jetons = lireJetons(JETONS, theme); + for (const nom of noms) { + assert.equal(jetons.get(nom)[3], 1, `${theme} : ${nom}`); + assert.doesNotMatch( + brutes.get(nom), + /rgba|hsla|transparent|#[0-9a-f]{8}\b|#[0-9a-f]{4}\b|\/\s*[\d.]+%?\s*\)/i, + `${theme} : ${nom}`, + ); + } + } + }); + + test('chaque marque tient 3:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => { + assert.deepEqual(verifierPalette(JETONS, MARQUES), []); + }); + + test('chaque texte tient 4,5:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => { + assert.deepEqual(verifierPalette(JETONS, TEXTES), []); + }); +}); diff --git a/src/interface/palette.js b/src/interface/palette.js new file mode 100644 index 0000000..a82acaf --- /dev/null +++ b/src/interface/palette.js @@ -0,0 +1,292 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La palette se valide par calcul (§ 12.7, § 14.5). Le module lit les jetons +// de couleur dans le texte de jetons.css, simule les trois dichromaties, +// mesure le contraste au sens des WCAG et contrôle la teinte réservée au +// conflit. Il est pur : il reçoit le texte de la feuille, ne lit aucun +// fichier et ne touche pas le document. +// +// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255, +// alpha de 0 à 1. + +const THEMES = ['clair', 'sombre']; + +// Ordre des simulations dans les fautes rendues : il ne dépend pas de l'ordre +// des clés de SIMULATIONS. +const ORDRE_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie']; + +// Une couleur se lit chromatique quand sa plus grande et sa plus petite +// composante s'écartent d'au moins SEUIL_CHROMATIQUE sur 255 ; en deçà, elle +// se lit grise et sa teinte n'a pas de sens. Deux teintes nommées voisines, +// le rouge et l'orange, se tiennent à ECART_TEINTE degrés. +const SEUIL_CHROMATIQUE = 26; +const ECART_TEINTE = 30; + +const PREFIXE_COULEUR = '--couleur-'; + +// Les seuls sélecteurs sous lesquels une couleur se déclare : :root, ce qui +// s'applique sans attribut, et un bloc par thème. +const SELECTEURS_COULEUR = [':root', ...THEMES.map((t) => `:root[data-theme='${t}']`)]; + +/** Matrices de Machado, Oliveira et Fernandes (2009), sévérité 1, appliquées en + * RGB linéaire. Chaque ligne somme à 1 : le blanc et le noir sont des points + * fixes, et un gris reste gris. */ +export const SIMULATIONS = Object.freeze({ + protanopie: Object.freeze([ + Object.freeze([0.152286, 1.052583, -0.204868]), + Object.freeze([0.114503, 0.786281, 0.099216]), + Object.freeze([-0.003882, -0.048116, 1.051998]), + ]), + deuteranopie: Object.freeze([ + Object.freeze([0.367322, 0.860646, -0.227968]), + Object.freeze([0.280085, 0.672501, 0.047413]), + Object.freeze([-0.01182, 0.04294, 0.968881]), + ]), + tritanopie: Object.freeze([ + Object.freeze([1.255528, -0.076749, -0.178779]), + Object.freeze([-0.078411, 0.930809, 0.147602]), + Object.freeze([0.004733, 0.691367, 0.3039]), + ]), +}); + +/** Les blocs de règles d'une feuille, dans leur ordre : { selecteurs, declarations }, + * selecteurs la liste des sélecteurs, espaces resserrés et guillemets + * d'attribut ramenés à l'apostrophe ; declarations une Map propriété → valeur + * brute, la dernière déclaration d'une propriété l'emportant. Les commentaires + * sont ôtés. Un bloc imbriqué (@media, @supports) ou non fermé lève : la + * feuille de jetons n'en porte aucun, et un thème caché dans une règle + * conditionnelle échapperait à la lecture. */ +export function lireBlocs(texteCss) { + const texte = texteCss.replace(/\/\*[\s\S]*?\*\//g, ''); + const blocs = []; + let debut = 0; + for (;;) { + const ouvrant = texte.indexOf('{', debut); + if (ouvrant === -1) { + if (texte.slice(debut).includes('}')) { + throw new Error('accolade fermante sans bloc ouvert'); + } + break; + } + const fermant = texte.indexOf('}', ouvrant); + if (fermant === -1) { + throw new Error(`bloc non fermé : ${texte.slice(debut, ouvrant).trim()}`); + } + const imbrique = texte.indexOf('{', ouvrant + 1); + if (imbrique !== -1 && imbrique < fermant) { + throw new Error(`bloc imbriqué non lu : ${texte.slice(debut, ouvrant).trim()}`); + } + const selecteurs = texte + .slice(debut, ouvrant) + .split(',') + .map((s) => s.trim().replace(/\s+/g, ' ').replace(/"/g, "'")); + const declarations = new Map(); + for (const declaration of texte.slice(ouvrant + 1, fermant).split(';')) { + const deuxPoints = declaration.indexOf(':'); + if (deuxPoints === -1) continue; + const nom = declaration.slice(0, deuxPoints).trim(); + const valeur = declaration.slice(deuxPoints + 1).trim(); + if (nom !== '') declarations.set(nom, valeur); + } + blocs.push({ selecteurs, declarations }); + debut = fermant + 1; + } + return blocs; +} + +// [r, g, b, a] d'une valeur de couleur littérale, ou null pour une forme que le +// module ne lit pas. Formes lues : #rgb, #rgba, #rrggbb, #rrggbbaa, +// rgb()/rgba() à virgules ou à espaces, alpha en nombre ou en pourcentage, +// transparent. +function couleurLitterale(valeur) { + const v = valeur.trim().toLowerCase(); + if (v === 'transparent') return [0, 0, 0, 0]; + const h = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v); + if (h !== null) { + const chiffres = h[1].length <= 4 ? [...h[1]].map((c) => c + c) : h[1].match(/../g); + const [r, g, b, a = 'ff'] = chiffres; + return [...[r, g, b].map((x) => Number.parseInt(x, 16)), a === 'ff' ? 1 : Number.parseInt(a, 16) / 255]; + } + const f = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(v); + if (f !== null) { + const alpha = f[4] === undefined ? 1 : Number(f[4]) / (f[5] === '%' ? 100 : 1); + return [Number(f[1]), Number(f[2]), Number(f[3]), alpha]; + } + return null; +} + +/** Couleurs d'un thème lues dans le texte de jetons.css : Map nom → [r, g, b, a], + * dans l'ordre des déclarations. Un jeton de couleur est une propriété + * --couleur-… ; il vaut une couleur littérale ou var(--couleur-…) d'un autre + * jeton du même thème. theme 'clair' ou 'sombre' : les blocs dont un sélecteur + * est :root[data-theme=''] ; theme null : les blocs dont un sélecteur + * est :root seul, ce qui s'applique sans attribut. Lève sur un thème sans + * bloc, une valeur illisible, un renvoi sans cible ou circulaire, et sur un + * bloc qui déclare une couleur sous un autre sélecteur que :root et + * :root[data-theme='clair' | 'sombre'] : dans le navigateur, il masquerait + * le jeton que la lecture mesure. */ +export function lireJetons(texteCss, theme) { + const tous = lireBlocs(texteCss); + for (const bloc of tous) { + const autres = bloc.selecteurs.filter((s) => !SELECTEURS_COULEUR.includes(s)); + const couleur = [...bloc.declarations.keys()].find((n) => n.startsWith(PREFIXE_COULEUR)); + if (autres.length > 0 && couleur !== undefined) { + throw new Error(`couleur ${couleur} déclarée sous un sélecteur non lu : ${autres.join(', ')}`); + } + } + const selecteur = theme === null ? ':root' : `:root[data-theme='${theme}']`; + const blocs = tous.filter((b) => b.selecteurs.includes(selecteur)); + if (blocs.length === 0) { + throw new Error(`aucun bloc pour le thème ${theme}`); + } + const brutes = new Map(); + for (const bloc of blocs) { + for (const [nom, valeur] of bloc.declarations) { + if (nom.startsWith(PREFIXE_COULEUR)) brutes.set(nom, valeur); + } + } + const resolues = new Map(); + const resoudre = (nom, chemin) => { + if (resolues.has(nom)) return resolues.get(nom); + if (chemin.includes(nom)) { + throw new Error(`renvoi circulaire : ${[...chemin, nom].join(' → ')}`); + } + if (!brutes.has(nom)) { + throw new Error(`jeton absent du thème ${theme} : ${nom}${chemin.length ? ` (lu par ${chemin.at(-1)})` : ''}`); + } + const valeur = brutes.get(nom); + const renvoi = /^var\(\s*(--[\w-]+)\s*\)$/.exec(valeur); + const couleur = renvoi !== null ? resoudre(renvoi[1], [...chemin, nom]) : couleurLitterale(valeur); + if (couleur === null) { + throw new Error(`couleur illisible, thème ${theme} : ${nom}: ${valeur}`); + } + resolues.set(nom, couleur); + return couleur; + }; + const jetons = new Map(); + for (const nom of brutes.keys()) { + jetons.set(nom, resoudre(nom, [])); + } + return jetons; +} + +const versLineaire = (c) => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; + +const versSrgb = (l) => { + const borne = Math.min(1, Math.max(0, l)); + const s = borne <= 0.0031308 ? borne * 12.92 : 1.055 * borne ** (1 / 2.4) - 0.055; + return s * 255; +}; + +/** La couleur que voit une personne atteinte de la dichromatie nommée : la + * matrice s'applique aux composantes linéaires, le résultat est borné à + * [0, 1] puis rendu en sRGB, sans arrondi. Alpha inchangé. */ +export function simuler(couleur, nomSimulation) { + if (!Object.hasOwn(SIMULATIONS, nomSimulation)) { + throw new Error(`simulation inconnue : ${nomSimulation}`); + } + const matrice = SIMULATIONS[nomSimulation]; + const lineaire = couleur.slice(0, 3).map(versLineaire); + const simulee = matrice.map((ligne) => versSrgb(ligne[0] * lineaire[0] + ligne[1] * lineaire[1] + ligne[2] * lineaire[2])); + return [...simulee, couleur[3] ?? 1]; +} + +function luminance(couleur) { + const [r, g, b] = couleur.map(versLineaire); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** Rapport de contraste des WCAG entre deux couleurs opaques, de 1 à 21, + * symétrique. La seule implémentation du dépôt (contrainte 1) : + * App.navigateur.test.js l'importe, test/navigateur/rendu.js la réexporte. + * Une couleur dont l'alpha est donné et différent de 1 lève : le ratio d'une + * couleur composée avec ce qui est dessous n'est pas celui de ses + * composantes. */ +export function contraste(a, b) { + for (const couleur of [a, b]) { + if (couleur[3] !== undefined && couleur[3] !== 1) { + throw new Error(`contraste d'une couleur à canal alpha ${couleur[3]} : [${couleur.join(', ')}]`); + } + } + const la = luminance(a.slice(0, 3)); + const lb = luminance(b.slice(0, 3)); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +/** Chaque entrée tient son seuil sur chacun de ses fonds, sur les couleurs + * d'origine puis sous chaque simulation, dans les deux thèmes. entrees : + * [{jeton, fonds, seuil}], noms de jetons --couleur-…. Rend les fautes + * [{theme, simulation, jeton, fond, ratio, seuil}], simulation valant + * 'origine' pour les couleurs d'origine, dans l'ordre thème, simulation, + * entrée, fond ; [] quand tout tient. Un jeton absent d'un thème lève. */ +export function verifierPalette(texteCss, entrees) { + const fautes = []; + for (const theme of THEMES) { + const jetons = lireJetons(texteCss, theme); + const lire = (nom) => { + if (!jetons.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`); + return jetons.get(nom); + }; + for (const simulation of ['origine', ...ORDRE_SIMULATIONS]) { + const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation)); + for (const { jeton, fonds, seuil } of entrees) { + const couleur = vue(lire(jeton)); + for (const fond of fonds) { + const ratio = contraste(couleur, vue(lire(fond))); + if (ratio < seuil) fautes.push({ theme, simulation, jeton, fond, ratio, seuil }); + } + } + } + } + return fautes; +} + +/** Teinte HSL d'une couleur en degrés, dans [0, 360), ou null quand sa plus + * grande et sa plus petite composante s'écartent de moins de + * SEUIL_CHROMATIQUE : la couleur se lit grise. */ +export function teinte(couleur) { + const [r, g, b] = couleur; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const etendue = max - min; + if (etendue < SEUIL_CHROMATIQUE) return null; + let h; + if (max === r) h = ((g - b) / etendue) % 6; + else if (max === g) h = (b - r) / etendue + 2; + else h = (r - g) / etendue + 4; + const degres = h * 60; + return degres < 0 ? degres + 360 : degres; +} + +/** § 12.7 : la teinte de chaque jeton chromatique se tient à ECART_TEINTE + * degrés au moins de celle du conflit, dans chaque thème. jetonsDuConflit : + * le jeton de conflit d'abord, puis ceux qui ne peignent que les marques de + * conflit, que le contrôle ne mesure pas. Rend les fautes [{theme, jeton, + * ecart}], ecart en degrés sur le cercle ; un jeton de conflit achromatique + * est lui-même une faute, d'écart null — aucune teinte n'est alors réservée. */ +export function verifierTeinteReservee(texteCss, jetonsDuConflit) { + const [conflit] = jetonsDuConflit; + const fautes = []; + for (const theme of THEMES) { + const jetons = lireJetons(texteCss, theme); + if (!jetons.has(conflit)) throw new Error(`jeton absent du thème ${theme} : ${conflit}`); + const reference = teinte(jetons.get(conflit)); + if (reference === null) { + fautes.push({ theme, jeton: conflit, ecart: null }); + continue; + } + for (const [nom, couleur] of jetons) { + if (jetonsDuConflit.includes(nom)) continue; + const h = teinte(couleur); + if (h === null) continue; + const brut = Math.abs(h - reference); + const ecart = Math.min(brut, 360 - brut); + if (ecart < ECART_TEINTE) fautes.push({ theme, jeton: nom, ecart }); + } + } + return fautes; +} diff --git a/src/interface/palette.test.js b/src/interface/palette.test.js new file mode 100644 index 0000000..5f18d76 --- /dev/null +++ b/src/interface/palette.test.js @@ -0,0 +1,356 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Lecture des jetons, simulation des dichromaties, contraste et teinte +// réservée (§ 12.7, § 13.3, § 14.5). Les fonctions s'éprouvent d'abord sur des +// feuilles d'épreuve écrites ici, où chaque défaut est posé exprès, puis sur +// le vrai fichier de jetons : ses familles, ses deux thèmes, les jetons que +// lisent les composants, la teinte du conflit. +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from '../../test/lanceur.js'; +import { + SIMULATIONS, + contraste, + lireBlocs, + lireJetons, + simuler, + teinte, + verifierPalette, + verifierTeinteReservee, +} from './palette.js'; + +const DOSSIER = fileURLToPath(new URL('.', import.meta.url)); +const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8'); +const NOMS_SIMULATIONS = ['protanopie', 'deuteranopie', 'tritanopie']; + +// Feuille d'épreuve : le même corps de couleurs dans les deux thèmes, le +// clair valant aussi sans attribut. +function feuille(couleurs, sombre = couleurs) { + const corps = (table) => + Object.entries(table) + .map(([nom, valeur]) => ` ${nom}: ${valeur};`) + .join('\n'); + return [ + ':root { --espace-1: 0.25rem; }', + `:root,\n:root[data-theme='clair'] {\n color-scheme: light;\n${corps(couleurs)}\n}`, + `:root[data-theme="sombre"] {\n color-scheme: dark;\n${corps(sombre)}\n}`, + ].join('\n\n'); +} + +// Composantes sRGB de 0 à 255 d'une couleur #rrggbb. +function hex(valeur) { + return [1, 3, 5].map((i) => Number.parseInt(valeur.slice(i, i + 2), 16)); +} + +const enHex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +describe('lireBlocs et lireJetons', () => { + test('lit les blocs dans leur ordre, commentaires ôtés, et les couleurs de chaque thème', () => { + const css = [ + "/* { un commentaire n'ouvre aucun bloc } */", + ':root { --espace-1: 0.25rem; --police-interface: system-ui, sans-serif; }', + ":root, :root[data-theme='clair'] {", + ' --couleur-a: #fff;', + ' --couleur-b: #102030;', + ' --couleur-c: rgb(1, 2, 3);', + ' --couleur-d: rgb(4 5 6);', + ' --couleur-e: var(--couleur-b);', + ' --couleur-f: rgba(7, 8, 9, 0.5);', + ' --couleur-g: #10203080;', + ' --couleur-h: transparent;', + '}', + ':root[data-theme="sombre"] { --couleur-a: #000000; }', + ].join('\n'); + const blocs = lireBlocs(css); + assert.equal(blocs.length, 3); + assert.deepEqual(blocs[0].selecteurs, [':root']); + assert.deepEqual(blocs[1].selecteurs, [':root', ":root[data-theme='clair']"]); + assert.deepEqual(blocs[2].selecteurs, [":root[data-theme='sombre']"]); + assert.equal(blocs[0].declarations.get('--police-interface'), 'system-ui, sans-serif'); + + const clair = lireJetons(css, 'clair'); + assert.deepEqual([...clair.keys()], [ + '--couleur-a', '--couleur-b', '--couleur-c', '--couleur-d', + '--couleur-e', '--couleur-f', '--couleur-g', '--couleur-h', + ]); + assert.deepEqual(clair.get('--couleur-a'), [255, 255, 255, 1]); + assert.deepEqual(clair.get('--couleur-b'), [16, 32, 48, 1]); + assert.deepEqual(clair.get('--couleur-c'), [1, 2, 3, 1]); + assert.deepEqual(clair.get('--couleur-d'), [4, 5, 6, 1]); + assert.deepEqual(clair.get('--couleur-e'), [16, 32, 48, 1]); + assert.deepEqual(clair.get('--couleur-f'), [7, 8, 9, 0.5]); + assert.deepEqual(clair.get('--couleur-g'), [16, 32, 48, 128 / 255]); + assert.deepEqual(clair.get('--couleur-h'), [0, 0, 0, 0]); + assert.deepEqual([...lireJetons(css, 'sombre')], [['--couleur-a', [0, 0, 0, 1]]]); + // Sans attribut : les seuls blocs :root, ceux que le clair partage. + assert.deepEqual(lireJetons(css, null), clair); + }); + + test('lève sur un thème absent, une couleur illisible, un renvoi sans cible et un bloc imbriqué', () => { + const css = feuille({ '--couleur-a': '#ffffff' }); + assert.throws(() => lireJetons(css, 'contraste'), /contraste/); + assert.throws(() => lireJetons(feuille({ '--couleur-a': 'hsl(0 0% 50%)' }), 'clair'), /--couleur-a/); + assert.throws(() => lireJetons(feuille({ '--couleur-a': 'var(--couleur-z)' }), 'clair'), /--couleur-z/); + assert.throws( + () => lireJetons(feuille({ '--couleur-a': 'var(--couleur-b)', '--couleur-b': 'var(--couleur-a)' }), 'clair'), + /--couleur-a/, + ); + assert.throws( + () => lireBlocs('@media (prefers-color-scheme: dark) { :root { --couleur-a: #000; } }'), + /imbriqué/, + ); + assert.throws(() => lireBlocs(':root { --couleur-a: #000;'), /fermé/); + }); + + test('lève sur une couleur déclarée sous un autre sélecteur que :root et les deux thèmes : elle masquerait le jeton lu', () => { + const base = feuille({ '--couleur-sol': '#eeeeee', '--couleur-focus': '#5a3fc0' }); + for (const intrus of [ + ":root[data-theme='sombre'] svg { --couleur-sol: #ff8a80; --couleur-focus: #e8e8e8; }", + 'html { --couleur-sol: #ff8a80; }', + ":root[data-theme='contraste'] { --couleur-sol: #ff8a80; }", + ":root, .plan { --couleur-sol: #ff8a80; }", + ]) { + const css = `${base}\n${intrus}`; + for (const theme of ['clair', 'sombre', null]) { + assert.throws(() => lireJetons(css, theme), /sélecteur/, `${theme} : ${intrus}`); + } + assert.throws(() => lireJetons(`${JETONS}\n${intrus}`, 'sombre'), /sélecteur/, intrus); + } + // Un autre sélecteur sans couleur n'est pas lu, et ne lève pas. + assert.deepEqual(lireJetons(`${base}\n.plan { --espace-1: 1rem; }`, 'clair'), lireJetons(base, 'clair')); + }); +}); + +describe('contraste et simulations', () => { + test('noir sur blanc vaut 21, une couleur sur elle-même 1, dans les deux sens', () => { + const noir = [0, 0, 0, 1]; + const blanc = [255, 255, 255, 1]; + assert.ok(Math.abs(contraste(noir, blanc) - 21) < 1e-9); + assert.equal(contraste(blanc, noir), contraste(noir, blanc)); + assert.equal(contraste([90, 120, 30, 1], [90, 120, 30, 1]), 1); + // Valeur connue : #767676 sur blanc, 4,54:1 au sens des WCAG. + assert.ok(Math.abs(contraste(hex('#767676'), hex('#ffffff')) - 4.54) < 0.01); + }); + + test("lève sur une couleur à canal alpha partiel : le ratio d'une couleur composée mentirait", () => { + assert.throws(() => contraste([0, 0, 0, 0.5], [255, 255, 255, 1]), /alpha/); + assert.throws(() => contraste([0, 0, 0, 1], [255, 255, 255, 0]), /alpha/); + assert.ok(contraste([0, 0, 0], [255, 255, 255]) > 20); + }); + + test('les trois matrices sont celles de Machado, Oliveira et Fernandes, sévérité 1', () => { + assert.deepEqual(Object.keys(SIMULATIONS).sort(), [...NOMS_SIMULATIONS].sort()); + assert.deepEqual(SIMULATIONS.protanopie[0], [0.152286, 1.052583, -0.204868]); + assert.deepEqual(SIMULATIONS.deuteranopie[1], [0.280085, 0.672501, 0.047413]); + assert.deepEqual(SIMULATIONS.tritanopie[2], [0.004733, 0.691367, 0.3039]); + }); + + test('le blanc et le noir sont des points fixes des trois matrices, à 1/255 près ; alpha intact', () => { + assert.equal(NOMS_SIMULATIONS.length, 3); + for (const nom of NOMS_SIMULATIONS) { + for (const couleur of [[255, 255, 255, 1], [0, 0, 0, 1]]) { + const simulee = simuler(couleur, nom); + for (let i = 0; i < 3; i += 1) { + assert.ok(Math.abs(simulee[i] - couleur[i]) <= 1, `${nom} ${couleur} → ${simulee}`); + } + assert.equal(simulee[3], 1); + } + } + assert.throws(() => simuler([0, 0, 0, 1], 'achromatopsie'), /achromatopsie/); + }); + + test('un gris reste gris, un rouge pur perd sa clarté sous la protanopie', () => { + const gris = simuler([118, 118, 118, 1], 'deuteranopie'); + assert.ok(Math.abs(gris[0] - 118) <= 1 && Math.abs(gris[2] - 118) <= 1, `${gris}`); + const rouge = [255, 0, 0, 1]; + assert.ok(contraste(simuler(rouge, 'protanopie'), [0, 0, 0, 1]) < contraste(rouge, [0, 0, 0, 1])); + }); +}); + +describe('verifierPalette', () => { + // Cherche un rouge et un vert qui tiennent 3:1 en couleurs d'origine et + // tombent sous 3:1 sous au moins une simulation. + function coupleRougeVert() { + for (let a = 0; a <= 200; a += 8) { + for (let v = 40; v <= 200; v += 8) { + const rouge = [255, a, a, 1]; + const vert = [0, v, 0, 1]; + if (contraste(rouge, vert) < 3) continue; + const simule = NOMS_SIMULATIONS.some( + (nom) => contraste(simuler(rouge, nom), simuler(vert, nom)) < 3, + ); + if (simule) return { rouge, vert }; + } + } + return null; + } + + test('un couple rouge-vert qui tient 3:1 en origine et tombe une fois simulé est refusé, sous simulation seulement', () => { + const couple = coupleRougeVert(); + assert.notEqual(couple, null); + const css = feuille({ '--couleur-rouge': enHex(couple.rouge.slice(0, 3)), '--couleur-vert': enHex(couple.vert.slice(0, 3)) }); + const fautes = verifierPalette(css, [{ jeton: '--couleur-rouge', fonds: ['--couleur-vert'], seuil: 3 }]); + assert.ok(fautes.length > 0); + assert.ok(fautes.every((f) => f.simulation !== 'origine'), JSON.stringify(fautes)); + assert.ok(fautes.every((f) => f.ratio < 3 && f.seuil === 3)); + assert.ok(fautes.every((f) => f.jeton === '--couleur-rouge' && f.fond === '--couleur-vert')); + assert.deepEqual([...new Set(fautes.map((f) => f.theme))], ['clair', 'sombre']); + }); + + test('une marque qui tient sur son premier fond et pas sur le second est refusée sur le second', () => { + const css = feuille({ + '--couleur-marque': '#202020', + '--couleur-clair': '#ffffff', + '--couleur-sombre': '#000000', + }); + const fautes = verifierPalette(css, [ + { jeton: '--couleur-marque', fonds: ['--couleur-clair', '--couleur-sombre'], seuil: 3 }, + ]); + // Deux thèmes, l'origine et trois simulations : huit fautes, toutes sur le second fond. + assert.equal(fautes.length, 8); + assert.ok(fautes.every((f) => f.fond === '--couleur-sombre')); + assert.deepEqual( + fautes.map((f) => `${f.theme}:${f.simulation}`), + ['clair', 'sombre'].flatMap((t) => ['origine', ...NOMS_SIMULATIONS].map((s) => `${t}:${s}`)), + ); + }); + + test('rend [] quand chaque entrée tient sur chaque fond ; lève sur un jeton inconnu', () => { + const css = feuille({ '--couleur-texte': '#000000', '--couleur-fond': '#ffffff' }, { '--couleur-texte': '#ffffff', '--couleur-fond': '#000000' }); + assert.deepEqual(verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-fond'], seuil: 4.5 }]), []); + assert.throws( + () => verifierPalette(css, [{ jeton: '--couleur-texte', fonds: ['--couleur-absente'], seuil: 3 }]), + /--couleur-absente/, + ); + }); +}); + +describe('teinte et verifierTeinteReservee (§ 12.7)', () => { + test('la teinte HSL ne vaut que pour une couleur dont les composantes s’écartent de 26 au moins', () => { + assert.equal(teinte([255, 0, 0, 1]), 0); + assert.equal(teinte([0, 255, 0, 1]), 120); + assert.equal(teinte([0, 0, 255, 1]), 240); + assert.equal(teinte([255, 0, 128, 1]) > 329 && teinte([255, 0, 128, 1]) < 331, true); + assert.equal(teinte([138, 125, 125, 1]), null); // écart 13 : gris + assert.equal(teinte([125, 100, 100, 1]), null); // écart 25 : gris + assert.notEqual(teinte([126, 100, 100, 1]), null); // écart 26 : chromatique + }); + + test('un refus de la teinte du conflit et d’une autre clarté est refusé, valeurs pourtant distinctes', () => { + const conflit = '#c62828'; + const refus = '#5a1212'; + assert.notEqual(conflit, refus); + assert.ok(Math.abs(teinte(hex(conflit)) - teinte(hex(refus))) < 1); + const css = feuille({ + '--couleur-conflit': conflit, + '--couleur-conflit-trame': '#e57373', + '--couleur-refus': refus, + '--couleur-gris-rose': '#8a7d7d', + '--couleur-bleu': '#1f3a5f', + }); + const fautes = verifierTeinteReservee(css, ['--couleur-conflit', '--couleur-conflit-trame']); + assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), ['clair:--couleur-refus', 'sombre:--couleur-refus']); + assert.ok(fautes.every((f) => f.ecart < 30)); + }); + + test('le jeton de conflit lui-même doit être chromatique ; une teinte à 30° passe, à 29° non ; le cercle se referme', () => { + const gris = feuille({ '--couleur-conflit': '#808080', '--couleur-bleu': '#1f3a5f' }); + assert.deepEqual(verifierTeinteReservee(gris, ['--couleur-conflit']), [ + { theme: 'clair', jeton: '--couleur-conflit', ecart: null }, + { theme: 'sombre', jeton: '--couleur-conflit', ecart: null }, + ]); + // Conflit à 0° ; 30° et 330° passent, 29° et 331° non. + const css = feuille({ + '--couleur-conflit': '#ff0000', + '--couleur-a': '#ff8000', // 30,1° + '--couleur-b': '#ff7b00', // 28,9° + '--couleur-c': '#ff0080', // 329,9° + '--couleur-d': '#ff0079', // 331,5° + }); + const fautes = verifierTeinteReservee(css, ['--couleur-conflit']); + assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [ + 'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d', + ]); + }); +}); + +// Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés. +function sourcesDeStyle() { + return readdirSync(DOSSIER, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile() && /\.(svelte|css)$/.test(e.name)) + .map((e) => join(e.parentPath, e.name)) + .sort(); +} + +describe('jetons.css', () => { + // Lue dans chaque épreuve : une feuille illisible fait échouer chacune, sans + // empêcher les épreuves des fonctions de se lancer. + const blocs = () => lireBlocs(JETONS); + const declares = () => new Set(blocs().flatMap((b) => [...b.declarations.keys()])); + const nomsDe = (prefixe) => [...declares()].filter((n) => n.startsWith(prefixe)); + + test('un fichier, toutes les familles : couleurs, polices, échelle typographique, espacements, rayons, ombres (§ 13.3)', () => { + for (const prefixe of ['--couleur-', '--police-', '--taille-', '--espace-', '--rayon-', '--ombre-']) { + assert.ok(nomsDe(prefixe).length > 0, `aucun jeton ${prefixe}…`); + } + // Une échelle a trois pas au moins, de valeurs distinctes. + const tailles = new Set( + blocs().flatMap((b) => [...b.declarations].filter(([n]) => n.startsWith('--taille-')).map(([, v]) => v)), + ); + assert.ok(tailles.size >= 3, `${tailles.size} tailles distinctes`); + }); + + test('deux thèmes posés par data-theme, de mêmes jetons de couleur ; sans attribut, le clair ; aucune préférence du système lue', () => { + const clair = lireJetons(JETONS, 'clair'); + const sombre = lireJetons(JETONS, 'sombre'); + assert.ok(clair.size >= 20, `${clair.size} couleurs`); + assert.deepEqual([...sombre.keys()].sort(), [...clair.keys()].sort()); + assert.deepEqual(lireJetons(JETONS, null), clair); + // Le thème sombre redéfinit chaque couleur à une autre valeur que le clair, + // sauf celles que les deux thèmes partagent exprès. + const differentes = [...clair.keys()].filter((n) => String(clair.get(n)) !== String(sombre.get(n))); + assert.ok(differentes.length > clair.size / 2, `${differentes.length} couleurs changent`); + assert.equal(/prefers-color-scheme/.test(JETONS.replace(/\/\*[\s\S]*?\*\//g, '')), false); + }); + + test('chaque var(--…) lu par un .svelte ou un .css de src/interface est déclaré dans jetons.css', () => { + const sources = sourcesDeStyle(); + assert.ok(sources.length >= 2, `${sources.length} sources de style`); + const lus = []; + for (const chemin of sources) { + for (const [, nom] of readFileSync(chemin, 'utf8').matchAll(/var\(\s*(--[\w-]+)/g)) { + lus.push({ chemin, nom }); + } + } + assert.ok(lus.length >= 11, `${lus.length} lectures`); + const noms = declares(); + const absents = lus.filter((l) => !noms.has(l.nom)).map((l) => `${l.chemin} ${l.nom}`); + assert.deepEqual(absents, []); + }); + + test("les onze jetons d'App.svelte gardent leur nom", () => { + const noms = declares(); + for (const nom of [ + '--couleur-page-fond', '--couleur-page-texte', '--couleur-bandeau-fond', + '--couleur-bandeau-texte', '--couleur-bandeau-filet', '--police-interface', + '--taille-texte-bandeau', '--espace-2', '--espace-3', '--espace-4', '--rayon-1', + ]) { + assert.ok(noms.has(nom), nom); + } + }); + + test('la teinte du conflit est réservée (§ 12.7) : chromatique, sa valeur à lui seul, à 30° de tout autre jeton chromatique', () => { + for (const theme of ['clair', 'sombre']) { + const jetons = lireJetons(JETONS, theme); + const conflit = jetons.get('--couleur-conflit'); + assert.notEqual(conflit, undefined, theme); + assert.notEqual(teinte(conflit), null, `${theme} : conflit achromatique`); + const memeValeur = [...jetons].filter(([n, c]) => n !== '--couleur-conflit' && String(c) === String(conflit)); + assert.deepEqual(memeValeur.map(([n]) => n), [], theme); + } + assert.deepEqual(verifierTeinteReservee(JETONS, ['--couleur-conflit']), []); + }); +});