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.aucune')} {t('plan.legende.superposition')}{t('plan.legende.titre')}
+ {#if entrees.length === 0}
+
+ {#each entrees as entree (entree.regle)}
+
+ {#if entrees.length > 1}
+