From 63b997faa9426510f1f5bb435b5ba6d58afcae26 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 02:33:12 -0400 Subject: [PATCH] [ADD] interface: colour tokens, two themes, checked palette, marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's colours carry meaning — a conflict, a reservation, a mode — so they must stay readable for every reader and in both themes. jetons.css defines the tokens for the light and dark themes, set by data-theme on the root; palette.js reads them from the stylesheet text and checks, by computation, contrast to WCAG and separation under the three dichromacies, keeping the conflict hue reserved. The theme follows the system at each start and the operator's choice lasts the session. Checked: 1709 node and 43 browser tests. --- FR --- [ADD] interface : jetons de couleur, deux thèmes, palette, marques Les couleurs du plan portent un sens — un conflit, une réservation, un mode — et doivent rester lisibles pour tout lecteur et dans les deux thèmes. jetons.css définit les jetons des thèmes clair et sombre, posés par data-theme sur la racine ; palette.js les lit dans le texte de la feuille et contrôle, par calcul, le contraste au sens des WCAG et la séparation sous les trois dichromaties, la teinte du conflit restant réservée. Le thème suit le système à chaque démarrage ; le choix de l'opérateur vaut pour la séance. Vérifié : 1709 node et 43 navigateur. Assisted-by: Claude Opus 5.5 --- src/interface/App.navigateur.test.js | 51 ++-- src/interface/jetons.css | 148 +++++++++-- src/interface/marques.js | 74 ++++++ src/interface/marques.test.js | 132 ++++++++++ src/interface/palette.js | 292 ++++++++++++++++++++++ src/interface/palette.test.js | 356 +++++++++++++++++++++++++++ 6 files changed, 998 insertions(+), 55 deletions(-) create mode 100644 src/interface/marques.js create mode 100644 src/interface/marques.test.js create mode 100644 src/interface/palette.js create mode 100644 src/interface/palette.test.js 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']), []); + }); +});