[ADD] interface: visual foundations, pills, cards, icons, navy header

Screens need colour that separates information without ever carrying it
alone. Tokens gain state, membership and status tints whose marks hold
3:1 and texts 4.5:1 in both themes and under three dichromacies, and
every hue stays 30 degrees from the reserved conflict hues. Commands get
three levels; Pastille, Carte and an icon module join the primitives;
memberships map to six stable tints over sorted names. A pill label
wraps and never truncates, and keeps one line beside a nowrap neighbour.
Checked: node 3949; browser 767, only the walkthrough captures differ.

--- FR ---

[ADD] interface : fondations, pastilles, cartes, icônes, bandeau marine

Les écrans ont besoin d'une couleur qui distingue l'information sans
jamais la porter seule. Les jetons gagnent des teintes d'état,
d'appartenance et de statut dont les marques tiennent 3:1 et les textes
4,5:1 dans les deux thèmes et sous trois dichromaties, chaque teinte à
30 degrés des teintes de conflit réservées. Les commandes ont trois
niveaux ; Pastille, Carte et un module d'icônes rejoignent les
primitives ; les appartenances reçoivent six teintes stables sur les
noms triés. Un libellé de pastille se replie, jamais tronqué.
Vérifié : node 3949 ; navigateur 767, seules les captures du parcours.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 22:59:51 -04:00
parent 73d08294f5
commit 4d34039f47
25 changed files with 2031 additions and 73 deletions

View file

@ -40,6 +40,7 @@ import { FORMAT, analyser, configurationDepuisCharge } from '../stockage/documen
import { ErreurStockage } from '../stockage/erreurs.js';
import { VERSION } from '../version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { fixerCadre } from '../../test/navigateur/entrees.js';
import { monterApp } from '../../test/navigateur/montage.js';
import {
attenuations,
@ -924,3 +925,141 @@ describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
}
});
});
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
// L'élément du bandeau dont le texte propre, blancs de bord ôtés, vaut texte.
const porteurDe = (racine, texte) =>
[...racine.querySelectorAll('*')].find((e) => [...e.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() === texte));
describe('le bandeau de la maquette (§ 8.4, § 18.6)', () => {
test('la marque : un dessin caché aux technologies d’assistance, de la teinte de la marque, devant le nom ; la version et la provenance en pilules, de l’encre secondaire du bandeau', async () => {
const m = await avecEvenementOuvert();
for (const theme of THEMES) {
await sousTheme(theme, () => {
const dessin = bandeau(m).querySelector('svg');
expect(dessin, theme).not.toBeNull();
expect(dessin.getAttribute('aria-hidden'), theme).toBe('true');
expect(visible(dessin), theme).toBe(true);
expect(composantes(getComputedStyle(dessin).color), theme).toEqual(jeton('--couleur-bandeau-marque'));
const nom = porteurDe(bandeau(m), t('coquille.nom'));
expect(nom, theme).toBeDefined();
expect(dessin.compareDocumentPosition(nom) & Node.DOCUMENT_POSITION_FOLLOWING, theme).toBeTruthy();
expect(dessin.getBoundingClientRect().right, theme).toBeLessThanOrEqual(nom.getBoundingClientRect().left);
for (const texte of [VERSION.affichee, libelleProvenance('test')]) {
const pilule = porteurDe(bandeau(m), texte);
const style = getComputedStyle(pilule);
expect(composantes(style.color), `${theme} ${texte}`).toEqual(jeton('--couleur-bandeau-texte-secondaire'));
expect(composantes(style.borderTopColor), `${theme} ${texte}`).toEqual(jeton('--couleur-bandeau-filet'));
expect(Number.parseFloat(style.borderTopLeftRadius), `${theme} ${texte}`).toBeGreaterThanOrEqual(pilule.getBoundingClientRect().height / 2);
}
});
}
});
test('le thème en commutateur segmenté : un groupe nommé et bordé de deux boutons côte à côte ; le retenu plein de l’encre du bandeau et gras, l’autre sans fond', async () => {
const m = await monter();
const groupe = dans(m, `[role="group"][aria-label="${t('coquille.theme.titre')}"]`);
expect(groupe).not.toBeNull();
const [clair, sombre] = ['clair', 'sombre'].map((choix) => groupe.querySelector(`[data-commande="theme"][data-theme-choix="${choix}"]`));
expect(clair.getBoundingClientRect().top).toBe(sombre.getBoundingClientRect().top);
expect(clair.getBoundingClientRect().right).toBeLessThanOrEqual(sombre.getBoundingClientRect().left);
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(composantes(getComputedStyle(groupe).borderTopColor), theme).toEqual(jeton('--couleur-bandeau-filet'));
const [retenu, autre] = clair.getAttribute('aria-pressed') === 'true' ? [clair, sombre] : [sombre, clair];
expect(composantes(getComputedStyle(retenu).backgroundColor), theme).toEqual(jeton('--couleur-bandeau-texte'));
expect(composantes(getComputedStyle(retenu).color), theme).toEqual(jeton('--couleur-bandeau-fond'));
expect(getComputedStyle(retenu).fontWeight, theme).toBe('700');
expect(composantes(getComputedStyle(autre).backgroundColor), theme).toEqual([0, 0, 0, 0]);
expect(getComputedStyle(autre).fontWeight, theme).toBe('400');
});
}
});
test('« Liste des événements » et « À propos » : une icône cachée devant le texte de « Liste des événements », le texte inchangé ; le libellé de mode en pilule, en gras', async () => {
const m = await avecEvenementOuvert();
const liste = dans(m, '[data-bandeau] [data-commande="liste"]');
expect(liste.textContent.trim()).toBe(t('coquille.liste'));
expect(liste.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
expect(dans(m, '[data-bandeau] [data-commande="apropos"]').textContent.trim()).toBe(t('coquille.apropos.titre'));
const mode = libelleDeMode(m);
expect(getComputedStyle(mode).fontWeight).toBe('700');
expect(Number.parseFloat(getComputedStyle(mode).borderTopLeftRadius)).toBeGreaterThanOrEqual(mode.getBoundingClientRect().height / 2);
});
test('le bandeau tient sur une ligne de 52 px au plus à 1 366 px de large, événement ouvert, et ne déborde pas à 1 024 px', async () => {
const avant = { largeur: innerWidth, hauteur: innerHeight };
try {
await fixerCadre(1366, 700);
const m = await avecEvenementOuvert();
expect(bandeau(m).getBoundingClientRect().height).toBeLessThanOrEqual(52);
await fixerCadre(1024, 700);
flushSync();
expect(bandeau(m).scrollWidth).toBeLessThanOrEqual(bandeau(m).clientWidth);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(1024);
} finally {
await fixerCadre(avant.largeur, avant.hauteur);
}
});
});
describe('la page de la maquette : fond, largeur bornée, styles de base', () => {
test('au-delà de la largeur de page, l’écran se centre dans --largeur-page ; en deçà, il prend la fenêtre', async () => {
const avant = { largeur: innerWidth, hauteur: innerHeight };
try {
await fixerCadre(1800, 900);
const m = await monter();
const main = ecran(m).parentElement;
expect(main.tagName.toLowerCase()).toBe('main');
const boite = main.getBoundingClientRect();
const borne = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * 87;
expect(boite.width).toBeCloseTo(borne, 0);
expect(boite.left).toBeCloseTo((document.documentElement.clientWidth - boite.width) / 2, 0);
await fixerCadre(1366, 700);
flushSync();
expect(main.getBoundingClientRect().width).toBeCloseTo(document.documentElement.clientWidth, 0);
} finally {
await fixerCadre(avant.largeur, avant.hauteur);
}
});
test('un titre, un tableau et un champ sans style propre prennent les styles de base de l’écran ; le document imprimé les ignore', async () => {
const m = await monter();
const zone = document.createElement('div');
zone.innerHTML = [
'<h1>Titre témoin</h1>',
'<table><thead><tr><th>En-tête</th></tr></thead><tbody><tr><td>12</td></tr></tbody></table>',
'<input type="text" value="saisie">',
'<div data-impression><table><thead><tr><th>Papier</th></tr></thead></table><input type="text" value="papier"></div>',
].join('');
ecran(m).append(zone);
try {
for (const theme of THEMES) {
await sousTheme(theme, () => {
const [titre, th, td, champ] = ['h1', 'th', 'td', 'input'].map((s) => zone.querySelector(s));
const racine = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
expect(Number.parseFloat(getComputedStyle(titre).fontSize), theme).toBeCloseTo(racine * 1.875, 1);
expect(getComputedStyle(th).textAlign, theme).toBe('start');
expect(getComputedStyle(td).fontVariantNumeric, theme).toBe('tabular-nums');
expect(composantes(getComputedStyle(champ).backgroundColor), theme).toEqual(jeton('--couleur-carte-fond'));
expect(composantes(getComputedStyle(champ).borderTopColor), theme).toEqual(jeton('--couleur-page-filet'));
expect(getComputedStyle(champ).borderTopLeftRadius, theme).toBe('8px');
const papier = zone.querySelector('[data-impression]');
expect(getComputedStyle(papier.querySelector('th')).textAlign, theme).not.toBe('start');
expect(getComputedStyle(papier.querySelector('input')).borderTopLeftRadius, theme).not.toBe('8px');
});
}
} finally {
zone.remove();
}
});
});

View file

@ -36,6 +36,14 @@
// présent sur tout écran, ne défile pas : un écran plus haut que la
// fenêtre défile sous lui.
//
// La page porte le fond de la page ; l'écran — l'élément <main>, parent
// direct de chaque [data-ecran] — se centre dans --largeur-page au-delà
// de cette largeur et prend la fenêtre en deçà. App pose les styles de
// base d'un titre de premier niveau, des cellules d'un tableau et des
// champs de saisie de l'écran, de spécificité nulle : le style propre d'un
// composant l'emporte toujours, et le document imprimé (§ 11.7), sous
// [data-impression], ne les reçoit pas.
//
// Le thème part de la propriété themeInitial, ou de la préférence du
// système lue au montage ; le réglage de l'opérateur le change pour la
// séance, en mémoire, posé en data-theme sur <html> (§ 19.2). À la sortie
@ -362,6 +370,41 @@
.ecran {
flex: 1;
box-sizing: border-box;
width: 100%;
max-width: var(--largeur-page);
margin-inline: auto;
padding: var(--espace-4);
}
/* Les styles de base de l'écran, sous :where : spécificité nulle. Le
titre de page ; les en-têtes alignés au début de la ligne, les nombres
en chiffres tabulaires ; les champs de saisie sur le fond de la carte,
au filet de la page — cases à cocher, boutons radio et sélecteurs de
fichier exceptés. */
:global(:where(.ecran h1:not([data-impression] *))) {
font-size: var(--taille-titre-page);
line-height: 1.2;
letter-spacing: -0.01em;
}
:global(:where(.ecran th:not([data-impression] *))) {
text-align: start;
}
:global(:where(.ecran :is(td, th):not([data-impression] *))) {
font-variant-numeric: tabular-nums;
}
:global(
:where(
.ecran
:is(input:not([type='checkbox'], [type='radio'], [type='file']), select, textarea):not([data-impression] *)
)
) {
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
}
</style>

View file

@ -10,6 +10,15 @@
// aria-pressed, et l'ouverture du panneau « À propos ». Collé en haut de la
// fenêtre, il ne défile pas.
//
// Son habillage : la marque — le dessin d'une table ronde et de ses six
// chaises, caché aux technologies d'assistance —, le nom, la version et la
// provenance en pilules, le libellé de mode en pilule grasse, puis, à
// droite, « Liste des événements » précédée de son icône, le thème en
// commutateur segmenté — le retenu plein de l'encre du bandeau, en gras —
// et « À propos ». Chaque élément garde la hauteur d'une ligne de commande
// du bandeau : le bandeau tient sur une ligne à 1 366 px, l'événement
// ouvert, et la surface de travail garde sa hauteur.
//
// Le bandeau ne décide de rien : App lui donne l'état de la séance, la
// version et la provenance telles qu'elles s'affichent, le thème et les
// gestes à appeler ; mode.js choisit les clés du libellé de mode et de
@ -21,6 +30,7 @@
// commandes qui le portent sont un <div>, non un <span>.
import { getContext } from 'svelte';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
import Icone from './primitives/Icone.svelte';
import Refus from './primitives/Refus.svelte';
import { THEMES, cleDeTheme } from './theme.js';
@ -33,18 +43,29 @@
</script>
<header class="bandeau" data-bandeau>
<svg class="marque" width="24" height="24" viewBox="0 0 30 30" aria-hidden="true" focusable="false">
<circle cx="15" cy="15" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="15" cy="3.5" r="2.5" fill="currentColor" />
<circle cx="25" cy="9.5" r="2.5" fill="currentColor" />
<circle cx="25" cy="20.5" r="2.5" fill="currentColor" />
<circle cx="15" cy="26.5" r="2.5" fill="currentColor" />
<circle cx="5" cy="20.5" r="2.5" fill="currentColor" />
<circle cx="5" cy="9.5" r="2.5" fill="currentColor" />
</svg>
<span class="nom">{t('coquille.nom')}</span>
<span class="version">{version}</span>
<span class="provenance">{provenance}</span>
<span class="pilule version">{version}</span>
<span class="pilule provenance">{provenance}</span>
{#if cleMode !== null}
<span class="mode" data-mode-libelle="bandeau">{t(cleMode)}</span>
<span class="pilule mode" data-mode-libelle="bandeau">{t(cleMode)}</span>
{/if}
{#if cleAnnonce !== null}
<span class="annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
<span class="pilule annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
{/if}
<div class="commandes">
{#if etat.evenement !== null}
<button type="button" data-commande="liste" onclick={revenirALaListe}>{t('coquille.liste')}</button>
<button type="button" data-commande="liste" onclick={revenirALaListe}
><Icone nom="liste" />{t('coquille.liste')}</button
>
{#if refusListe !== null}
<div class="refus-liste" data-refus="liste">
<Refus refus={refusListe} />
@ -73,7 +94,7 @@
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: var(--espace-2) var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
@ -82,12 +103,27 @@
font-size: var(--taille-texte-bandeau);
}
.provenance,
.mode,
.annonce {
padding: 0 var(--espace-2);
.marque {
flex: none;
color: var(--couleur-bandeau-marque);
}
.nom {
font-weight: bold;
}
/* La version, la provenance, le libellé de mode et l'annonce : des
pilules à filet. */
.pilule {
padding: 0.125rem var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
border-radius: var(--rayon-pilule);
font-size: var(--taille-texte-petit);
}
.version,
.provenance {
color: var(--couleur-bandeau-texte-secondaire);
}
.mode {
@ -107,42 +143,55 @@
width: max-content;
max-width: min(32rem, calc(100vw - 2 * var(--espace-4)));
margin-top: var(--espace-1);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
border-radius: var(--rayon-2);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
}
.commandes,
.themes {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2);
box-shadow: var(--ombre-2);
}
.commandes {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
margin-left: auto;
}
button {
display: inline-flex;
align-items: center;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
border-radius: var(--rayon-2);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font: inherit;
cursor: pointer;
}
/* Le thème retenu se distingue par sa forme autant que par ses couleurs :
texte souligné et gras, couleurs du bandeau inversées. */
button[aria-pressed='true'] {
/* Le commutateur de thème : un groupe bordé, sans fond, dont les deux
choix se touchent. Le retenu se distingue par sa forme autant que par
ses couleurs : plein, aux couleurs du bandeau inversées, et en gras. */
.themes {
display: inline-flex;
padding: 0.0625rem;
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-2);
}
.themes button {
padding: 0.1875rem var(--espace-2);
border: none;
border-radius: var(--rayon-1);
background-color: transparent;
}
.themes button[aria-pressed='true'] {
background-color: var(--couleur-bandeau-texte);
color: var(--couleur-bandeau-fond);
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {

View file

@ -0,0 +1,48 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'attribution des teintes d'appartenance des listes, pure. Les
// appartenances distinctes d'un événement — deux chaînes différentes sont
// deux appartenances, comme pour le moteur — se rangent par le comparateur
// de noms du projet : leur clé normalisée (cleNormalisee, § 10.1), comparée
// point de code par point de code, puis, pour deux orthographes de même clé,
// leurs chaînes elles-mêmes. Les RANGS_APPARTENANCE premières reçoivent les
// rangs 1 à RANGS_APPARTENANCE, chacun une teinte de TEINTES_APPARTENANCE
// (marques.js) ; les suivantes, null : leur pastille est neutre.
//
// L'attribution lit toute la liste, exclues comprises : un filtre, un tri,
// une exclusion ne changent aucun rang, et une même appartenance garde sa
// teinte d'une vue à l'autre. Elle ne dépend que des noms portés.
import { cleNormalisee } from '../application/csv.js';
import { comparerPointsDeCode } from '../application/planche.js';
import { TEINTES_APPARTENANCE } from './marques.js';
/** Le nombre de teintes d'appartenance, celui de la palette. */
export const RANGS_APPARTENANCE = TEINTES_APPARTENANCE.length;
/**
* Les teintes des appartenances d'un événement.
*
* @param {Array<{id?: number, appartenance: string|null}>} participants
* tous les participants de l'événement, exclus compris
* @returns {Map<string, number|null>} chaque appartenance portée, dans
* l'ordre du comparateur de noms, vers son rang de 1 à RANGS_APPARTENANCE,
* ou null au-delà ; une personne sans appartenance n'y ajoute rien
* @throws {TypeError} une appartenance qui n'est ni une chaîne ni null, en
* nommant l'identifiant de la personne
*/
export function teintesAppartenances(participants) {
const noms = new Set();
for (const { id, appartenance } of participants) {
if (appartenance === null) continue;
if (typeof appartenance !== 'string') {
throw new TypeError(`participant ${id} : appartenance chaîne ou null attendue, reçu ${typeof appartenance}`);
}
noms.add(appartenance);
}
const ranges = [...noms]
.map((nom) => ({ nom, cle: cleNormalisee(nom) }))
.sort((a, b) => comparerPointsDeCode(a.cle, b.cle) || comparerPointsDeCode(a.nom, b.nom));
return new Map(ranges.map(({ nom }, i) => [nom, i < RANGS_APPARTENANCE ? i + 1 : null]));
}

View file

@ -0,0 +1,73 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'attribution des teintes d'appartenance des listes : les appartenances
// distinctes de l'événement, rangées par le comparateur de noms du projet,
// reçoivent les rangs 1 à RANGS_APPARTENANCE dans l'ordre ; au-delà, null,
// la pastille neutre. L'attribution se calcule sur toute la liste, exclues
// comprises : un filtre, une exclusion ou l'ordre des lignes ne repeint
// rien. Les noms sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { RANGS_APPARTENANCE, teintesAppartenances } from './appartenances.js';
// Des participants d'épreuve, un par appartenance donnée, dans l'ordre reçu.
const personnes = (...appartenances) =>
appartenances.map((appartenance, rang) => ({ id: rang + 1, nom: `Personne ${rang + 1}`, appartenance, exclu: false }));
describe('teintesAppartenances', () => {
test('six rangs au plus', () => {
assert.equal(RANGS_APPARTENANCE, 6);
});
test('les appartenances distinctes, rangées par leur clé normalisée, reçoivent 1, 2, 3 ; une Map dans cet ordre', () => {
const teintes = teintesAppartenances(personnes('Ordre des Pluviers', 'Cercle des Aulnes', 'ordre des pluviers', 'Cercle des Aulnes', 'Bastion des Ifs'));
assert.ok(teintes instanceof Map);
assert.deepEqual([...teintes], [
['Bastion des Ifs', 1],
['Cercle des Aulnes', 2],
['Ordre des Pluviers', 3],
['ordre des pluviers', 4],
]);
});
test('casse et accents mis à part, l’ordre est celui de la clé ; deux orthographes de même clé se départagent par leurs points de code', () => {
const teintes = teintesAppartenances(personnes('Écluse des Saules', 'echo des Noues', 'Ecluse des Saules', 'Dune'));
assert.deepEqual([...teintes.keys()], ['Dune', 'echo des Noues', 'Ecluse des Saules', 'Écluse des Saules']);
});
test('au-delà de six appartenances, null ; une personne sans appartenance ne compte pas', () => {
const noms = ['Hêtraie', 'Gué', 'Futaie', 'Estran', 'Dolmen', 'Combe', 'Bocage', 'Aber'];
const teintes = teintesAppartenances(personnes(...noms, null, null));
assert.deepEqual([...teintes], [
['Aber', 1],
['Bocage', 2],
['Combe', 3],
['Dolmen', 4],
['Estran', 5],
['Futaie', 6],
['Gué', null],
['Hêtraie', null],
]);
assert.equal(teintes.has(null), false);
});
test('l’ordre des lignes, un filtre ou une exclusion ne changent aucun rang : l’attribution lit toute la liste', () => {
const liste = personnes('Combe', 'Aber', 'Bocage', 'Aber');
const reference = teintesAppartenances(liste);
assert.deepEqual([...teintesAppartenances([...liste].reverse())], [...reference]);
const exclue = liste.map((p) => (p.appartenance === 'Aber' ? { ...p, exclu: true } : p));
assert.deepEqual([...teintesAppartenances(exclue)], [...reference]);
});
test('une liste vide rend une Map vide ; la liste reçue n’est pas modifiée', () => {
assert.equal(teintesAppartenances([]).size, 0);
const liste = Object.freeze(personnes('Combe', 'Aber').map((p) => Object.freeze(p)));
assert.deepEqual([...teintesAppartenances(liste)], [['Aber', 1], ['Combe', 2]]);
});
test('une appartenance ni chaîne ni null lève TypeError en nommant la personne', () => {
assert.throws(() => teintesAppartenances([{ id: 7, appartenance: 12 }]), (erreur) => erreur instanceof TypeError && /7/.test(erreur.message));
assert.throws(() => teintesAppartenances([{ id: 8 }]), TypeError);
});
});

View file

@ -62,7 +62,27 @@
au-dessus de sa ligne de base à 0,262 au-dessous : les capitales
accentuées (0,944) et les jambages (0,244) du latin y tiennent ; seules
les capitales à deux diacritiques superposés (jusqu'à 1,059) en
débordent. */
débordent.
Les familles de l'interface, en plus de la page, du bandeau, du plan et
du papier : la carte — son fond, son fond sourd, qui porte une bande ou
un en-tête de tableau, son filet, son ombre, le filet léger entre deux
lignes d'un tableau — ; l'action principale — son fond, son texte, sa
teinte claire et son encre, celle d'un lien — ; les quatre états du plan,
le neutre et les appartenances, chacun un fond teinté et une marque, que
lisent les pastilles ; la bande de mode, un fond et un filet par mode, sa
marque étant la teinte du cadre de mode ; les messages — avis, refus,
information —, chacun son encre, un fond, un filet et une marque.
Le texte d'une pastille ou de la bande de mode garde l'encre de la page
(§ 12.7) : la teinte se porte par le fond et la marque. Les marques des
pastilles, des messages et de la bande se valident par calcul (SIGNES,
marques.js).
Les teintes d'appartenance ne vont que sur les listes, jamais sur le
plan : six teintes au plus, catégorielles, chacune à 30° au moins des
teintes de conflit, distinctes deux à deux sous les trois dichromaties et,
deux teintes voisines, en clarté (TEINTES_APPARTENANCE, marques.js). */
@font-face {
font-family: 'Noto Sans';
@ -89,12 +109,17 @@ textarea {
--police-interface: 'Noto Sans', system-ui, sans-serif;
--police-interligne: 1.3;
/* Échelle typographique, du plus petit au plus grand. */
/* Échelle typographique, du plus petit au plus grand ; l'intertitre en
petites capitales prend --interlettre-capitales. */
--taille-intertitre: 0.75rem;
--taille-texte-petit: 0.8125rem;
--taille-texte-bandeau: 0.9375rem;
--taille-texte: 1rem;
--taille-titre-carte: 1.125rem;
--taille-titre: 1.25rem;
--taille-titre-grand: 1.5rem;
--taille-titre-page: 1.875rem;
--interlettre-capitales: 0.05em;
--espace-1: 0.25rem;
--espace-2: 0.5rem;
@ -103,12 +128,22 @@ textarea {
--espace-5: 1.5rem;
--espace-6: 2rem;
/* Rayons : celui d'un contrôle, d'un bouton, d'une carte, d'une pilule. */
--rayon-1: 0.25rem;
--rayon-2: 0.5rem;
--rayon-3: 0.75rem;
--rayon-pilule: 62.5rem;
/* Ombres : une couleur opaque par thème, --couleur-ombre. */
/* La hauteur d'une cible principale (44 px), et la largeur au-delà de
laquelle le contenu d'un écran se centre. */
--hauteur-cible: 2.75rem;
--largeur-page: 87rem;
/* Ombres : une couleur opaque par thème, --couleur-ombre ; celle d'une
carte, plus légère, --couleur-carte-ombre. */
--ombre-1: 0 1px 2px var(--couleur-ombre);
--ombre-2: 0 2px 6px var(--couleur-ombre);
--ombre-carte: 0 1px 2px var(--couleur-carte-ombre);
}
:root,
@ -117,25 +152,82 @@ textarea {
color-scheme: light;
/* Page. */
--couleur-page-fond: #f6f7f9;
--couleur-page-texte: #1c2128;
--couleur-page-texte-secondaire: #4b5563;
--couleur-page-filet: #c3c9d1;
--couleur-page-fond: #f3f5f8;
--couleur-page-texte: #18212f;
--couleur-page-texte-secondaire: #586374;
--couleur-page-filet: #c3cbd6;
--couleur-ombre: #b4bcc6;
/* Bandeau d'application. */
--couleur-bandeau-fond: #172b47;
--couleur-bandeau-texte: #ffffff;
--couleur-bandeau-filet: #8fb3d9;
/* Carte. */
--couleur-carte-fond: #ffffff;
--couleur-carte-fond-sourd: #f8fafc;
--couleur-carte-filet: #dce1e8;
--couleur-carte-ombre: #e1e5eb;
--couleur-ligne-filet: #edf0f4;
/* Cadre de mode (§ 8.4). */
/* Bandeau d'application. */
--couleur-bandeau-fond: #13223a;
--couleur-bandeau-texte: #ffffff;
--couleur-bandeau-texte-secondaire: #b9c4d6;
--couleur-bandeau-filet: #4a6185;
--couleur-bandeau-marque: #7fb0f0;
/* Action principale. */
--couleur-action-fond: #1f5fbf;
--couleur-action-texte: #ffffff;
--couleur-action-teinte: #e8f0fb;
--couleur-action-encre: #184c99;
/* États du plan (§ 9), le neutre et les appartenances : fond teinté et
marque. */
--couleur-neutre-fond: #eef1f5;
--couleur-neutre-marque: #5e6b7a;
--couleur-etat-brouillon-fond: var(--couleur-neutre-fond);
--couleur-etat-brouillon-marque: var(--couleur-neutre-marque);
--couleur-etat-propose-fond: #fbf0de;
--couleur-etat-propose-marque: #9a6500;
--couleur-etat-retenu-fond: #e1f3ec;
--couleur-etat-retenu-marque: #1a8566;
--couleur-etat-bloque-fond: #ece9fa;
--couleur-etat-bloque-marque: #6a55c8;
--couleur-appartenance-1-fond: #e4eff4;
--couleur-appartenance-1-marque: #185a72;
--couleur-appartenance-2-fond: #f7f1e3;
--couleur-appartenance-2-marque: #a17317;
--couleur-appartenance-3-fond: #f1eafc;
--couleur-appartenance-3-marque: #7c24e0;
--couleur-appartenance-4-fond: #e4f2ef;
--couleur-appartenance-4-marque: #32857a;
--couleur-appartenance-5-fond: #f2ede3;
--couleur-appartenance-5-marque: #6b4d0f;
--couleur-appartenance-6-fond: #f3f0fc;
--couleur-appartenance-6-marque: #8f6dd5;
/* Cadre de mode (§ 8.4), et bande de mode : fond et filet ; sa marque est
la teinte du cadre. */
--couleur-cadre-lecture: #5c6878;
--couleur-cadre-ecriture: #2e7d32;
--couleur-cadre-bloque: #7b3fa0;
--couleur-mode-lecture-fond: #e9edf3;
--couleur-mode-lecture-filet: #c3cbd6;
--couleur-mode-ecriture-fond: #e3f4e8;
--couleur-mode-ecriture-filet: #a9d8b8;
--couleur-mode-bloque-fond: #f1eaf7;
--couleur-mode-bloque-filet: #cfb5e3;
/* Messages. */
/* Messages : encre, fond, filet, marque. */
--couleur-refus: #6a1b9a;
--couleur-refus-fond: #f6effb;
--couleur-refus-filet: #cfb3e6;
--couleur-refus-marque: var(--couleur-refus);
--couleur-avis: #7a5000;
--couleur-avis-fond: #fff6e5;
--couleur-avis-filet: #f0c77a;
--couleur-avis-marque: var(--couleur-avis);
--couleur-information: #1d5bb8;
--couleur-information-fond: #e8f0fb;
--couleur-information-filet: #b7cef0;
--couleur-information-marque: var(--couleur-information);
/* 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
@ -179,16 +271,68 @@ textarea {
--couleur-page-filet: #3a414b;
--couleur-ombre: #000000;
--couleur-carte-fond: #1b1f25;
--couleur-carte-fond-sourd: #20252c;
--couleur-carte-filet: #2e353f;
--couleur-carte-ombre: #0b0d10;
--couleur-ligne-filet: #262c34;
--couleur-bandeau-fond: #1d2c3f;
--couleur-bandeau-texte: #f2f4f7;
--couleur-bandeau-texte-secondaire: #b3bfd0;
--couleur-bandeau-filet: #5b7fa6;
--couleur-bandeau-marque: #8fbaf2;
--couleur-action-fond: #8ab4f8;
--couleur-action-texte: #0b1730;
--couleur-action-teinte: #1a2b45;
--couleur-action-encre: #a8c7fa;
--couleur-neutre-fond: #272d36;
--couleur-neutre-marque: #9aa5b4;
--couleur-etat-brouillon-fond: var(--couleur-neutre-fond);
--couleur-etat-brouillon-marque: var(--couleur-neutre-marque);
--couleur-etat-propose-fond: #352a15;
--couleur-etat-propose-marque: #e0a54a;
--couleur-etat-retenu-fond: #15322a;
--couleur-etat-retenu-marque: #4cc3a0;
--couleur-etat-bloque-fond: #29254a;
--couleur-etat-bloque-marque: #a99af0;
--couleur-appartenance-1-fond: #1f2335;
--couleur-appartenance-1-marque: #96a1e9;
--couleur-appartenance-2-fond: #2b2416;
--couleur-appartenance-2-marque: #c98f1d;
--couleur-appartenance-3-fond: #251d34;
--couleur-appartenance-3-marque: #9c5ae7;
--couleur-appartenance-4-fond: #162b2a;
--couleur-appartenance-4-marque: #70e1d2;
--couleur-appartenance-5-fond: #2a2717;
--couleur-appartenance-5-marque: #e7cb5a;
--couleur-appartenance-6-fond: #152826;
--couleur-appartenance-6-marque: #298e88;
--couleur-cadre-lecture: #9aa5b4;
--couleur-cadre-ecriture: #81c784;
--couleur-cadre-bloque: #ce93d8;
--couleur-mode-lecture-fond: #1f252d;
--couleur-mode-lecture-filet: #3a434f;
--couleur-mode-ecriture-fond: #16291c;
--couleur-mode-ecriture-filet: #2f5e3c;
--couleur-mode-bloque-fond: #271e33;
--couleur-mode-bloque-filet: #56407a;
--couleur-refus: #d7a6f0;
--couleur-refus-fond: #261d31;
--couleur-refus-filet: #5d4677;
--couleur-refus-marque: var(--couleur-refus);
--couleur-avis: #f0c060;
--couleur-avis-fond: #2e2513;
--couleur-avis-filet: #6b5424;
--couleur-avis-marque: var(--couleur-avis);
--couleur-information: #9cc2f5;
--couleur-information-fond: #172338;
--couleur-information-filet: #34507a;
--couleur-information-marque: var(--couleur-information);
--couleur-focus: #8764e1;
--couleur-selection: #4070f0;

View file

@ -11,6 +11,13 @@
// 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.
//
// Deux registres de marques : MARQUES, celles que les écrans rendent sous
// data-marque et que les épreuves de rendu mesurent sur ce qui est peint
// dessous ; SIGNES, celles des pastilles, des messages, de la bande de mode
// et de l'action principale, qui se valident par calcul sur chacun de leurs
// fonds. Un signe ne porte jamais seul une information : un libellé
// l'accompagne toujours.
const c = (nom) => `--couleur-${nom}`;
@ -56,7 +63,50 @@ export const JETONS_CONFLIT = Object.freeze(CONFLITS.map(({ jeton }) => jeton));
* elle se pose juste sous la plus petite séparation que la palette tient
* entre une teinte de conflit et une autre marque, et garde la palette de
* reculer sans établir qu'un écart de 5 se perçoit. */
export const SEPARATIONS = Object.freeze({ entreConflits: 12, autresMarques: 5 });
export const SEPARATIONS = Object.freeze({ entreConflits: 12, autresMarques: 5, entreAppartenances: 10, clarteAppartenances: 10 });
/** La palette catégorielle des appartenances, sur les listes seulement —
* jamais sur le plan, dont le canal chromatique appartient aux conflits :
* { rang, marque, fond }, rang de 1 à n, le jeton de son point et celui du
* fond de sa pastille. Ses marques se séparent deux à deux de
* SEPARATIONS.entreAppartenances sous les trois dichromaties, et deux
* teintes voisines de SEPARATIONS.clarteAppartenances en clarté
* (verifierClartes) : six teintes au plus tiennent ces deux seuils à 3:1
* sur la carte. Au-delà de n appartenances, la pastille est neutre
* (appartenances.js). */
export const TEINTES_APPARTENANCE = Object.freeze(
[1, 2, 3, 4, 5, 6].map((rang) =>
Object.freeze({ rang, marque: c(`appartenance-${rang}-marque`), fond: c(`appartenance-${rang}-fond`) }),
),
);
// Les surfaces de contenu sur lesquelles une pastille se pose.
const SURFACES = ['carte-fond', 'carte-fond-sourd', 'page-fond'];
// Les teintes d'une pastille dont la marque se pose aussi hors de son fond.
const TEINTES_DE_PASTILLE = [
['etatBrouillon', 'etat-brouillon'],
['etatPropose', 'etat-propose'],
['etatRetenu', 'etat-retenu'],
['etatBloque', 'etat-bloque'],
['neutre', 'neutre'],
...TEINTES_APPARTENANCE.map(({ rang }) => [`appartenance${rang}`, `appartenance-${rang}`]),
];
/** Les signes, même forme que MARQUES, seuil 3 : l'icône ou le point d'une
* pastille sur son fond teinté et sur les surfaces de contenu ; l'icône
* d'un message sur son fond ; l'icône de la bande de mode, de la teinte du
* cadre de mode, sur le fond de la bande ; le fond de l'action principale,
* qui délimite le bouton, sur les surfaces. */
export const SIGNES = Object.freeze([
...TEINTES_DE_PASTILLE.map(([nom, teinte]) => marque(nom, `${teinte}-marque`, [`${teinte}-fond`, ...SURFACES])),
marque('signeAvis', 'avis-marque', ['avis-fond']),
marque('signeRefus', 'refus-marque', ['refus-fond']),
marque('signeInformation', 'information-marque', ['information-fond']),
marque('bandeEcriture', 'cadre-ecriture', ['mode-ecriture-fond']),
marque('bandeLecture', 'cadre-lecture', ['mode-lecture-fond']),
marque('bandeBloque', 'cadre-bloque', ['mode-bloque-fond']),
marque('actionPrincipale', 'action-fond', SURFACES),
]);
/** 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
@ -86,7 +136,7 @@ export const MARQUES = Object.freeze([
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]),
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', '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']),
@ -105,14 +155,40 @@ export const MARQUES = Object.freeze([
marque('filigrane', 'filigrane', ['papier']),
]);
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus,
* avis, listes du plan sur le sol, pastille. */
// Les fonds teintés d'une pastille : ceux de ses teintes, puis ceux des
// messages, qu'un statut reprend, et la teinte de l'action.
const FONDS_ETIQUETTE = [
...TEINTES_DE_PASTILLE.map(([, teinte]) => `${teinte}-fond`),
'avis-fond',
'refus-fond',
'information-fond',
'action-teinte',
];
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire,
* bandeau, refus, avis, listes du plan sur le sol, pastille du plan ; puis
* l'encre de la page sur la carte, l'encre secondaire sur la carte, le
* texte d'une étiquette — l'encre de la page sur chaque fond teinté de
* pastille (§ 12.7) —, celui de la bande de mode, la version et la
* provenance du bandeau, le thème retenu du bandeau, aux couleurs du
* bandeau inversées, le texte de l'action principale, un lien ou un texte
* mis en valeur de l'encre de l'action, et l'information. Le refus, l'avis
* et l'information gardent l'encre de leur message, sur son fond. */
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('refus', 'refus', ['page-fond', 'refus-fond', 'carte-fond']),
texte('avis', 'avis', ['page-fond', 'avis-fond', 'carte-fond']),
texte('listes', 'liste-texte', ['sol']),
texte('pastille', 'pastille-texte', ['pastille-fond']),
texte('carte', 'page-texte', ['carte-fond', 'carte-fond-sourd']),
texte('secondaireCarte', 'page-texte-secondaire', ['carte-fond', 'carte-fond-sourd']),
texte('etiquette', 'page-texte', FONDS_ETIQUETTE),
texte('bandeMode', 'page-texte', ['mode-ecriture-fond', 'mode-lecture-fond', 'mode-bloque-fond']),
texte('bandeauSecondaire', 'bandeau-texte-secondaire', ['bandeau-fond']),
texte('bandeauChoisi', 'bandeau-fond', ['bandeau-texte']),
texte('action', 'action-texte', ['action-fond']),
texte('lien', 'action-encre', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'action-teinte']),
texte('information', 'information', ['information-fond', 'page-fond', 'carte-fond']),
]);

View file

@ -8,8 +8,8 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { CONFLITS, JETONS_CONFLIT, MARQUES, SEPARATIONS, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, verifierPalette, verifierSeparation } from './palette.js';
import { CONFLITS, JETONS_CONFLIT, MARQUES, SEPARATIONS, SIGNES, TEINTES_APPARTENANCE, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, teinte, verifierClartes, verifierPalette, verifierSeparation } from './palette.js';
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
const THEMES = ['clair', 'sombre'];
@ -78,7 +78,7 @@ describe('registre des marques', () => {
});
test('chaque jeton et chaque fond du registre existe dans les deux thèmes', () => {
const entrees = [...MARQUES, ...TEXTES];
const entrees = [...MARQUES, ...TEXTES, ...SIGNES];
assert.ok(entrees.length > 20);
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
@ -128,7 +128,7 @@ describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
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.deepEqual(SEPARATIONS, { entreConflits: 12, autresMarques: 5, entreAppartenances: 10, clarteAppartenances: 10 });
assert.ok(Object.isFrozen(SEPARATIONS));
});
@ -142,7 +142,7 @@ describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
});
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)));
const peintes = [...MARQUES, ...TEXTES, ...SIGNES].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.
@ -151,17 +151,105 @@ describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
});
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']);
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille, puis les textes des cartes, des étiquettes, de la bande de mode, du bandeau, de l’action et des messages ; seuil 4,5', () => {
assert.deepEqual(TEXTES.map((t) => t.nom), [
'page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille',
'carte', 'secondaireCarte', 'etiquette', 'bandeMode', 'bandeauSecondaire', 'bandeauChoisi', 'action', 'lien', 'information',
]);
assert.ok(TEXTES.every((t) => t.seuil === 4.5 && t.fonds.length > 0));
assert.equal(new Set(TEXTES.map((t) => t.nom)).size, TEXTES.length);
assert.deepEqual(parNom(TEXTES, 'listes').fonds, ['--couleur-sol']);
assert.deepEqual(parNom(TEXTES, 'pastille').fonds, ['--couleur-pastille-fond']);
});
test('le texte d’une étiquette est l’encre de la page, sur chaque fond teinté d’une pastille (§ 12.7) ; celui d’un message, l’encre de son message sur son fond', () => {
const etiquette = parNom(TEXTES, 'etiquette');
assert.equal(etiquette.jeton, '--couleur-page-texte');
const fondsTeintes = [
'neutre', 'etat-brouillon', 'etat-propose', 'etat-retenu', 'etat-bloque', 'avis', 'refus', 'information',
...TEINTES_APPARTENANCE.map(({ rang }) => `appartenance-${rang}`),
].map((teinte) => `--couleur-${teinte}-fond`);
for (const fond of fondsTeintes) assert.ok(etiquette.fonds.includes(fond), fond);
assert.deepEqual(parNom(TEXTES, 'bandeMode').fonds, ['ecriture', 'lecture', 'bloque'].map((m) => `--couleur-mode-${m}-fond`));
for (const message of ['refus', 'avis', 'information']) {
const entree = parNom(TEXTES, message);
assert.equal(entree.jeton, `--couleur-${message}`, message);
for (const fond of [`--couleur-${message}-fond`, '--couleur-page-fond', '--couleur-carte-fond']) assert.ok(entree.fonds.includes(fond), `${message} : ${fond}`);
}
});
});
describe('les signes des pastilles, des messages, de la bande de mode et de l’action', () => {
const NOMS_SIGNES = [
'etatBrouillon', 'etatPropose', 'etatRetenu', 'etatBloque', 'neutre',
'appartenance1', 'appartenance2', 'appartenance3', 'appartenance4', 'appartenance5', 'appartenance6',
'signeAvis', 'signeRefus', 'signeInformation', 'bandeEcriture', 'bandeLecture', 'bandeBloque', 'actionPrincipale',
];
test('le registre des signes : noms uniques, hors de MARQUES, seuil 3, figé', () => {
assert.deepEqual(SIGNES.map((s) => s.nom), NOMS_SIGNES);
assert.ok(Object.isFrozen(SIGNES) && SIGNES.every((s) => Object.isFrozen(s) && Object.isFrozen(s.fonds)));
assert.ok(SIGNES.every((s) => s.seuil === 3 && s.fonds.length > 0));
const marques = new Set(MARQUES.map((m) => m.nom));
assert.deepEqual(SIGNES.filter((s) => marques.has(s.nom)).map((s) => s.nom), []);
});
test('le signe d’un état, du neutre ou d’une appartenance se mesure sur son fond teinté, la carte, son fond sourd et la page', () => {
const teintes = [
['etatBrouillon', 'etat-brouillon'], ['etatPropose', 'etat-propose'], ['etatRetenu', 'etat-retenu'], ['etatBloque', 'etat-bloque'], ['neutre', 'neutre'],
...TEINTES_APPARTENANCE.map(({ rang }) => [`appartenance${rang}`, `appartenance-${rang}`]),
];
for (const [nom, teinte] of teintes) {
const signe = parNom(SIGNES, nom);
assert.equal(signe.jeton, `--couleur-${teinte}-marque`, nom);
assert.deepEqual(signe.fonds, [`--couleur-${teinte}-fond`, '--couleur-carte-fond', '--couleur-carte-fond-sourd', '--couleur-page-fond'], nom);
}
// La bande de mode reprend la teinte du cadre de mode, sur son propre fond.
for (const mode of ['Ecriture', 'Lecture', 'Bloque']) {
const signe = parNom(SIGNES, `bande${mode}`);
assert.deepEqual([signe.jeton, ...signe.fonds], [`--couleur-cadre-${mode.toLowerCase()}`, `--couleur-mode-${mode.toLowerCase()}-fond`]);
}
});
test('chaque signe tient 3:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, SIGNES), []);
});
test('le focus se mesure aussi sur la carte et son fond sourd', () => {
for (const fond of ['--couleur-carte-fond', '--couleur-carte-fond-sourd']) assert.ok(parNom(MARQUES, 'focus').fonds.includes(fond), fond);
});
});
describe('la palette des appartenances (listes seulement)', () => {
test('six teintes au plus, rangées de 1 à n, chacune un fond et une marque', () => {
assert.ok(TEINTES_APPARTENANCE.length > 0 && TEINTES_APPARTENANCE.length <= 6);
assert.ok(Object.isFrozen(TEINTES_APPARTENANCE) && TEINTES_APPARTENANCE.every(Object.isFrozen));
TEINTES_APPARTENANCE.forEach((entree, i) => {
assert.deepEqual(entree, { rang: i + 1, marque: `--couleur-appartenance-${i + 1}-marque`, fond: `--couleur-appartenance-${i + 1}-fond` });
});
});
const marques = () => TEINTES_APPARTENANCE.map(({ marque }) => marque);
test('chaque marque est chromatique dans les deux thèmes : la teinte la porte, avec le nom', () => {
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
for (const nom of marques()) assert.notEqual(teinte(jetons.get(nom)), null, `${theme} : ${nom}`);
}
});
test(`les marques se séparent deux à deux de ${SEPARATIONS.entreAppartenances} au moins (OKLab × 100), dans les deux thèmes, en couleurs d’origine et sous les trois simulations`, () => {
assert.deepEqual(verifierSeparation(JETONS, marques(), [], SEPARATIONS.entreAppartenances), []);
});
test(`deux marques de teintes voisines se distinguent aussi en clarté, de ${SEPARATIONS.clarteAppartenances} au moins (L d’OKLab × 100)`, () => {
assert.deepEqual(verifierClartes(JETONS, marques(), SEPARATIONS.clarteAppartenances), []);
});
});
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]));
const noms = new Set([...MARQUES, ...TEXTES, ...SIGNES].flatMap((e) => [e.jeton, ...e.fonds]));
assert.ok(noms.size > 20);
const blocs = lireBlocs(JETONS);
for (const theme of THEMES) {

View file

@ -3,8 +3,9 @@
// 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 l'écart perçu entre deux
// couleurs, et contrôle les teintes réservées aux conflits. Il est pur : il
// mesure le contraste au sens des WCAG, l'écart perçu entre deux couleurs
// et l'écart de clarté de deux teintes voisines, 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.
//
@ -360,3 +361,33 @@ export function verifierSeparation(texteCss, jetons, autres, seuil) {
}
return fautes;
}
/** Des teintes voisines se distinguent en clarté : dans chaque thème, deux
* jetons dont les teintes HSL d'origine se tiennent à moins d'ECART_TEINTE
* degrés — ou dont l'un se lit gris, sans teinte — s'écartent d'au moins
* seuil en clarté, le L d'OKLab multiplié par 100. Deux teintes plus
* éloignées se distinguent par la teinte et ne se mesurent pas ici. Rend
* les fautes [{theme, jeton, autre, ecart, seuil}], ecart l'écart de clarté
* × 100, dans l'ordre thème, paires de jetons ; [] quand tout tient. Un
* jeton absent d'un thème lève. */
export function verifierClartes(texteCss, jetons, seuil) {
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);
};
jetons.forEach((jeton, i) => {
for (const autre of jetons.slice(i + 1)) {
const [a, b] = [lire(jeton), lire(autre)];
const [ta, tb] = [teinte(a), teinte(b)];
const brut = ta === null || tb === null ? 0 : Math.abs(ta - tb);
if (Math.min(brut, 360 - brut) >= ECART_TEINTE) continue;
const ecart = 100 * Math.abs(oklab(a)[0] - oklab(b)[0]);
if (ecart < seuil) fautes.push({ theme, jeton, autre, ecart, seuil });
}
});
}
return fautes;
}

