[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:
parent
73d08294f5
commit
4d34039f47
25 changed files with 2031 additions and 73 deletions
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
48
src/interface/appartenances.js
Normal file
48
src/interface/appartenances.js
Normal 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]));
|
||||
}
|
||||
73
src/interface/appartenances.test.js
Normal file
73
src/interface/appartenances.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
127
src/interface/primitives/Carte.svelte
Normal file
127
src/interface/primitives/Carte.svelte
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
40
src/interface/primitives/Icone.svelte
Normal file
40
src/interface/primitives/Icone.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
220
src/interface/primitives/Pastille.svelte
Normal file
220
src/interface/primitives/Pastille.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
483
src/interface/primitives/habillage.navigateur.test.js
Normal file
483
src/interface/primitives/habillage.navigateur.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
63
src/interface/primitives/icones.js
Normal file
63
src/interface/primitives/icones.js
Normal 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];
|
||||
}
|
||||
55
src/interface/primitives/icones.test.js
Normal file
55
src/interface/primitives/icones.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
78
src/interface/primitives/pastille.js
Normal file
78
src/interface/primitives/pastille.js
Normal 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 };
|
||||
}
|
||||
74
src/interface/primitives/pastille.test.js
Normal file
74
src/interface/primitives/pastille.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue