From 4d34039f47c2228f517c6c867c38bec7249cd5fd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 22:59:51 -0400 Subject: [PATCH] [ADD] interface: visual foundations, pills, cards, icons, navy header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/interface/App.navigateur.test.js | 139 +++++ src/interface/App.svelte | 43 ++ src/interface/Bandeau.svelte | 101 +++- src/interface/appartenances.js | 48 ++ src/interface/appartenances.test.js | 73 +++ src/interface/jetons.css | 170 +++++- src/interface/marques.js | 88 +++- src/interface/marques.test.js | 104 +++- src/interface/palette.js | 35 +- src/interface/palette.test.js | 36 ++ src/interface/primitives/Carte.svelte | 127 +++++ src/interface/primitives/Champ.svelte | 4 +- src/interface/primitives/Commande.svelte | 51 +- src/interface/primitives/Confirmation.svelte | 12 +- src/interface/primitives/Icone.svelte | 40 ++ src/interface/primitives/Infobulle.svelte | 10 +- src/interface/primitives/Pastille.svelte | 220 ++++++++ src/interface/primitives/Refus.svelte | 15 +- src/interface/primitives/commande.js | 19 + src/interface/primitives/commande.test.js | 16 + .../primitives/habillage.navigateur.test.js | 483 ++++++++++++++++++ src/interface/primitives/icones.js | 63 +++ src/interface/primitives/icones.test.js | 55 ++ src/interface/primitives/pastille.js | 78 +++ src/interface/primitives/pastille.test.js | 74 +++ 25 files changed, 2031 insertions(+), 73 deletions(-) create mode 100644 src/interface/appartenances.js create mode 100644 src/interface/appartenances.test.js create mode 100644 src/interface/primitives/Carte.svelte create mode 100644 src/interface/primitives/Icone.svelte create mode 100644 src/interface/primitives/Pastille.svelte create mode 100644 src/interface/primitives/habillage.navigateur.test.js create mode 100644 src/interface/primitives/icones.js create mode 100644 src/interface/primitives/icones.test.js create mode 100644 src/interface/primitives/pastille.js create mode 100644 src/interface/primitives/pastille.test.js diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index e07e02b..b48eaf3 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -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 = [ + '

Titre témoin

', + '
En-tête
12
', + '', + '
Papier
', + ].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(); + } + }); +}); diff --git a/src/interface/App.svelte b/src/interface/App.svelte index 2a89737..31e2ecf 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -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
, 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 (§ 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); + } diff --git a/src/interface/Bandeau.svelte b/src/interface/Bandeau.svelte index a18cf01..e4e28cc 100644 --- a/src/interface/Bandeau.svelte +++ b/src/interface/Bandeau.svelte @@ -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
, non un . 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 @@
+ {t('coquille.nom')} - {version} - {provenance} + {version} + {provenance} {#if cleMode !== null} - {t(cleMode)} + {t(cleMode)} {/if} {#if cleAnnonce !== null} - {t(cleAnnonce)} + {t(cleAnnonce)} {/if}
{#if etat.evenement !== null} - + {#if refusListe !== null}
@@ -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 { diff --git a/src/interface/appartenances.js b/src/interface/appartenances.js new file mode 100644 index 0000000..03becd4 --- /dev/null +++ b/src/interface/appartenances.js @@ -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} 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])); +} diff --git a/src/interface/appartenances.test.js b/src/interface/appartenances.test.js new file mode 100644 index 0000000..80b3369 --- /dev/null +++ b/src/interface/appartenances.test.js @@ -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); + }); +}); diff --git a/src/interface/jetons.css b/src/interface/jetons.css index 9e77241..6b24fef 100644 --- a/src/interface/jetons.css +++ b/src/interface/jetons.css @@ -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; diff --git a/src/interface/marques.js b/src/interface/marques.js index 01611d1..e113ffc 100644 --- a/src/interface/marques.js +++ b/src/interface/marques.js @@ -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']), ]); diff --git a/src/interface/marques.test.js b/src/interface/marques.test.js index a982f0b..bef91ec 100644 --- a/src/interface/marques.test.js +++ b/src/interface/marques.test.js @@ -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) { diff --git a/src/interface/palette.js b/src/interface/palette.js index 1b3998a..328b774 100644 --- a/src/interface/palette.js +++ b/src/interface/palette.js @@ -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; +} diff --git a/src/interface/palette.test.js b/src/interface/palette.test.js index b57fb56..b058d77 100644 --- a/src/interface/palette.test.js +++ b/src/interface/palette.test.js @@ -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 }) diff --git a/src/interface/primitives/Carte.svelte b/src/interface/primitives/Carte.svelte new file mode 100644 index 0000000..90758ef --- /dev/null +++ b/src/interface/primitives/Carte.svelte @@ -0,0 +1,127 @@ + + + + +{#if titre !== null} +
+
+
+ + {#if icone !== null} + + {/if} + {titre} + + {#if sousTitre !== null} +

{sousTitre}

+ {/if} +
+ {#if actions !== null} +
{@render actions()}
+ {/if} +
+ {#if children !== null} +
{@render children()}
+ {/if} +
+{:else} +
+
{@render children?.()}
+
+{/if} + + diff --git a/src/interface/primitives/Champ.svelte b/src/interface/primitives/Champ.svelte index 5602372..f66d5cf 100644 --- a/src/interface/primitives/Champ.svelte +++ b/src/interface/primitives/Champ.svelte @@ -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); diff --git a/src/interface/primitives/Commande.svelte b/src/interface/primitives/Commande.svelte index 57f7a9a..bbc394b 100644 --- a/src/interface/primitives/Commande.svelte +++ b/src/interface/primitives/Commande.svelte @@ -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 @@