View file

@ -21,6 +21,7 @@ import {
oklab,
simuler,
teinte,
verifierClartes,
verifierPalette,
verifierSeparation,
verifierTeintesReservees,
@ -374,6 +375,41 @@ describe('ecartPercu et verifierSeparation (§ 12.7 : simuler d’abord, puis me
});
});
describe('verifierClartes : des teintes voisines se distinguent en clarté', () => {
test('deux bleus de même clarté sont relevés, dans chaque thème ; un bleu et un ocre de même clarté passent : leurs teintes s’écartent', () => {
const css = feuille({ '--couleur-a': '#2050b0', '--couleur-b': '#2a56b2', '--couleur-o': '#8a5a00' });
const fautes = verifierClartes(css, ['--couleur-a', '--couleur-b', '--couleur-o'], 10);
assert.deepEqual(fautes.map((f) => `${f.theme} ${f.jeton}/${f.autre}`), ['clair --couleur-a/--couleur-b', 'sombre --couleur-a/--couleur-b']);
assert.ok(fautes.every((f) => f.seuil === 10 && f.ecart < 10 && f.ecart >= 0));
assert.ok(Math.abs(oklab(hex('#2050b0'))[0] - oklab(hex('#8a5a00'))[0]) < 0.1, 'le bleu et l’ocre ont une clarté voisine');
});
test('une teinte à 29° de l’autre est voisine, à 31° elle ne l’est plus ; un gris est voisin de toute couleur', () => {
// Trois couleurs de même clarté perçue, à 0,001 près : un bleu à 199,8°,
// un bleu à 229,2° et un autre à 231,0°.
const base = '#228ec3';
for (const [autre, voisine] of [['#637ae3', true], ['#6679e4', false]]) {
assert.ok(Math.abs(oklab(hex(base))[0] - oklab(hex(autre))[0]) < 0.001, autre);
const ecart = teinte(hex(autre)) - teinte(hex(base));
assert.ok(voisine ? ecart > 29 && ecart < 30 : ecart > 30 && ecart < 32, `${autre} : écart ${ecart}`);
const css = feuille({ '--couleur-a': base, '--couleur-b': autre });
assert.equal(verifierClartes(css, ['--couleur-a', '--couleur-b'], 5).length, voisine ? 2 : 0, autre);
}
const gris = feuille({ '--couleur-a': '#2050b0', '--couleur-g': '#56595e' });
assert.equal(teinte(hex('#56595e')), null);
assert.ok(Math.abs(oklab(hex('#2050b0'))[0] - oklab(hex('#56595e'))[0]) < 0.05);
assert.equal(verifierClartes(gris, ['--couleur-a', '--couleur-g'], 10).length, 2);
});
test('deux bleus de clartés écartées passent ; le seuil se mesure en L × 100 ; un jeton absent lève', () => {
const css = feuille({ '--couleur-clair': '#6f9be8', '--couleur-fonce': '#173a80' });
const ecart = 100 * Math.abs(oklab(hex('#6f9be8'))[0] - oklab(hex('#173a80'))[0]);
assert.deepEqual(verifierClartes(css, ['--couleur-clair', '--couleur-fonce'], ecart - 0.01), []);
assert.equal(verifierClartes(css, ['--couleur-clair', '--couleur-fonce'], ecart + 0.01).length, 2);
assert.throws(() => verifierClartes(css, ['--couleur-clair', '--couleur-absente'], 10), /--couleur-absente/);
});
});
// Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés.
function sourcesDeStyle() {
return readdirSync(DOSSIER, { recursive: true, withFileTypes: true })

View file

@ -0,0 +1,127 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Une carte : la surface d'un contenu, sur le fond de la page — fond de la
// carte, filet, rayon, ombre légère. Titrée, c'est une région (<section>)
// nommée par son titre, un intitulé de niveau niveau, que suivent son
// sous-titre puis, à droite, ses actions ; le contenu vient dessous, séparé
// par un filet. Sans titre, c'est une boîte sans nom, que son contenu
// remplit.
//
// titre : le texte de l'intitulé, déjà traduit, ou null ; sousTitre : une
// ligne d'encre secondaire sous le titre, ou null ; niveau : 2 à 4 ;
// icone : une icône des primitives devant le titre, ou null ; plein : le
// contenu touche les bords — un tableau —, sinon il s'en écarte ; actions
// et children : des snippets.
import Icone from './Icone.svelte';
let { titre = null, sousTitre = null, niveau = 2, icone = null, plein = false, actions = null, children = null } = $props();
const uid = $props.id();
const idTitre = `${uid}-titre`;
// L'élément de l'intitulé ; un niveau hors de 2 à 4 lève au montage.
const balise = $derived.by(() => {
if (![2, 3, 4].includes(niveau)) throw new RangeError(`carte : niveau de titre hors de 2 à 4 : ${JSON.stringify(niveau)}`);
return `h${niveau}`;
});
</script>
{#if titre !== null}
<section class="carte" aria-labelledby={idTitre}>
<div class="entete" class:seule={children === null}>
<div class="intitule">
<svelte:element this={balise} id={idTitre} class="titre">
{#if icone !== null}
<span class="pictogramme"><Icone nom={icone} /></span>
{/if}
{titre}
</svelte:element>
{#if sousTitre !== null}
<p class="sous-titre">{sousTitre}</p>
{/if}
</div>
{#if actions !== null}
<div class="actions">{@render actions()}</div>
{/if}
</div>
{#if children !== null}
<div class="corps" class:plein>{@render children()}</div>
{/if}
</section>
{:else}
<div class="carte">
<div class="corps" class:plein>{@render children?.()}</div>
</div>
{/if}
<style>
.carte {
min-width: 0;
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.entete {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--espace-3);
padding: var(--espace-4) var(--espace-5);
border-bottom: 1px solid var(--couleur-carte-filet);
}
.entete.seule {
border-bottom: none;
}
.intitule {
min-width: 0;
}
.titre {
display: flex;
align-items: center;
gap: var(--espace-2);
margin: 0;
font-size: var(--taille-titre-carte);
font-weight: bold;
line-height: var(--police-interligne);
}
.pictogramme {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--rayon-2);
background-color: var(--couleur-neutre-fond);
color: var(--couleur-neutre-marque);
}
.sous-titre {
margin: var(--espace-1) 0 0;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
}
.corps {
padding: var(--espace-4) var(--espace-5);
}
.corps.plein {
padding: 0;
}
</style>

View file

@ -212,8 +212,8 @@
input {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);

View file

@ -26,12 +26,18 @@
// data-commande porte nom, ou à défaut le dernier segment de la clé.
// confirmer se lit au montage : une commande qui change de confirmation se
// remonte.
//
// variante : 'secondaire', la défaut, à filet sur le fond de la carte ;
// 'principale', l'action à faire sur l'écran, pleine de la teinte de
// l'action, haute d'une cible principale ; 'discrete', sans fond ni filet
// au repos. Elle ne change que l'apparence ; une variante hors de
// VARIANTES_COMMANDE lève RangeError au montage.
import { getContext, onDestroy, untrack } from 'svelte';
import { effetDe } from '../raccourcis.js';
import { DELAI_CONFIRMATION_MS, creerMachineCommande } from './commande.js';
import { DELAI_CONFIRMATION_MS, creerMachineCommande, exigerVariante } from './commande.js';
import Refus from './Refus.svelte';
let { cle, executer, confirmer = null, details = {}, nom = null, gestes = {} } = $props();
let { cle, executer, confirmer = null, details = {}, nom = null, gestes = {}, variante = 'secondaire' } = $props();
const t = getContext('t');
const horloge = getContext('horloge');
@ -44,6 +50,7 @@
const libelle = $derived(armee ? t(confirmer) : t(cle, details));
const designation = $derived(nom ?? cle.slice(cle.lastIndexOf('.') + 1));
const forme = $derived(exigerVariante(variante));
function desarmer() {
if (minuterie === null) return;
@ -93,6 +100,8 @@
<button
type="button"
data-commande={designation}
class:principale={forme === 'principale'}
class:discrete={forme === 'discrete'}
aria-busy={enCours ? 'true' : undefined}
aria-disabled={enCours ? 'true' : undefined}
onkeydown={surTouche}
@ -111,23 +120,57 @@
gap: var(--espace-2);
}
/* La secondaire : à filet, sur le fond de la carte. */
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
cursor: pointer;
}
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
/* La principale : pleine de la teinte de l'action, d'une cible
principale de haut. */
button.principale {
min-height: var(--hauteur-cible);
padding: 0 var(--espace-4);
border-color: var(--couleur-action-fond);
background-color: var(--couleur-action-fond);
color: var(--couleur-action-texte);
font-weight: bold;
}
/* La discrète : ni fond ni filet au repos ; le survol lui donne le fond
sourd de la carte. */
button.discrete {
border-color: transparent;
background-color: transparent;
}
button.discrete:hover {
background-color: var(--couleur-carte-fond-sourd);
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
/* Occupée, la principale garde ses couleurs ; son filet tireté prend
l'encre de son texte. */
button.principale[aria-disabled='true'] {
border-color: var(--couleur-action-texte);
color: var(--couleur-action-texte);
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;

View file

@ -151,10 +151,10 @@
<style>
dialog {
max-width: min(40rem, calc(100vw - 2 * var(--espace-4)));
padding: var(--espace-4);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-page-fond);
padding: var(--espace-5);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-2);
}
@ -189,8 +189,8 @@
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;

View file

@ -0,0 +1,40 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Une icône du module des primitives (icones.js) : un <svg> au trait,
// sans remplissage, du trait de la couleur courante — le parent la règle
// par color, d'un jeton. Elle est cachée aux technologies d'assistance et
// ne prend jamais le focus : un libellé l'accompagne toujours. taille :
// le côté du carré, en px. Un nom inconnu lève RangeError au montage.
import { traceIcone } from './icones.js';
let { nom, taille = 16 } = $props();
const traces = $derived(traceIcone(nom));
</script>
<svg
class="icone"
width={taille}
height={taille}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
{#each traces as trace, rang (rang)}
<path d={trace} />
{/each}
</svg>
<style>
.icone {
flex: none;
vertical-align: middle;
}
</style>

View file

@ -89,12 +89,12 @@
box-sizing: border-box;
width: max-content;
max-width: min(26rem, calc(100% - 16px));
padding: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background: var(--couleur-page-fond);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-1);
box-shadow: var(--ombre-2);
font-size: var(--taille-texte);
font-weight: normal;
line-height: 1.4;

View file

@ -0,0 +1,220 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Une pastille (§ 12.7) : un fond teinté, un signe — une icône ou un
// point — de la marque de sa teinte, et un libellé de l'encre de la page.
// La couleur redit le libellé et ne le remplace jamais. Le modèle — la
// teinte, le signe — est celui de pastille.js ; une sorte ou une valeur
// hors contrat lève RangeError au montage.
//
// sorte : 'etat', 'appartenance' ou 'statut' ; valeur : l'état du plan, le
// rang de 1 à 6 ou null (teintesAppartenances), le ton ; libelle : le
// texte affiché, déjà traduit ou une donnée ; icone : une icône des
// primitives, qui remplace le signe par défaut. Le signe est caché aux
// technologies d'assistance.
//
// Un libellé court tient sur une ligne. Un libellé plus long que la place
// donnée se replie, entre deux mots puis entre deux caractères, et ne se
// tronque jamais : la pastille ne dépasse pas son conteneur, padding
// compris, et le texte ne sort pas de son fond.
import Icone from './Icone.svelte';
import { modelePastille } from './pastille.js';
let { sorte, valeur, libelle, icone = null } = $props();
const modele = $derived(modelePastille(sorte, valeur, icone));
</script>
<span class="pastille {modele.teinte}" class:etat={sorte === 'etat'}>
{#if modele.icone !== null}
<span class="signe"><Icone nom={modele.icone} taille={14} /></span>
{:else if modele.point}
<span class="point" aria-hidden="true"></span>
{/if}
<span class="libelle">{libelle}</span>
</span>
<style>
/* box-sizing: border-box borne la pastille à max-width padding compris.
Sa largeur minimale reste celle de son mot le plus long : dans une rangée
flexible trop étroite, ce sont ses voisins qui rétrécissent, et un
libellé court ne se casse pas en colonne de lettres. */
.pastille {
display: inline-flex;
box-sizing: border-box;
align-items: center;
gap: var(--espace-1);
max-width: 100%;
padding: 0.125rem var(--espace-2) 0.125rem 0.4375rem;
/* Le rayon vaut la moitié de la hauteur d'une ligne, padding compris :
une pastille d'une ligne est une pilule, une pastille repliée un
rectangle arrondi dont les coins ne rognent pas le texte. Un moteur
sans l'unité lh garde la pilule. */
border-radius: var(--rayon-pilule);
border-radius: calc(0.5lh + 0.125rem);
color: var(--couleur-page-texte);
font-size: var(--taille-texte-petit);
line-height: var(--police-interligne);
}
/* Le libellé se replie même sous un parent en nowrap ; overflow-wrap:
break-word coupe entre deux caractères un mot plus long que la largeur
bornée par max-width, sans le compter dans la largeur minimale ;
min-width: 0 laisse le libellé descendre à cette largeur. */
.libelle {
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
}
/* Un état du plan se lit en gras. */
.etat {
font-weight: bold;
}
.signe {
display: inline-flex;
flex: none;
}
.point {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
/* Chaque teinte : le fond de la pastille, la couleur du signe — le trait
d'une icône, qui la lit en currentColor, ou le fond d'un point. */
.neutre {
background-color: var(--couleur-neutre-fond);
}
.neutre .signe {
color: var(--couleur-neutre-marque);
}
.neutre .point {
background-color: var(--couleur-neutre-marque);
}
.etat-brouillon {
background-color: var(--couleur-etat-brouillon-fond);
}
.etat-brouillon .signe {
color: var(--couleur-etat-brouillon-marque);
}
.etat-brouillon .point {
background-color: var(--couleur-etat-brouillon-marque);
}
.etat-propose {
background-color: var(--couleur-etat-propose-fond);
}
.etat-propose .signe {
color: var(--couleur-etat-propose-marque);
}
.etat-propose .point {
background-color: var(--couleur-etat-propose-marque);
}
.etat-retenu {
background-color: var(--couleur-etat-retenu-fond);
}
.etat-retenu .signe {
color: var(--couleur-etat-retenu-marque);
}
.etat-retenu .point {
background-color: var(--couleur-etat-retenu-marque);
}
.etat-bloque {
background-color: var(--couleur-etat-bloque-fond);
}
.etat-bloque .signe {
color: var(--couleur-etat-bloque-marque);
}
.etat-bloque .point {
background-color: var(--couleur-etat-bloque-marque);
}
.appartenance-1 {
background-color: var(--couleur-appartenance-1-fond);
}
.appartenance-1 .signe {
color: var(--couleur-appartenance-1-marque);
}
.appartenance-1 .point {
background-color: var(--couleur-appartenance-1-marque);
}
.appartenance-2 {
background-color: var(--couleur-appartenance-2-fond);
}
.appartenance-2 .signe {
color: var(--couleur-appartenance-2-marque);
}
.appartenance-2 .point {
background-color: var(--couleur-appartenance-2-marque);
}
.appartenance-3 {
background-color: var(--couleur-appartenance-3-fond);
}
.appartenance-3 .signe {
color: var(--couleur-appartenance-3-marque);
}
.appartenance-3 .point {
background-color: var(--couleur-appartenance-3-marque);
}
.appartenance-4 {
background-color: var(--couleur-appartenance-4-fond);
}
.appartenance-4 .signe {
color: var(--couleur-appartenance-4-marque);
}
.appartenance-4 .point {
background-color: var(--couleur-appartenance-4-marque);
}
.appartenance-5 {
background-color: var(--couleur-appartenance-5-fond);
}
.appartenance-5 .signe {
color: var(--couleur-appartenance-5-marque);
}
.appartenance-5 .point {
background-color: var(--couleur-appartenance-5-marque);
}
.appartenance-6 {
background-color: var(--couleur-appartenance-6-fond);
}
.appartenance-6 .signe {
color: var(--couleur-appartenance-6-marque);
}
.appartenance-6 .point {
background-color: var(--couleur-appartenance-6-marque);
}
.information {
background-color: var(--couleur-information-fond);
}
.information .signe {
color: var(--couleur-information-marque);
}
.avis {
background-color: var(--couleur-avis-fond);
}
.avis .signe {
color: var(--couleur-avis-marque);
}
.refus {
background-color: var(--couleur-refus-fond);
}
.refus .signe {
color: var(--couleur-refus-marque);
}
</style>

View file

@ -16,9 +16,14 @@
// quand il est donné, reçoit le refus affiché au départ du remède, celui
// qu'il levait : le porteur ne retire que lui, même remplacé entre-temps.
// Un refus de details.autres rend à lever le refus qui le porte.
//
// Le refus se peint sur son propre fond, --couleur-refus-fond, bordé de son
// filet et d'un trait de gauche de la teinte du refus, une icône devant son
// texte : il se lit de même sur la page, une carte ou une fenêtre.
import { getContext } from 'svelte';
import { actionRemede } from './commande.js';
import Commande from './Commande.svelte';
import Icone from './Icone.svelte';
import Refus from './Refus.svelte';
let { refus, lever = null, gestes = {}, id = undefined } = $props();
@ -43,7 +48,7 @@
</script>
<div class="refus" role="status" data-refus={refus.code} {id}>
<p>{message}</p>
<p><span class="signe"><Icone nom="interdit" /></span>{message}</p>
{#if action !== null}
{#key action}
<Commande
@ -68,7 +73,10 @@
align-items: flex-start;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-refus-filet);
border-left: 3px solid var(--couleur-refus);
border-radius: var(--rayon-2);
background-color: var(--couleur-refus-fond);
color: var(--couleur-refus);
font-size: var(--taille-texte);
}
@ -76,4 +84,9 @@
p {
margin: 0;
}
.signe {
margin-right: var(--espace-1);
color: var(--couleur-refus-marque);
}
</style>

View file

@ -36,6 +36,25 @@
import { COMMANDES } from '../../application/commandes.js';
/** Les variantes d'une commande, la secondaire en tête — celle d'une
* commande qui n'en donne pas : principale, l'action à faire sur l'écran,
* pleine de la teinte de l'action ; secondaire, à filet ; discrète, sans
* fond ni filet au repos. La variante ne change que l'apparence : le rôle,
* les attributs et la machine restent ceux de toute commande. */
export const VARIANTES_COMMANDE = Object.freeze(['secondaire', 'principale', 'discrete']);
/**
* Une variante de commande, rendue telle quelle.
*
* @param {string} variante
* @returns {'secondaire'|'principale'|'discrete'}
* @throws {RangeError} hors de VARIANTES_COMMANDE, en la nommant
*/
export function exigerVariante(variante) {
if (!VARIANTES_COMMANDE.includes(variante)) throw new RangeError(`commande : variante inconnue ${JSON.stringify(variante) ?? 'undefined'}`);
return variante;
}
/** Le délai au terme duquel une commande armée se désarme. */
export const DELAI_CONFIRMATION_MS = 10_000;

View file

@ -22,8 +22,10 @@ import {
GESTES,
SECTIONS_REMEDE,
SOURCES_SECOURS,
VARIANTES_COMMANDE,
actionRemede,
creerMachineCommande,
exigerVariante,
libelleRemede,
} from './commande.js';
@ -348,3 +350,17 @@ describe('commande : l’action d’un remède', () => {
assert.equal(action.cle, 'communs.remede.choisirPlace');
});
});
describe('commande : la variante', () => {
test('trois variantes, la secondaire en tête : celle d’une commande qui n’en donne pas', () => {
assert.deepEqual(VARIANTES_COMMANDE, ['secondaire', 'principale', 'discrete']);
assert.ok(Object.isFrozen(VARIANTES_COMMANDE));
});
test('exigerVariante rend une variante du contrat telle quelle ; une autre lève RangeError en la nommant', () => {
for (const variante of VARIANTES_COMMANDE) assert.equal(exigerVariante(variante), variante);
for (const autre of ['primaire', '', null, undefined, 'Principale']) {
assert.throws(() => exigerVariante(autre), (e) => e instanceof RangeError && e.message.includes(JSON.stringify(autre) ?? 'undefined'), String(autre));
}
});
});

View file

@ -0,0 +1,483 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'habillage des primitives dans un vrai moteur de rendu (§ 12.7, § 14.5) :
// l'icône, la pastille, la carte, les variantes de la commande, le refus,
// l'infobulle, le champ et la fenêtre à confirmer, montés chacun par mount,
// dans les deux thèmes. Le test interroge le rendu : le style calculé, la
// boîte, le nom accessible, jamais la présence d'une classe (§ 14.2). La
// couleur attendue d'un jeton se relit sur une sonde qui le peint, et le
// contraste d'un texte se mesure sur ce qui est peint sous lui.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, describe, expect, test } from 'vitest';
import { createRawSnippet, flushSync, mount, unmount } from 'svelte';
import '../jetons.css';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { creerTraduction } from '../../application/traduction.js';
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import { RANGS_APPARTENANCE } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Carte from './Carte.svelte';
import Champ from './Champ.svelte';
import Commande from './Commande.svelte';
import Confirmation from './Confirmation.svelte';
import Icone from './Icone.svelte';
import Infobulle from './Infobulle.svelte';
import Pastille from './Pastille.svelte';
import Refus from './Refus.svelte';
import { ICONES, NOMS_ICONES } from './icones.js';
import { ETATS_PASTILLE, TONS_PASTILLE, modelePastille } from './pastille.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const THEMES = ['clair', 'sombre'];
const TRANSPARENT = [0, 0, 0, 0];
// Les composants montés par l'épreuve courante, et leurs conteneurs.
const montes = [];
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
});
// Monte un composant dans un conteneur neuf, au début du corps, sur le fond
// de la page.
function monter(Composant, props, contexte = new Map([['t', t]])) {
const cible = document.createElement('div');
cible.style.backgroundColor = 'var(--couleur-page-fond)';
cible.style.padding = '8px';
document.body.prepend(cible);
const composant = mount(Composant, { target: cible, props, context: contexte });
flushSync();
montes.push({ composant, cible });
return cible;
}
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
const style = (element) => getComputedStyle(element);
const fond = (element) => composantes(style(element).backgroundColor);
// Un texte tient 4,5:1 sur chaque couleur peinte sous lui.
function exigerLisible(element, lieu) {
const texte = couleurTexte({ element, pseudo: null });
const fonds = fondPeintSous({ element, pseudo: null });
expect(fonds.length, `${lieu} : aucun fond opaque`).toBeGreaterThan(0);
for (const f of fonds) expect(contraste(texte, f), `${lieu} sur ${f}`).toBeGreaterThanOrEqual(4.5);
}
// La couleur d'un signe : celle de son trait pour une icône, de son fond pour
// un point.
const couleurSigne = (signe) => (signe.tagName.toLowerCase() === 'svg' ? composantes(style(signe.querySelector('path')).stroke) : fond(signe));
// Un signe tient 3:1 sur chaque couleur peinte sous lui (§ 14.5). Sous une
// icône, le fond se lit sous le dessin ; un point peint son propre fond, et
// ce qui est sous lui se lit donc sous son conteneur.
function exigerSigneVisible(signe, lieu) {
const couleur = couleurSigne(signe);
const fonds = fondPeintSous(signe.tagName.toLowerCase() === 'svg' ? signe : signe.parentElement);
expect(fonds.length, `${lieu} : aucun fond opaque sous le signe`).toBeGreaterThan(0);
for (const f of fonds) expect(contraste(couleur, f), `${lieu} : signe sur ${f}`).toBeGreaterThanOrEqual(3);
}
// Bord droit du texte d'un élément : celui de la plus à droite des boîtes de
// ses lignes, lu sur une plage qui couvre son contenu. La boîte de l'élément
// ne le dit pas : un texte qui déborde sort de sa boîte sans l'élargir.
function bordDroitDuTexte(element) {
const plage = document.createRange();
plage.selectNodeContents(element);
return Math.max(...[...plage.getClientRects()].map((r) => r.right));
}
// Un point du texte est-il sur le fond d'une boîte aux quatre coins arrondis
// d'un même rayon ? Le rayon peint est le rayon calculé, réduit comme le fait
// le moteur quand deux coins voisins se chevaucheraient : au plus la moitié
// du plus petit côté. Seuls les quatre carrés de coin peuvent tomber hors du
// fond.
function surLeFond(boite, rayonCalcule, x, y) {
const r = Math.min(rayonCalcule, boite.width / 2, boite.height / 2);
const cx = Math.min(Math.max(x, boite.left + r), boite.right - r);
const cy = Math.min(Math.max(y, boite.top + r), boite.bottom - r);
return Math.hypot(x - cx, y - cy) <= r + 0.5;
}
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
describe('icône (module des primitives)', () => {
test('chaque icône : un <svg> caché aux technologies d’assistance, au trait de la couleur courante, sans remplissage, un tracé par entrée du module', () => {
for (const nom of NOMS_ICONES) {
const cible = monter(Icone, { nom });
const svg = cible.querySelector('svg');
expect(svg, nom).not.toBeNull();
expect(svg.getAttribute('aria-hidden'), nom).toBe('true');
expect(svg.getAttribute('focusable'), nom).toBe('false');
expect(svg.getAttribute('viewBox'), nom).toBe('0 0 24 24');
expect(svg.querySelectorAll('path').length, nom).toBe(ICONES[nom].length);
const trait = style(svg.querySelector('path'));
expect(trait.fill, nom).toBe('none');
expect(trait.stroke, nom).toBe(style(svg).color);
const boite = svg.getBoundingClientRect();
expect([boite.width, boite.height], nom).toEqual([16, 16]);
const dessin = svg.getBBox();
expect(dessin.width * dessin.height, nom).toBeGreaterThan(0);
expect(svg.textContent.trim(), nom).toBe('');
}
});
test('la taille se donne en px ; un nom inconnu lève RangeError au montage', () => {
const cible = monter(Icone, { nom: 'coche', taille: 20 });
expect(cible.querySelector('svg').getBoundingClientRect().width).toBe(20);
expect(() => monter(Icone, { nom: 'licorne' })).toThrow(RangeError);
});
});
describe('pastille', () => {
// La pastille montée : sa racine, son signe — le seul élément caché aux
// technologies d'assistance —, et l'élément qui porte son libellé.
function pastille(props) {
const cible = monter(Pastille, props);
const racine = cible.firstElementChild;
const signe = racine.querySelector('[aria-hidden="true"]');
const libelle = [...racine.querySelectorAll('*')].find((e) => [...e.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '')) ?? racine;
return { racine, signe, libelle };
}
test('un état du plan : le libellé de l’encre de la page sur le fond teinté de l’état, l’icône de l’état dans sa marque, dans les deux thèmes', async () => {
for (const theme of THEMES) {
await sousTheme(theme, () => {
for (const etat of ETATS_PASTILLE) {
const lieu = `${theme} ${etat}`;
const { racine, signe, libelle } = pastille({ sorte: 'etat', valeur: etat, libelle: `Libellé ${etat}` });
expect(racine.textContent.trim(), lieu).toBe(`Libellé ${etat}`);
expect(visible(racine), lieu).toBe(true);
expect(fond(racine), lieu).toEqual(jeton(`--couleur-etat-${etat}-fond`));
expect(composantes(style(libelle).color), lieu).toEqual(jeton('--couleur-page-texte'));
expect(signe.tagName.toLowerCase(), lieu).toBe('svg');
expect(signe.querySelectorAll('path').length, lieu).toBe(ICONES[modelePastille('etat', etat).icone].length);
expect(couleurSigne(signe), lieu).toEqual(jeton(`--couleur-etat-${etat}-marque`));
exigerSigneVisible(signe, lieu);
exigerLisible(libelle, lieu);
}
});
}
});
test('une appartenance : le point de la marque de son rang sur son fond ; au-delà, le neutre ; le libellé reste le nom', async () => {
for (const theme of THEMES) {
await sousTheme(theme, () => {
for (const rang of [...Array.from({ length: RANGS_APPARTENANCE }, (_, i) => i + 1), null]) {
const lieu = `${theme} rang ${rang}`;
const teinte = rang === null ? 'neutre' : `appartenance-${rang}`;
const { racine, signe, libelle } = pastille({ sorte: 'appartenance', valeur: rang, libelle: 'Cercle des Aulnes' });
expect(racine.textContent.trim(), lieu).toBe('Cercle des Aulnes');
expect(signe.tagName.toLowerCase(), lieu).toBe('span');
expect(signe.getBoundingClientRect().width, lieu).toBeGreaterThanOrEqual(6);
expect(fond(racine), lieu).toEqual(jeton(`--couleur-${teinte}-fond`));
expect(couleurSigne(signe), lieu).toEqual(jeton(`--couleur-${teinte}-marque`));
exigerSigneVisible(signe, lieu);
exigerLisible(libelle, lieu);
}
});
}
});
test('un statut : le fond de son ton, sans signe ; une icône donnée se dessine dans la marque du ton', async () => {
for (const theme of THEMES) {
await sousTheme(theme, () => {
for (const ton of TONS_PASTILLE) {
const lieu = `${theme} ${ton}`;
const teinte = modelePastille('statut', ton).teinte;
const sans = pastille({ sorte: 'statut', valeur: ton, libelle: 'exclue' });
expect(sans.signe, lieu).toBeNull();
expect(fond(sans.racine), lieu).toEqual(jeton(`--couleur-${teinte}-fond`));
exigerLisible(sans.libelle, lieu);
const avec = pastille({ sorte: 'statut', valeur: ton, libelle: 'exclue', icone: 'croix' });
expect(avec.signe.querySelectorAll('path').length, lieu).toBe(ICONES.croix.length);
expect(couleurSigne(avec.signe), lieu).toEqual(jeton(`--couleur-${teinte}-marque`));
exigerSigneVisible(avec.signe, lieu);
}
});
}
});
// Un nom d'appartenance long, inventé : une donnée n'a pas de longueur
// bornée.
const LONG = 'Coopérative intercommunale des apiculteurs du littoral';
test('un libellé long se replie dans la largeur donnée : le texte reste dans la pastille, la pastille dans son conteneur', () => {
const { racine, libelle } = pastille({ sorte: 'appartenance', valeur: 2, libelle: LONG });
const conteneur = racine.parentElement;
conteneur.style.width = '120px';
const droite = conteneur.getBoundingClientRect().right - parseFloat(style(conteneur).paddingRight);
expect(racine.textContent.trim()).toBe(LONG);
expect(bordDroitDuTexte(libelle)).toBeLessThanOrEqual(racine.getBoundingClientRect().right + 0.5);
expect(racine.getBoundingClientRect().right).toBeLessThanOrEqual(droite + 0.5);
expect(racine.scrollWidth).toBeLessThanOrEqual(racine.clientWidth + 1);
// Le libellé replié reste sur le fond teinté, coins arrondis compris :
// les coins extérieurs de sa première et de sa dernière ligne.
const plage = document.createRange();
plage.selectNodeContents(libelle);
const lignes = [...plage.getClientRects()];
expect(lignes.length).toBeGreaterThan(1);
const boite = racine.getBoundingClientRect();
const rayon = parseFloat(style(racine).borderTopLeftRadius);
const [premiere, derniere] = [lignes[0], lignes.at(-1)];
for (const [x, y, coin] of [
[premiere.left, premiere.top, 'haut gauche'],
[premiere.right, premiere.top, 'haut droit'],
[derniere.left, derniere.bottom, 'bas gauche'],
[derniere.right, derniere.bottom, 'bas droit'],
]) {
expect(surLeFond(boite, rayon, x, y), `coin ${coin} du libellé hors du fond`).toBe(true);
}
exigerLisible(libelle, 'libellé replié');
});
test('un mot seul plus long que la place se coupe entre deux caractères, sans sortir de la pastille ni du conteneur', () => {
const mot = 'Apiculteursintercommunauxdulittoral';
const { racine, libelle } = pastille({ sorte: 'appartenance', valeur: 3, libelle: mot });
const conteneur = racine.parentElement;
conteneur.style.width = '120px';
const droite = conteneur.getBoundingClientRect().right - parseFloat(style(conteneur).paddingRight);
expect(racine.textContent.trim()).toBe(mot);
expect(bordDroitDuTexte(libelle)).toBeLessThanOrEqual(racine.getBoundingClientRect().right + 0.5);
expect(racine.getBoundingClientRect().right).toBeLessThanOrEqual(droite + 0.5);
});
test('dans la cellule d’un tableau de pleine largeur, un libellé long n’élargit pas le tableau', () => {
const cible = document.createElement('div');
cible.style.width = '300px';
cible.style.backgroundColor = 'var(--couleur-page-fond)';
const tableau = document.createElement('table');
tableau.style.width = '100%';
const cellule = tableau.insertRow().insertCell();
cible.append(tableau);
document.body.prepend(cible);
const composant = mount(Pastille, { target: cellule, props: { sorte: 'appartenance', valeur: 2, libelle: LONG }, context: new Map([['t', t]]) });
flushSync();
montes.push({ composant, cible });
const racine = cellule.firstElementChild;
expect(tableau.getBoundingClientRect().width).toBeLessThanOrEqual(300);
expect(racine.getBoundingClientRect().right).toBeLessThanOrEqual(cellule.getBoundingClientRect().right + 0.5);
});
test('un libellé court se tient sur une ligne, sans rognure ; une sorte ou une valeur hors contrat lève au montage', () => {
const { racine, libelle } = pastille({ sorte: 'etat', valeur: 'propose', libelle: 'Proposé' });
const plage = document.createRange();
plage.selectNodeContents(libelle);
expect(plage.getClientRects().length).toBe(1);
expect(racine.scrollWidth).toBeLessThanOrEqual(racine.clientWidth + 1);
expect(() => monter(Pastille, { sorte: 'etat', valeur: 'archive', libelle: 'x' })).toThrow(RangeError);
expect(() => monter(Pastille, { sorte: 'appartenance', valeur: 9, libelle: 'x' })).toThrow(RangeError);
});
test('dans une rangée flexible trop étroite, un libellé court garde sa ligne : la pastille ne rétrécit pas sous son mot', () => {
for (const largeur of [300, 200, 140]) {
const cible = document.createElement('div');
cible.style.cssText = `display:flex;gap:8px;width:${largeur}px`;
const voisin = document.createElement('button');
voisin.style.whiteSpace = 'nowrap';
voisin.textContent = 'Ouvrir les tables et les sièges';
document.body.prepend(cible);
const composant = mount(Pastille, { target: cible, props: { sorte: 'etat', valeur: 'brouillon', libelle: 'Brouillon' }, context: new Map([['t', t]]) });
cible.append(voisin);
flushSync();
montes.push({ composant, cible });
const plage = document.createRange();
plage.selectNodeContents(cible.querySelector('.libelle'));
expect(plage.getClientRects().length, `${largeur} px`).toBe(1);
}
});
test('une pastille de statut sans signe, repliée, garde son texte sur le fond aux coins de gauche', () => {
const { racine, libelle } = pastille({ sorte: 'statut', valeur: 'neutre', libelle: LONG });
racine.parentElement.style.width = '120px';
const plage = document.createRange();
plage.selectNodeContents(libelle);
const lignes = [...plage.getClientRects()];
expect(lignes.length).toBeGreaterThan(1);
const boite = racine.getBoundingClientRect();
const rayon = parseFloat(style(racine).borderTopLeftRadius);
expect(surLeFond(boite, rayon, lignes[0].left, lignes[0].top), 'haut gauche').toBe(true);
expect(surLeFond(boite, rayon, lignes.at(-1).left, lignes.at(-1).bottom), 'bas gauche').toBe(true);
});
});
describe('carte', () => {
test('titrée : une région nommée par son titre, au niveau donné, ses actions dans l’en-tête, son contenu dessous, son sous-titre', () => {
const cible = monter(Carte, {
titre: 'Participants',
sousTitre: '13 personnes',
niveau: 3,
actions: snippet('<button type="button">Ajouter</button>'),
children: snippet('<p>Contenu de la carte</p>'),
});
const region = cible.querySelector('section');
expect(region).not.toBeNull();
const titre = region.querySelector('h3');
expect(titre.textContent.trim()).toBe('Participants');
expect(region.getAttribute('aria-labelledby')).toBe(titre.id);
expect(document.getElementById(region.getAttribute('aria-labelledby'))).toBe(titre);
const action = region.querySelector('button');
const contenu = [...region.querySelectorAll('p')].find((p) => p.textContent === 'Contenu de la carte');
const sousTitre = [...region.querySelectorAll('p')].find((p) => p.textContent === '13 personnes');
expect(action.getBoundingClientRect().top).toBeLessThan(contenu.getBoundingClientRect().top);
expect(sousTitre.getBoundingClientRect().top).toBeGreaterThan(titre.getBoundingClientRect().top);
expect(sousTitre.getBoundingClientRect().bottom).toBeLessThanOrEqual(contenu.getBoundingClientRect().top);
expect(titre.compareDocumentPosition(contenu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('l’habillage : fond de la carte, filet, rayon de 12 px, ombre ; titre et sous-titre lisibles, dans les deux thèmes', async () => {
const cible = monter(Carte, { titre: 'Dossier de travail', sousTitre: 'Le dossier des événements', children: snippet('<p>Texte</p>') });
const region = cible.querySelector('section');
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(region), theme).toEqual(jeton('--couleur-carte-fond'));
expect(composantes(style(region).borderTopColor), theme).toEqual(jeton('--couleur-carte-filet'));
expect(style(region).borderTopWidth, theme).toBe('1px');
expect(style(region).borderTopLeftRadius, theme).toBe('12px');
expect(style(region).boxShadow, theme).not.toBe('none');
for (const texte of region.querySelectorAll('h2, p')) exigerLisible(texte, `${theme} ${texte.textContent}`);
const sousTitre = [...region.querySelectorAll('p')].find((p) => p.textContent === 'Le dossier des événements');
expect(composantes(style(sousTitre).color), theme).toEqual(jeton('--couleur-page-texte-secondaire'));
});
}
});
test('pleine, son contenu touche ses bords ; sinon il s’en écarte ; sans titre, une boîte sans nom ni titre ; une icône se dessine dans le titre', () => {
const contenu = (props) => {
const cible = monter(Carte, { ...props, children: snippet('<p>Bord</p>') });
const p = [...cible.querySelectorAll('p')].find((x) => x.textContent === 'Bord');
return { cible, gauche: p.getBoundingClientRect().left - cible.firstElementChild.getBoundingClientRect().left };
};
const pleine = contenu({ titre: 'Tableau', plein: true });
const ecartee = contenu({ titre: 'Tableau' });
expect(pleine.gauche).toBeLessThanOrEqual(1);
expect(ecartee.gauche).toBeGreaterThanOrEqual(12);
const sans = contenu({});
expect(sans.cible.querySelector('section, h1, h2, h3, h4')).toBeNull();
const avecIcone = monter(Carte, { titre: 'Démonstrations', icone: 'demarrer', children: snippet('<p>x</p>') });
const svg = avecIcone.querySelector('h2 svg');
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(avecIcone.querySelector('h2').textContent.trim()).toBe('Démonstrations');
});
});
describe('commande : la variante', () => {
const contexte = () => new Map([['t', t], ['horloge', creerHorlogePilotee()]]);
const commande = (props) => monter(Commande, { cle: 'communs.commande.modifier', executer: () => undefined, ...props }, contexte()).querySelector('button');
test('principale : pleine de la teinte de l’action, son texte, 44 px de haut au moins ; secondaire, la défaut : fond de la carte et filet ; discrète : ni fond ni filet visibles', async () => {
const principale = commande({ variante: 'principale' });
const secondaire = commande({});
const discrete = commande({ variante: 'discrete' });
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(principale), theme).toEqual(jeton('--couleur-action-fond'));
expect(composantes(style(principale).color), theme).toEqual(jeton('--couleur-action-texte'));
expect(composantes(style(principale).borderTopColor), theme).toEqual(jeton('--couleur-action-fond'));
expect(principale.getBoundingClientRect().height, theme).toBeGreaterThanOrEqual(44);
expect(fond(secondaire), theme).toEqual(jeton('--couleur-carte-fond'));
expect(composantes(style(secondaire).borderTopColor), theme).toEqual(jeton('--couleur-page-filet'));
expect(composantes(style(secondaire).color), theme).toEqual(jeton('--couleur-page-texte'));
expect(fond(discrete), theme).toEqual(TRANSPARENT);
expect(composantes(style(discrete).borderTopColor)[3], theme).toBe(0);
for (const [nom, bouton] of [['principale', principale], ['secondaire', secondaire], ['discrete', discrete]]) {
exigerLisible(bouton, `${theme} ${nom}`);
}
});
}
});
test('la variante ne change ni le nom accessible, ni la désignation, ni la hauteur d’une secondaire d’aujourd’hui ; une variante inconnue lève au montage', () => {
const principale = commande({ variante: 'principale' });
const secondaire = commande({});
for (const bouton of [principale, secondaire]) {
expect(bouton.textContent).toBe(t('communs.commande.modifier'));
expect(bouton.getAttribute('data-commande')).toBe('modifier');
expect(bouton.getAttribute('type')).toBe('button');
}
// Les mesures de la boîte d'une secondaire restent celles d'avant la
// variante : la cible ne rapetisse pas.
expect([style(secondaire).paddingTop, style(secondaire).paddingBottom, style(secondaire).borderTopWidth]).toEqual(['4px', '4px', '1px']);
expect(style(secondaire).fontSize).toBe('16px');
expect(() => commande({ variante: 'primaire' })).toThrow(RangeError);
});
});
describe('refus, infobulle, champ et fenêtre à confirmer : habillés des jetons', () => {
test('le refus : son fond et son filet, son trait de gauche, son icône, son texte lisible, dans les deux thèmes', async () => {
const cible = monter(Refus, { refus: { code: 'MODE_LECTURE', details: { remede: null } } }, new Map([['t', t], ['seance', null]]));
const refus = cible.querySelector('[role="status"]');
const texte = refus.querySelector('p');
const icone = refus.querySelector('svg');
expect(icone.getAttribute('aria-hidden')).toBe('true');
expect(texte.textContent.trim()).toBe(t('MODE_LECTURE', { remede: null }));
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(refus), theme).toEqual(jeton('--couleur-refus-fond'));
expect(composantes(style(refus).borderLeftColor), theme).toEqual(jeton('--couleur-refus'));
expect(composantes(style(refus).borderTopColor), theme).toEqual(jeton('--couleur-refus-filet'));
expect(composantes(style(icone.querySelector('path')).stroke), theme).toEqual(jeton('--couleur-refus-marque'));
exigerSigneVisible(icone, theme);
exigerLisible(texte, theme);
});
}
});
test('l’infobulle : le fond et le filet de la carte, son texte lisible', async () => {
const ancre = document.createElement('span');
ancre.textContent = 'terme';
Object.assign(ancre.style, { position: 'fixed', left: '40px', top: '40px' });
document.body.append(ancre);
try {
const cible = monter(Infobulle, { id: 'infobulle-habillage', ouverte: { ancre, cle: 'propositions.explication.rencontresTous', details: {} }, fermer: () => undefined });
flushSync();
const bulle = cible.querySelector('[role="tooltip"]');
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(bulle), theme).toEqual(jeton('--couleur-carte-fond'));
expect(composantes(style(bulle).borderTopColor), theme).toEqual(jeton('--couleur-carte-filet'));
exigerLisible(bulle, theme);
});
}
} finally {
ancre.remove();
}
});
test('le champ : l’entrée sur le fond de la carte, au filet de la page, arrondie ; la fenêtre à confirmer sur le fond de la carte', async () => {
const champ = monter(
Champ,
{ cle: 'nom', etiquette: 'communs.commande.modifier', valeur: 'Ombrelle', lire: (texte) => ({ valeur: texte }), executer: () => undefined },
new Map([['t', t], ['seance', null]]),
);
const entree = champ.querySelector('input');
const fenetre = monter(
Confirmation,
{ titre: 'communs.remede.supprimerParticipant', elements: ['Iris Ombrelle'], surConfirmer: () => undefined, surAnnuler: () => undefined },
new Map([['t', t], ['seance', null], ['horloge', creerHorlogePilotee()]]),
).querySelector('dialog');
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(entree), theme).toEqual(jeton('--couleur-carte-fond'));
expect(composantes(style(entree).borderTopColor), theme).toEqual(jeton('--couleur-page-filet'));
expect(style(entree).borderTopLeftRadius, theme).toBe('8px');
expect(fond(fenetre), theme).toEqual(jeton('--couleur-carte-fond'));
});
}
fenetre.close();
});
});

