diff --git a/spec.md b/spec.md index 6bb76aa..e7fb2cc 100644 --- a/spec.md +++ b/spec.md @@ -1057,6 +1057,24 @@ Le signal **ne repose pas sur la seule couleur** : contour épaissi, trame, et une **pastille portant le nombre de violations**. Une part notable des opérateurs ne distingue pas un rouge d'un vert. +**Chaque règle a sa trame, d'une forme et d'une teinte propres** — hachures +descendantes pour « séparer les appartenances », hachures montantes pour +« nouveaux voisins », points pour « nouvelle table ». La forme suffit sans la +couleur ; la teinte la redit. Une table qui viole plusieurs règles superpose +leurs trames — les deux hachures se croisent en quadrillage — sous un même +contour épaissi, commun aux trois règles ; la pastille compte toutes ses +violations. + +**Une légende des conflits reste accessible depuis le plan**, par la barre de +vue : pour chaque règle active, un échantillon de sa trame — la forme et la +teinte que le plan peint — et son nom ; elle dit aussi ce que compte la +pastille. Une règle inactive n'y paraît pas. + +Le détail d'une table en conflit nomme les personnes de chaque violation ; +pour « séparer les appartenances », il nomme aussi **l'appartenance qui les +réunit**, telle que la liste la porte : « Même appartenance (Club des Merles) : +Iris Ombrelle et Théo Pervenche ». + **Quand toutes les tables sont en conflit, le plan cesse de signaler quoi que ce soit** — cas que le diagnostic sait prédire. La pastille chiffrée garde sa valeur puisqu'elle ordonne les tables entre elles, et le bandeau énonce le @@ -2603,8 +2621,11 @@ Une page de statistiques se casse par ses bords, et chacun se produira. - **L'identité d'une série ne repose jamais sur la seule couleur** : au plus deux séries, **étiquetées directement sur le tracé** en plus de la légende. La page s'imprime, souvent en noir et blanc. -- **Les couleurs d'état sont réservées** : la teinte qui signale un conflit ici - est **la même** que sur le plan de salle, et ne sert à rien d'autre. +- **Les couleurs d'état sont réservées** : **les teintes de conflit** — trois, + une par règle que le plan localise (§ 7.5) — sont ici **les mêmes** que sur + le plan de salle, et ne servent à rien d'autre ; chacune ne peint que les + marques de sa règle. Elles se distinguent entre elles et des autres marques + du plan **une fois les trois dichromaties simulées**. - **Une échelle séquentielle est une seule teinte, du clair au foncé.** Jamais d'arc-en-ciel : un dégradé multicolore invente des frontières là où la grandeur est continue. diff --git a/src/interface/jetons.css b/src/interface/jetons.css index c91cc15..4545524 100644 --- a/src/interface/jetons.css +++ b/src/interface/jetons.css @@ -19,8 +19,16 @@ 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. + Trois teintes sont réservées aux conflits (§ 7.5, § 12.7), une par règle + que le plan localise : --couleur-conflit-appartenance (séparer les + appartenances), --couleur-conflit-voisins (nouveaux voisins) et + --couleur-conflit-table (nouvelle table). Chacune ne peint que la trame de + sa règle ; tout autre jeton chromatique se tient à 30° au moins de chacune, + et aucun ne porte la valeur de l'une. Elles se distinguent entre elles et + des autres marques du plan sous les trois dichromaties (SEPARATIONS, + marques.js), et tiennent 3:1 sur le fond du plateau, qui borde leurs + traits. Le contour épaissi d'une table en conflit, commun aux trois, est + de l'encre forte du plan, --couleur-contour-fort. 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. */ @@ -91,7 +99,10 @@ --couleur-chaise-pleine: #1f1f1f; --couleur-hachure: #1f1f1f; --couleur-marque: #767676; - --couleur-conflit: #b3261e; + --couleur-contour-fort: #2b2f36; + --couleur-conflit-appartenance: #8f1d14; + --couleur-conflit-voisins: #c0189f; + --couleur-conflit-table: #6a9400; --couleur-pastille-fond: #2b2f36; --couleur-pastille-texte: #ffffff; --couleur-poignee: #4a5059; @@ -133,7 +144,10 @@ --couleur-chaise-pleine: #e8e8e8; --couleur-hachure: #ededed; --couleur-marque: #7a7a7a; - --couleur-conflit: #ff8a80; + --couleur-contour-fort: #e6e8eb; + --couleur-conflit-appartenance: #fc9890; + --couleur-conflit-voisins: #c0689c; + --couleur-conflit-table: #549c00; --couleur-pastille-fond: #e6e8eb; --couleur-pastille-texte: #15181c; --couleur-poignee: #c5ccd6; diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 8a70e84..a2f0b9f 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -113,15 +113,38 @@ export const TABLE = new Map([ // Le plan est un seul arrêt de tabulation : sa description dit comment s'y // déplacer. L'infobulle d'une table en conflit nomme chaque règle violée, // puis ses groupes : les noms sont des données, chacun dans son nœud, - // reliés par « et » dans une paire et séparés d'un groupe à l'autre. + // reliés par « et » dans une paire et séparés d'un groupe à l'autre. Une + // ligne de séparer les appartenances nomme l'appartenance de ses paires, + // une donnée dans son nœud, entre separerAppartenances et finAppartenance. ['plan.vue.role', 'plan de salle interactif'], ['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`], - ['plan.infobulle.separerAppartenances', 'appartenances réunies : '], + ['plan.infobulle.separerAppartenances', 'Même appartenance ('], + ['plan.infobulle.finAppartenance', ') : '], ['plan.infobulle.nouveauxVoisins', 'voisins revus : '], ['plan.infobulle.nouvelleTable', 'table revue : '], ['plan.infobulle.et', ' et '], ['plan.infobulle.separateur', ', '], + // --- La légende des conflits (§ 7.5) -------------------------------------- + // Une entrée par règle active : son échantillon, son nom, puis la forme de + // sa trame, qui la distingue sans la couleur. L'exemple de la pastille est + // le chiffre que porte son échantillon. + ['plan.legende.ouvrir', 'Légende'], + ['plan.legende.titre', 'Légende des conflits'], + ['plan.legende.separerAppartenances', 'Séparer les appartenances : même appartenance à la table'], + ['plan.legende.nouveauxVoisins', 'Nouveaux voisins à chaque tour : voisins revus'], + ['plan.legende.nouvelleTable', 'Nouvelle table à chaque tour : table revue'], + ['plan.legende.forme.hachuresDescendantes', '(hachures descendantes)'], + ['plan.legende.forme.hachuresMontantes', '(hachures montantes)'], + ['plan.legende.forme.points', '(points)'], + ['plan.legende.aucune', 'Aucune règle de conflit n\u{2019}est active.'], + [ + 'plan.legende.superposition', + 'Une table qui viole plusieurs règles superpose leurs trames ; son contour épaissi la signale quelle que soit la règle.', + ], + ['plan.legende.pastille', 'Pastille : le nombre de conflits de la table à ce tour, toutes règles confondues.'], + ['plan.legende.pastilleExemple', '2'], + // --- Les panneaux latéraux (§ 2.1, étape 8) -------------------------------- ['plan.panneaux', 'Panneaux du plan'], ['plan.listesTravail.titre', 'Listes de travail'], diff --git a/src/interface/marques.js b/src/interface/marques.js index 87b03c3..44ba41d 100644 --- a/src/interface/marques.js +++ b/src/interface/marques.js @@ -23,6 +23,38 @@ function entree(nom, jeton, fonds, seuil) { const marque = (nom, jeton, fonds) => entree(nom, jeton, fonds, 3); const texte = (nom, jeton, fonds) => entree(nom, jeton, fonds, 4.5); +// Les sortes de conflit : règle, marque de la trame, jeton court, forme. +const SORTES_CONFLIT = [ + ['separerAppartenances', 'trameAppartenance', 'conflit-appartenance', 'hachuresDescendantes'], + ['nouveauxVoisins', 'trameVoisins', 'conflit-voisins', 'hachuresMontantes'], + ['nouvelleTable', 'trameTable', 'conflit-table', 'points'], +]; + +/** Les trois sortes de conflit qu'une table porte (§ 7.5, § 12.7), une par + * règle que le moteur localise (conflitsParTable), dans son ordre — l'ordre + * du dessin, de la légende et de l'infobulle : { regle, marque, jeton, + * forme } — la règle du moteur, la marque data-marque de sa trame, le jeton + * de sa teinte, réservée à cette trame, et la forme de son motif + * (Motifs.svelte), qui la distingue sans la couleur. Deux règles violées à + * une même table superposent leurs trames : les hachures descendantes et + * montantes se croisent en quadrillage, les points se posent par-dessus. */ +export const CONFLITS = Object.freeze( + SORTES_CONFLIT.map(([regle, trame, jeton, forme]) => Object.freeze({ regle, marque: trame, jeton: c(jeton), forme })), +); + +/** Les jetons des teintes de conflit, dans l'ordre de CONFLITS. */ +export const JETONS_CONFLIT = Object.freeze(CONFLITS.map(({ jeton }) => jeton)); + +/** Écarts perçus minimaux d'une teinte de conflit, en distance OKLab × 100 + * mesurée en couleurs d'origine et sous chaque dichromatie (ecartPercu, + * palette.js) : entreConflits, d'une teinte de conflit à une autre — la + * couleur redit la forme de la règle ; autresMarques, d'une teinte de + * conflit à chaque autre marque du plan. Le second seuil est plus bas : les + * gris du mobilier occupent la clarté où une teinte de conflit tient 3:1 + * sur le plateau sombre, et la forme distingue toujours la trame de ce + * qu'elle côtoie. */ +export const SEPARATIONS = Object.freeze({ entreConflits: 12, autresMarques: 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. */ @@ -34,10 +66,12 @@ export const MARQUES = Object.freeze([ 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']), + // Le conflit (§ 7.5) : contour épaissi sur le sol, commun aux trois + // règles et peint de l'encre forte du plan ; une trame par règle violée + // sur le plateau, chacune de sa teinte, ses traits détachés par un halo + // du fond du plateau ; pastille chiffrée près de la table. + marque('contourConflit', 'contour-fort', ['sol', 'table-fond']), + ...SORTES_CONFLIT.map(([, trame, jeton]) => marque(trame, jeton, ['table-fond'])), marque('pastille', 'pastille-fond', ['sol', 'table-fond']), marque('poignee', 'poignee', ['sol']), marque('gel', 'gel', ['sol', 'table-fond']), diff --git a/src/interface/marques.test.js b/src/interface/marques.test.js index 2d06db1..6fe3398 100644 --- a/src/interface/marques.test.js +++ b/src/interface/marques.test.js @@ -8,15 +8,15 @@ 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'; +import { CONFLITS, JETONS_CONFLIT, MARQUES, SEPARATIONS, TEXTES } from './marques.js'; +import { lireBlocs, lireJetons, verifierPalette, verifierSeparation } 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', + 'contourConflit', 'trameAppartenance', 'trameVoisins', 'trameTable', 'pastille', 'poignee', 'gel', 'miseEnEvidence', 'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus', 'contourTable', 'contourChaise', 'echelle', 'fantome', ]; @@ -39,7 +39,7 @@ const FONDS_REQUIS = { 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', () => { + test('les vingt-deux 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); @@ -91,6 +91,44 @@ describe('registre des marques', () => { }); }); +describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => { + test('une par règle localisable, dans l’ordre du moteur : sa règle, la marque de sa trame, le jeton de sa teinte, la forme de son motif', () => { + assert.deepEqual( + CONFLITS.map((c) => [c.regle, c.marque, c.jeton, c.forme]), + [ + ['separerAppartenances', 'trameAppartenance', '--couleur-conflit-appartenance', 'hachuresDescendantes'], + ['nouveauxVoisins', 'trameVoisins', '--couleur-conflit-voisins', 'hachuresMontantes'], + ['nouvelleTable', 'trameTable', '--couleur-conflit-table', 'points'], + ], + ); + assert.ok(CONFLITS.every((c) => Object.keys(c).sort().join() === 'forme,jeton,marque,regle')); + assert.ok(Object.isFrozen(CONFLITS) && CONFLITS.every((c) => Object.isFrozen(c))); + assert.deepEqual(JETONS_CONFLIT, CONFLITS.map(({ jeton }) => jeton)); + assert.ok(Object.isFrozen(JETONS_CONFLIT)); + // Les seuils d'écart perçu : entre deux teintes de conflit, et d'une + // teinte de conflit à une autre marque du plan. + assert.deepEqual(SEPARATIONS, { entreConflits: 12, autresMarques: 5 }); + assert.ok(Object.isFrozen(SEPARATIONS)); + }); + + test('chaque trame est au registre, peinte de son jeton sur le fond du plateau', () => { + for (const { marque, jeton } of CONFLITS) { + const entree = parNom(MARQUES, marque); + assert.notEqual(entree, undefined, marque); + assert.equal(entree.jeton, jeton, marque); + assert.deepEqual(entree.fonds, ['--couleur-table-fond'], marque); + } + }); + + test('teinte réservée au registre : une teinte de conflit ne peint que sa trame, aucune autre marque ni aucun texte', () => { + const peintes = [...MARQUES, ...TEXTES].filter((e) => JETONS_CONFLIT.includes(e.jeton) || e.fonds.some((f) => JETONS_CONFLIT.includes(f))); + assert.deepEqual(peintes.map((e) => e.nom), CONFLITS.map(({ marque }) => marque)); + // Le contour épaissi d'une table en conflit est commun aux trois règles : + // il ne porte aucune de leurs teintes. + assert.ok(!JETONS_CONFLIT.includes(parNom(MARQUES, 'contourConflit').jeton)); + }); +}); + 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']); @@ -129,4 +167,25 @@ describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => { 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), []); }); + + // Les autres marques du plan : celles qui se peignent sur le sol, le + // plateau ou une chaise, trames de conflit exceptées, chaque jeton une fois. + const FONDS_DU_PLAN = new Set(['--couleur-sol', '--couleur-table-fond', ...REMPLISSAGES_CHAISE]); + const autresMarquesDuPlan = () => [ + ...new Set(MARQUES.filter((m) => !JETONS_CONFLIT.includes(m.jeton) && m.fonds.some((f) => FONDS_DU_PLAN.has(f))).map((m) => m.jeton)), + ]; + + test(`les trois teintes de conflit se séparent deux à deux de ${SEPARATIONS.entreConflits} au moins (OKLab × 100), dans les deux thèmes, en couleurs d’origine et sous les trois simulations`, () => { + assert.equal(JETONS_CONFLIT.length, 3); + assert.deepEqual(verifierSeparation(JETONS, JETONS_CONFLIT, [], SEPARATIONS.entreConflits), []); + }); + + test(`chaque teinte de conflit se sépare de ${SEPARATIONS.autresMarques} au moins de chaque autre marque du plan, dans les deux thèmes, sous les trois simulations`, () => { + const autres = autresMarquesDuPlan(); + assert.ok(autres.length >= 10, `${autres.length} autres marques`); + for (const jeton of ['--couleur-gel', '--couleur-mise-en-evidence', '--couleur-selection', '--couleur-focus', '--couleur-fantome', '--couleur-pastille-fond']) { + assert.ok(autres.includes(jeton), jeton); + } + assert.deepEqual(verifierSeparation(JETONS, JETONS_CONFLIT, autres, SEPARATIONS.autresMarques), []); + }); }); diff --git a/src/interface/palette.js b/src/interface/palette.js index a82acaf..1c041da 100644 --- a/src/interface/palette.js +++ b/src/interface/palette.js @@ -3,9 +3,10 @@ // 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. +// mesure le contraste au sens des WCAG et l'écart perçu entre deux +// couleurs, et contrôle les teintes réservées aux conflits. 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. @@ -262,30 +263,92 @@ export function teinte(couleur) { 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; +/** § 12.7 : chaque teinte réservée — celles des conflits — est chromatique, + * et la teinte de chaque autre jeton chromatique se tient à ECART_TEINTE + * degrés au moins de chacune, dans chaque thème. Les teintes réservées ne + * se mesurent pas entre elles : verifierSeparation les distingue. Rend les + * fautes [{theme, reservee, jeton, ecart}], ecart en degrés sur le cercle, + * dans l'ordre thème, teinte réservée, jeton ; une teinte réservée + * achromatique est elle-même une faute, jeton égal à reservee et ecart + * null — aucune teinte n'est alors réservée. Un jeton réservé absent d'un + * thème lève. */ +export function verifierTeintesReservees(texteCss, reservees) { 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 }); + for (const reservee of reservees) { + if (!jetons.has(reservee)) throw new Error(`jeton absent du thème ${theme} : ${reservee}`); + const reference = teinte(jetons.get(reservee)); + if (reference === null) { + fautes.push({ theme, reservee, jeton: reservee, ecart: null }); + continue; + } + for (const [nom, couleur] of jetons) { + if (reservees.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, reservee, jeton: nom, ecart }); + } + } + } + return fautes; +} + +/** Les coordonnées OKLab [L, a, b] d'une couleur sRGB (Ottosson) : L vaut 0 + * pour le noir et 1 pour le blanc, a et b sont nuls pour un gris. Les + * composantes passent en RGB linéaire, puis dans l'espace LMS, dont la + * racine cubique se recompose en L, a, b. Alpha ignoré. */ +export function oklab(couleur) { + const [r, g, b] = couleur.slice(0, 3).map(versLineaire); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +/** L'écart perçu entre deux couleurs : la distance euclidienne de leurs + * coordonnées OKLab, multipliée par 100 — 100 du noir au blanc, 0 d'une + * couleur à elle-même. Symétrique. Sous une dichromatie, il se mesure sur + * les couleurs simulées (§ 12.7 : simuler d'abord, puis mesurer). */ +export function ecartPercu(a, b) { + const [la, aa, ba] = oklab(a); + const [lb, ab, bb] = oklab(b); + return 100 * Math.hypot(la - lb, aa - ab, ba - bb); +} + +/** Les jetons se distinguent, sur les couleurs d'origine puis sous chaque + * simulation, dans les deux thèmes : chaque paire de jetons, puis chaque + * jeton contre chacun des autres, tient un écart perçu (ecartPercu) d'au + * moins seuil ; les autres ne se mesurent pas entre eux. Rend les fautes + * [{theme, simulation, jeton, autre, ecart, seuil}], simulation valant + * 'origine' pour les couleurs d'origine, dans l'ordre thème, simulation, + * paires de jetons, puis jetons contre autres ; [] quand tout tient. Un + * jeton absent d'un thème lève. */ +export function verifierSeparation(texteCss, jetons, autres, seuil) { + const paires = []; + jetons.forEach((jeton, i) => { + for (const autre of jetons.slice(i + 1)) paires.push([jeton, autre]); + }); + for (const jeton of jetons) for (const autre of autres) paires.push([jeton, autre]); + const fautes = []; + for (const theme of THEMES) { + const lus = lireJetons(texteCss, theme); + const lire = (nom) => { + if (!lus.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`); + return lus.get(nom); + }; + for (const simulation of ['origine', ...ORDRE_SIMULATIONS]) { + const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation)); + for (const [jeton, autre] of paires) { + const ecart = ecartPercu(vue(lire(jeton)), vue(lire(autre))); + if (ecart < seuil) fautes.push({ theme, simulation, jeton, autre, ecart, seuil }); + } } } return fautes; diff --git a/src/interface/palette.test.js b/src/interface/palette.test.js index 5f18d76..2dbcfa3 100644 --- a/src/interface/palette.test.js +++ b/src/interface/palette.test.js @@ -5,21 +5,25 @@ // 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. +// lisent les composants, les teintes des trois sortes de 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 { CONFLITS, JETONS_CONFLIT } from './marques.js'; import { SIMULATIONS, contraste, + ecartPercu, lireBlocs, lireJetons, + oklab, simuler, teinte, verifierPalette, - verifierTeinteReservee, + verifierSeparation, + verifierTeintesReservees, } from './palette.js'; const DOSSIER = fileURLToPath(new URL('.', import.meta.url)); @@ -228,7 +232,7 @@ describe('verifierPalette', () => { }); }); -describe('teinte et verifierTeinteReservee (§ 12.7)', () => { +describe('teinte et verifierTeintesReservees (§ 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); @@ -239,28 +243,47 @@ describe('teinte et verifierTeinteReservee (§ 12.7)', () => { 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', () => { + test('un refus de la teinte d’un 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-conflit-a': conflit, + '--couleur-conflit-b': '#1f8a3a', '--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']); + const fautes = verifierTeintesReservees(css, ['--couleur-conflit-a', '--couleur-conflit-b']); + assert.deepEqual(fautes.map((f) => `${f.theme}:${f.reservee}:${f.jeton}`), [ + 'clair:--couleur-conflit-a:--couleur-refus', + 'sombre:--couleur-conflit-a:--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', () => { + test('chaque teinte réservée est une référence : un jeton proche de la seconde est refusé ; deux teintes réservées ne se mesurent pas entre elles', () => { + // Rouge à 0°, vert à 120° : le jeton à 135° n'est proche que du vert. + const css = feuille({ + '--couleur-conflit-a': '#ff0000', + '--couleur-conflit-b': '#00ff00', + '--couleur-conflit-c': '#ff1000', + '--couleur-sarcelle': '#00ff40', + '--couleur-bleu': '#0000ff', + }); + const fautes = verifierTeintesReservees(css, ['--couleur-conflit-a', '--couleur-conflit-b', '--couleur-conflit-c']); + assert.deepEqual(fautes.map((f) => `${f.theme}:${f.reservee}:${f.jeton}`), [ + 'clair:--couleur-conflit-b:--couleur-sarcelle', + 'sombre:--couleur-conflit-b:--couleur-sarcelle', + ]); + }); + + test('une teinte réservée 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 }, + assert.deepEqual(verifierTeintesReservees(gris, ['--couleur-conflit']), [ + { theme: 'clair', reservee: '--couleur-conflit', jeton: '--couleur-conflit', ecart: null }, + { theme: 'sombre', reservee: '--couleur-conflit', jeton: '--couleur-conflit', ecart: null }, ]); // Conflit à 0° ; 30° et 330° passent, 29° et 331° non. const css = feuille({ @@ -270,13 +293,65 @@ describe('teinte et verifierTeinteReservee (§ 12.7)', () => { '--couleur-c': '#ff0080', // 329,9° '--couleur-d': '#ff0079', // 331,5° }); - const fautes = verifierTeinteReservee(css, ['--couleur-conflit']); + const fautes = verifierTeintesReservees(css, ['--couleur-conflit']); assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [ 'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d', ]); }); }); +describe('ecartPercu et verifierSeparation (§ 12.7 : simuler d’abord, puis mesurer l’écart)', () => { + test('OKLab : le blanc vaut L = 1, le noir L = 0, un gris n’a ni a ni b ; l’écart du noir au blanc vaut 100, d’une couleur à elle-même 0, dans les deux sens', () => { + const blanc = oklab([255, 255, 255, 1]); + const noir = oklab([0, 0, 0, 1]); + assert.ok(Math.abs(blanc[0] - 1) < 1e-4 && Math.abs(noir[0]) < 1e-9); + const gris = oklab([118, 118, 118, 1]); + assert.ok(Math.abs(gris[1]) < 1e-4 && Math.abs(gris[2]) < 1e-4, `${gris}`); + assert.ok(Math.abs(ecartPercu([0, 0, 0, 1], [255, 255, 255, 1]) - 100) < 0.01); + assert.equal(ecartPercu([90, 120, 30, 1], [90, 120, 30, 1]), 0); + assert.equal(ecartPercu([200, 30, 30, 1], [30, 120, 200, 1]), ecartPercu([30, 120, 200, 1], [200, 30, 30, 1])); + }); + + test('un rouge et un vert de même clarté se séparent en couleurs d’origine et se confondent sous la deutéranopie : la faute n’est relevée que simulée', () => { + // Cherche un vert de la clarté d'un rouge donné : leurs écarts d'origine + // sont grands, et la deutéranopie les rapproche. + const rouge = [200, 40, 40, 1]; + let vert = null; + for (let v = 40; v <= 255 && vert === null; v += 1) { + const candidat = [40, v, 40, 1]; + if (Math.abs(oklab(candidat)[0] - oklab(rouge)[0]) < 0.01) vert = candidat; + } + assert.notEqual(vert, null); + assert.ok(ecartPercu(rouge, vert) > 20); + assert.ok(ecartPercu(simuler(rouge, 'deuteranopie'), simuler(vert, 'deuteranopie')) < 10); + const css = feuille({ '--couleur-rouge': enHex(rouge.slice(0, 3)), '--couleur-vert': enHex(vert.slice(0, 3)) }); + const fautes = verifierSeparation(css, ['--couleur-rouge', '--couleur-vert'], [], 10); + assert.ok(fautes.length > 0); + assert.ok(fautes.every((f) => f.simulation !== 'origine' && f.ecart < 10 && f.seuil === 10), JSON.stringify(fautes)); + assert.ok(fautes.some((f) => f.simulation === 'deuteranopie')); + assert.ok(fautes.every((f) => f.jeton === '--couleur-rouge' && f.autre === '--couleur-vert')); + assert.deepEqual([...new Set(fautes.map((f) => f.theme))], ['clair', 'sombre']); + }); + + test('chaque paire des jetons, puis chaque jeton contre chacun des autres ; les autres ne se mesurent pas entre eux ; un jeton absent lève', () => { + const css = feuille({ + '--couleur-a': '#c01010', + '--couleur-b': '#c11111', + '--couleur-x': '#1010c0', + '--couleur-y': '#1111c1', + }); + const fautes = verifierSeparation(css, ['--couleur-a', '--couleur-x'], ['--couleur-b', '--couleur-y'], 5); + // Origine et trois simulations, deux thèmes : a contre b et x contre y + // se confondent partout ; a contre x, a contre y, x contre b se séparent. + assert.deepEqual( + [...new Set(fautes.map((f) => `${f.jeton}/${f.autre}`))], + ['--couleur-a/--couleur-b', '--couleur-x/--couleur-y'], + ); + assert.equal(fautes.length, 2 * 2 * 4); + assert.throws(() => verifierSeparation(css, ['--couleur-a'], ['--couleur-absente'], 5), /--couleur-absente/); + }); +}); + // Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés. function sourcesDeStyle() { return readdirSync(DOSSIER, { recursive: true, withFileTypes: true }) @@ -342,15 +417,21 @@ describe('jetons.css', () => { } }); - test('la teinte du conflit est réservée (§ 12.7) : chromatique, sa valeur à lui seul, à 30° de tout autre jeton chromatique', () => { + test('les trois teintes de conflit sont réservées (§ 12.7) : chromatiques, chacune sa valeur, à 30° de tout autre jeton chromatique', () => { + assert.deepEqual(JETONS_CONFLIT, ['--couleur-conflit-appartenance', '--couleur-conflit-voisins', '--couleur-conflit-table']); 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); + for (const nom of JETONS_CONFLIT) { + const couleur = jetons.get(nom); + assert.notEqual(couleur, undefined, `${theme} : ${nom}`); + assert.notEqual(teinte(couleur), null, `${theme} : ${nom} achromatique`); + const memeValeur = [...jetons].filter(([n, c]) => n !== nom && String(c) === String(couleur)); + assert.deepEqual(memeValeur.map(([n]) => n), [], `${theme} : ${nom}`); + } + // Aucun autre jeton ne porte le préfixe des teintes de conflit. + assert.deepEqual([...jetons.keys()].filter((n) => n.startsWith('--couleur-conflit')), JETONS_CONFLIT, theme); } - assert.deepEqual(verifierTeinteReservee(JETONS, ['--couleur-conflit']), []); + assert.deepEqual(verifierTeintesReservees(JETONS, JETONS_CONFLIT), []); + assert.deepEqual(CONFLITS.map(({ jeton }) => jeton), JETONS_CONFLIT); }); }); diff --git a/src/interface/plan/BarreVue.svelte b/src/interface/plan/BarreVue.svelte index 8790cb3..07b81ad 100644 --- a/src/interface/plan/BarreVue.svelte +++ b/src/interface/plan/BarreVue.svelte @@ -2,9 +2,9 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> + +
+ +

{t('plan.legende.titre')}

+ {#if entrees.length === 0} +

{t('plan.legende.aucune')}

+ {/if} + + {#if entrees.length > 1} +

{t('plan.legende.superposition')}

+ {/if} +
+ + diff --git a/src/interface/plan/Motifs.svelte b/src/interface/plan/Motifs.svelte index c6178ae..662bc4a 100644 --- a/src/interface/plan/Motifs.svelte +++ b/src/interface/plan/Motifs.svelte @@ -5,11 +5,24 @@
- + (legendeOuverte = !legendeOuverte)} + {idLegende} + /> + {#if legendeOuverte} + + {/if}