View file

@ -0,0 +1,63 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les icônes des primitives : des tracés SVG au trait, dans un carré de 24
// unités, que Icone.svelte dessine sans remplissage, du trait de la couleur
// courante (currentColor) — la couleur vient du jeton que lit le parent.
// Aucune ne porte de texte ni de couleur. Une icône est un signe redondant :
// un libellé l'accompagne toujours, et le dessin est caché aux technologies
// d'assistance.
//
// Un point se trace comme un cercle de rayon 1,5, que l'épaisseur du trait
// remplit : deux arcs qui se referment.
// Un cercle de centre (x, y) et de rayon r, en deux demi-arcs.
const cercle = (x, y, r) => `M${x + r} ${y}a${r} ${r} 0 1 1-${2 * r} 0a${r} ${r} 0 1 1 ${2 * r} 0`;
const DESSINS = [
// Les quatre états du plan (§ 9) : brouillon, proposé, retenu, bloqué.
['crayon', ['M4 20h4L19 9l-4-4L4 16z', 'M14 6l4 4']],
['listePoints', ['M9 6h11M9 12h11M9 18h11', 'M4 6h.01M4 12h.01M4 18h.01']],
['coche', ['M4 12l5 5L20 6']],
['cadenas', ['M6 11h12v9H6z', 'M8 11V8a4 4 0 0 1 8 0v3']],
// Les commandes et les repères des écrans.
['liste', ['M4 6h16M4 12h16M4 18h16']],
['importer', ['M12 3v12', 'M7 10l5 5 5-5', 'M4 21h16']],
['exporter', ['M12 15V3', 'M7 8l5-5 5 5', 'M4 21h16']],
['ajouter', ['M12 5v14', 'M5 12h14']],
['chercher', [cercle(11, 11, 7), 'M20 20l-4-4']],
['calendrier', ['M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z', 'M3 10h18', 'M8 3v4', 'M16 3v4']],
// Les messages : information, avis, refus.
['information', [cercle(12, 12, 9), 'M12 11v5', 'M12 8h.01']],
['triangle', ['M12 3l10 18H2z', 'M12 10v5', 'M12 18h.01']],
['interdit', [cercle(12, 12, 9), 'M5.6 5.6l12.8 12.8']],
['dossier', ['M3 6h7l2 2h9v11H3z']],
// Les modes : lecture, écriture.
['oeil', ['M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z', cercle(12, 12, 3)]],
['stylo', ['M4 20h4L19 9l-4-4L4 16z']],
['defaire', ['M9 14L4 9l5-5', 'M4 9h11a5 5 0 0 1 0 10h-3']],
['imprimer', ['M6 9V3h12v6', 'M5 9h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z', 'M7 14h10v7H7z']],
['etoile', ['M12 3l2.6 5.6 6.1.7-4.5 4.2 1.2 6L12 16.6 6.6 19.5l1.2-6L3.3 9.3l6.1-.7z']],
['croix', ['M6 6l12 12M18 6L6 18']],
['chevron', ['M9 6l6 6-6 6']],
['points', [cercle(5, 12, 1.5), cercle(12, 12, 1.5), cercle(19, 12, 1.5)]],
['demarrer', ['M8 5l11 7-11 7z']],
];
/** Les noms des icônes, dans l'ordre du module. */
export const NOMS_ICONES = Object.freeze(DESSINS.map(([nom]) => nom));
/** Chaque icône par son nom : la liste figée de ses tracés. */
export const ICONES = Object.freeze(Object.fromEntries(DESSINS.map(([nom, traces]) => [nom, Object.freeze(traces)])));
/**
* Les tracés d'une icône.
*
* @param {string} nom
* @returns {readonly string[]}
* @throws {RangeError} un nom hors de NOMS_ICONES
*/
export function traceIcone(nom) {
if (!NOMS_ICONES.includes(nom)) throw new RangeError(`icône inconnue : ${JSON.stringify(nom)}`);
return ICONES[nom];
}

View file

@ -0,0 +1,55 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le module des icônes des primitives : chaque icône est une liste de tracés
// SVG au trait, dans un carré de 24 unités, sans couleur ni texte ; une icône
// inconnue lève plutôt que de dessiner un vide.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { ICONES, NOMS_ICONES, traceIcone } from './icones.js';
// Les commandes d'un tracé SVG, leurs nombres, leurs séparateurs : rien
// d'autre.
const TRACE = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s-]+$/;
describe('icônes des primitives', () => {
test('les icônes du contrat, dans l’ordre, chacune une liste figée de tracés non vides', () => {
assert.deepEqual(NOMS_ICONES, [
'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher',
'calendrier', 'information', 'triangle', 'interdit', 'dossier', 'oeil', 'stylo', 'defaire', 'imprimer',
'etoile', 'croix', 'chevron', 'points', 'demarrer',
]);
assert.ok(Object.isFrozen(ICONES) && Object.isFrozen(NOMS_ICONES));
assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort());
for (const nom of NOMS_ICONES) {
const traces = ICONES[nom];
assert.ok(Array.isArray(traces) && traces.length > 0 && Object.isFrozen(traces), nom);
for (const trace of traces) {
assert.match(trace, TRACE, nom);
assert.match(trace, /^M/, `${nom} : un tracé commence par M`);
}
}
});
test('chaque tracé reste dans le carré de 24 unités : ses coordonnées absolues entre 0 et 24', () => {
for (const nom of NOMS_ICONES) {
for (const trace of ICONES[nom]) {
// Les nombres qui suivent une commande absolue M, L, H, V, C, S, Q, T :
// coordonnées ; ceux des arcs (A, a) et des commandes relatives sont des
// rayons, des drapeaux ou des écarts, et ne se bornent pas ainsi.
for (const [, commande, nombres] of trace.matchAll(/([MLHVCSQT])([^A-Za-z]*)/g)) {
for (const nombre of nombres.match(/-?\d*\.?\d+/g) ?? []) {
const valeur = Number(nombre);
assert.ok(valeur >= 0 && valeur <= 24, `${nom} : ${commande}${nombres.trim()} sort du carré`);
}
}
}
}
});
test('traceIcone rend les tracés d’une icône ; un nom inconnu lève RangeError en le nommant', () => {
assert.equal(traceIcone('coche'), ICONES.coche);
assert.throws(() => traceIcone('licorne'), (erreur) => erreur instanceof RangeError && /licorne/.test(erreur.message));
assert.throws(() => traceIcone('toString'), RangeError);
});
});

View file

@ -0,0 +1,78 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle d'une pastille, pur : Pastille.svelte le câble. Une pastille est
// un fond teinté, un signe — une icône ou un point — de la marque de sa
// teinte, et un libellé de l'encre de la page (§ 12.7) : la couleur redit le
// libellé, elle ne le remplace jamais.
//
// La teinte nomme une famille de jetons de jetons.css : --couleur-<teinte>-fond
// et --couleur-<teinte>-marque, que SIGNES (marques.js) valide sur chaque
// fond où le signe se pose.
//
// sorte 'etat' valeur : un état du plan (§ 9), son icône propre ;
// sorte 'appartenance' valeur : un rang de 1 à RANGS_APPARTENANCE, ou null
// pour une appartenance au-delà, neutre ; un point ;
// sorte 'statut' valeur : un ton, sans signe par défaut.
//
// Une icône donnée remplace le signe par défaut.
import { RANGS_APPARTENANCE } from '../appartenances.js';
import { NOMS_ICONES } from './icones.js';
/** Les sortes de pastille. */
export const SORTES_PASTILLE = Object.freeze(['etat', 'appartenance', 'statut']);
/** Les états du plan, dans l'ordre du § 9, et l'icône de chacun. */
const ICONES_ETAT = new Map([
['brouillon', 'crayon'],
['propose', 'listePoints'],
['retenu', 'coche'],
['bloque', 'cadenas'],
]);
export const ETATS_PASTILLE = Object.freeze([...ICONES_ETAT.keys()]);
/** Les tons d'un statut, et la teinte de chacun : le neutre, ceux des
* messages, et celui de l'état retenu. */
const TEINTES_TON = new Map([
['neutre', 'neutre'],
['information', 'information'],
['avis', 'avis'],
['refus', 'refus'],
['retenu', 'etat-retenu'],
]);
export const TONS_PASTILLE = Object.freeze([...TEINTES_TON.keys()]);
const nommer = (valeur) => JSON.stringify(valeur) ?? String(valeur);
/**
* Le modèle d'une pastille.
*
* @param {'etat'|'appartenance'|'statut'} sorte
* @param {string|number|null} valeur selon la sorte : un état, un rang ou
* null, un ton
* @param {string|null} [icone] une icône de NOMS_ICONES, qui remplace le
* signe par défaut
* @returns {{teinte: string, icone: string|null, point: boolean}} la
* famille de jetons, l'icône dessinée ou null, et vrai quand le signe est
* un point
* @throws {RangeError} une sorte, une valeur ou une icône hors contrat
*/
export function modelePastille(sorte, valeur, icone = null) {
if (icone !== null && !NOMS_ICONES.includes(icone)) throw new RangeError(`pastille : icône inconnue ${nommer(icone)}`);
let defaut;
if (sorte === 'etat') {
if (!ICONES_ETAT.has(valeur)) throw new RangeError(`pastille : état inconnu ${nommer(valeur)}`);
defaut = { teinte: `etat-${valeur}`, icone: ICONES_ETAT.get(valeur), point: false };
} else if (sorte === 'appartenance') {
const rang = valeur === null || (Number.isInteger(valeur) && valeur >= 1 && valeur <= RANGS_APPARTENANCE);
if (!rang) throw new RangeError(`pastille : rang d'appartenance hors de 1 à ${RANGS_APPARTENANCE}, ou null : ${nommer(valeur)}`);
defaut = { teinte: valeur === null ? 'neutre' : `appartenance-${valeur}`, icone: null, point: true };
} else if (sorte === 'statut') {
if (!TEINTES_TON.has(valeur)) throw new RangeError(`pastille : ton inconnu ${nommer(valeur)}`);
defaut = { teinte: TEINTES_TON.get(valeur), icone: null, point: false };
} else {
throw new RangeError(`pastille : sorte inconnue ${nommer(sorte)}`);
}
return icone === null ? defaut : { teinte: defaut.teinte, icone, point: false };
}

View file

@ -0,0 +1,74 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle d'une pastille : sa sorte, sa valeur, la teinte qu'elles
// désignent, son signe — icône ou point — et ce qui lève. Pastille.svelte le
// câble ; la teinte nomme une famille de jetons, dont le registre des signes
// (marques.js) valide la marque sur le fond — marques.test.js vérifie que
// chaque jeton du registre est déclaré dans les deux thèmes.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { RANGS_APPARTENANCE } from '../appartenances.js';
import { SIGNES } from '../marques.js';
import { NOMS_ICONES } from './icones.js';
import { ETATS_PASTILLE, SORTES_PASTILLE, TONS_PASTILLE, modelePastille } from './pastille.js';
describe('modelePastille', () => {
test('trois sortes ; les quatre états du plan dans l’ordre du § 9 ; cinq tons de statut', () => {
assert.deepEqual(SORTES_PASTILLE, ['etat', 'appartenance', 'statut']);
assert.deepEqual(ETATS_PASTILLE, ['brouillon', 'propose', 'retenu', 'bloque']);
assert.deepEqual(TONS_PASTILLE, ['neutre', 'information', 'avis', 'refus', 'retenu']);
assert.ok([SORTES_PASTILLE, ETATS_PASTILLE, TONS_PASTILLE].every(Object.isFrozen));
});
test('un état : sa teinte, et son icône propre, distincte d’un état à l’autre', () => {
const modeles = ETATS_PASTILLE.map((etat) => modelePastille('etat', etat));
assert.deepEqual(modeles.map((m) => m.teinte), ['etat-brouillon', 'etat-propose', 'etat-retenu', 'etat-bloque']);
assert.deepEqual(modeles.map((m) => m.icone), ['crayon', 'listePoints', 'coche', 'cadenas']);
assert.ok(modeles.every((m) => m.point === false));
});
test('une appartenance : le rang 1 à 6 donne sa teinte, null la neutre ; un point, sans icône', () => {
for (let rang = 1; rang <= RANGS_APPARTENANCE; rang += 1) {
assert.deepEqual(modelePastille('appartenance', rang), { teinte: `appartenance-${rang}`, icone: null, point: true });
}
assert.deepEqual(modelePastille('appartenance', null), { teinte: 'neutre', icone: null, point: true });
});
test('un statut : la teinte de son ton, sans signe par défaut', () => {
assert.deepEqual(TONS_PASTILLE.map((ton) => modelePastille('statut', ton).teinte), ['neutre', 'information', 'avis', 'refus', 'etat-retenu']);
assert.ok(TONS_PASTILLE.every((ton) => modelePastille('statut', ton).icone === null && !modelePastille('statut', ton).point));
});
test('l’icône donnée remplace le signe par défaut, point compris', () => {
assert.deepEqual(modelePastille('statut', 'refus', 'croix'), { teinte: 'refus', icone: 'croix', point: false });
assert.deepEqual(modelePastille('appartenance', 2, 'etoile'), { teinte: 'appartenance-2', icone: 'etoile', point: false });
assert.equal(modelePastille('etat', 'retenu', 'etoile').icone, 'etoile');
});
test('chaque teinte a son signe au registre : sa marque mesurée sur son fond', () => {
const teintes = new Set([
...ETATS_PASTILLE.map((e) => modelePastille('etat', e).teinte),
...Array.from({ length: RANGS_APPARTENANCE }, (_, i) => modelePastille('appartenance', i + 1).teinte),
...TONS_PASTILLE.map((t) => modelePastille('statut', t).teinte),
]);
assert.equal(teintes.size, 4 + RANGS_APPARTENANCE + 4);
for (const teinte of teintes) {
const signe = SIGNES.find((s) => s.jeton === `--couleur-${teinte}-marque`);
assert.notEqual(signe, undefined, teinte);
assert.ok(signe.fonds.includes(`--couleur-${teinte}-fond`), teinte);
}
});
test('une sorte, une valeur ou une icône hors contrat lève RangeError en la nommant', () => {
const leve = (fn, mot) => assert.throws(fn, (e) => e instanceof RangeError && e.message.includes(mot));
leve(() => modelePastille('couleur', 'brouillon'), 'couleur');
leve(() => modelePastille('etat', 'archive'), 'archive');
leve(() => modelePastille('appartenance', 7), '7');
leve(() => modelePastille('appartenance', 0), '0');
leve(() => modelePastille('appartenance', '1'), '"1"');
leve(() => modelePastille('statut', 'retenue'), 'retenue');
leve(() => modelePastille('etat', 'retenu', 'licorne'), 'licorne');
assert.ok(NOMS_ICONES.includes('croix'));
});
});