+
diff --git a/src/interface/generation/Generation.navigateur.test.js b/src/interface/generation/Generation.navigateur.test.js
index 28398b5..dc2d453 100644
--- a/src/interface/generation/Generation.navigateur.test.js
+++ b/src/interface/generation/Generation.navigateur.test.js
@@ -35,8 +35,10 @@ import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
+import { couleurDeJeton } from '../../../test/navigateur/captures.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
+import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Travailleur from './travailleur.js?worker&inline';
@@ -942,3 +944,47 @@ describe('la durée du calcul (§ 5.10, § 19.4)', () => {
await attendre(() => expect(enCours(m)).toBe(false));
});
});
+
+// --- L'habillage : réglages en carte, action principale -------------------------
+
+// La carte qui porte un élément : le plus proche ancêtre peint du fond de la
+// carte, au rayon de 12 px ; null sans elle.
+function carteDe(element) {
+ const fond = couleurDeJeton('--couleur-carte-fond').join();
+ for (let n = element.parentElement; n !== null; n = n.parentElement) {
+ const style = getComputedStyle(n);
+ if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
+ }
+ return null;
+}
+
+describe('l’habillage de la génération', () => {
+ test('les réglages et « Générer » dans une même carte de la section ; la meilleure proposition dans la sienne, au-dessus', async () => {
+ const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
+ const reglages = section(m).querySelector('[data-reglages]');
+ const carte = carteDe(reglages);
+ expect(carte, 'carte des réglages').not.toBeNull();
+ expect(section(m).contains(carte)).toBe(true);
+ expect(carte.contains(generer(m))).toBe(true);
+ const meilleure = section(m).querySelector('[data-meilleure]');
+ const style = getComputedStyle(meilleure);
+ expect(composantes(style.backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond').join());
+ expect(style.borderTopLeftRadius).toBe('12px');
+ expect(carte.contains(meilleure)).toBe(false);
+ expect(meilleure.getBoundingClientRect().bottom).toBeLessThanOrEqual(carte.getBoundingClientRect().top);
+ });
+
+ test('« Générer » est l’action principale : pleine de la teinte de l’action, son texte à 4,5:1, haute de 44 px, dans les deux thèmes', async () => {
+ const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
+ for (const theme of ['clair', 'sombre']) {
+ await sousTheme(theme, async () => {
+ flushSync();
+ const style = getComputedStyle(generer(m));
+ const fond = composantes(style.backgroundColor);
+ expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
+ expect(contraste(composantes(style.color), fond), theme).toBeGreaterThanOrEqual(4.5);
+ expect(generer(m).getBoundingClientRect().height, theme).toBeGreaterThanOrEqual(44);
+ });
+ }
+ });
+});
diff --git a/src/interface/generation/Reglages.navigateur.test.js b/src/interface/generation/Reglages.navigateur.test.js
index 7a1a197..9df02c9 100644
--- a/src/interface/generation/Reglages.navigateur.test.js
+++ b/src/interface/generation/Reglages.navigateur.test.js
@@ -24,6 +24,8 @@ import { creerSeance } from '../../application/seance.js';
import { creerTraduction } from '../../application/traduction.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
+import { couleurDeJeton } from '../../../test/navigateur/captures.js';
+import { composantes, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Reglages from './Reglages.svelte';
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
@@ -162,3 +164,23 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
expect(designations(cible)).toEqual(['graine', 'generation.arret', 'nouvelleTable', 'coeursAutomatiques']);
});
});
+
+describe('l’habillage des réglages', () => {
+ test('chaque champ de saisie sur le fond de la carte, au filet de contrôle, dans les deux thèmes ; chaque groupe sous un intertitre en petites capitales', async () => {
+ const cible = await monter();
+ const champs = [...cible.querySelectorAll('input[type="text"]')];
+ expect(champs.length).toBeGreaterThan(0);
+ for (const theme of ['clair', 'sombre']) {
+ await sousTheme(theme, async () => {
+ for (const champ of champs) {
+ const style = getComputedStyle(champ);
+ expect(composantes(style.backgroundColor).join(), `${theme} ${champ.dataset.champ}`).toBe(couleurDeJeton('--couleur-carte-fond').join());
+ expect(composantes(style.borderTopColor).join(), `${theme} ${champ.dataset.champ}`).toBe(couleurDeJeton('--couleur-page-filet').join());
+ }
+ });
+ }
+ const legendes = [...cible.querySelectorAll('legend')];
+ expect(legendes.length).toBe(2);
+ for (const legende of legendes) expect(getComputedStyle(legende).textTransform).toBe('uppercase');
+ });
+});
diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte
index 35ab3ee..2f876ed 100644
--- a/src/interface/generation/Reglages.svelte
+++ b/src/interface/generation/Reglages.svelte
@@ -46,6 +46,10 @@
// posé, « Générer » ne part pas. Échap rend le dernier nombre accepté ; les
// deux touches sont celles du contexte 'champ' de la table des raccourcis.
// La valeur automatique du poste s'affiche à côté, dans les deux états.
+ //
+ // Habillage : la graine et les cœurs se saisissent comme un Champ, sur le
+ // fond de la carte au filet de contrôle ; les contraintes et les cœurs sont
+ // deux groupes sous un intertitre en petites capitales.
import { getContext, untrack } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
@@ -224,10 +228,10 @@
{t('generation.coeurs.note')}
{#if mesure === null}
-
{t('generation.duree.inconnue')}
+
{t('generation.duree.inconnue')}
{:else}
-
{t('generation.duree.observee', mesure)}
-
{t('generation.duree.estimee', estimation)}
+
{t('generation.duree.observee', mesure)}
+
{t('generation.duree.estimee', estimation)}
{/if}
@@ -235,7 +239,7 @@
.reglages {
display: flex;
flex-direction: column;
- gap: var(--espace-3);
+ gap: var(--espace-4);
}
.champs {
@@ -255,8 +259,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);
@@ -271,30 +275,37 @@
border-color: var(--couleur-refus);
}
+ /* Les deux groupes : un filet de carte, l'intertitre en petites capitales
+ d'encre secondaire. */
+ .coeurs,
+ .bascules {
+ margin: 0;
+ padding: var(--espace-3) var(--espace-4);
+ border: 1px solid var(--couleur-carte-filet);
+ border-radius: var(--rayon-2);
+ }
+
.coeurs {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
- margin: 0;
- padding: var(--espace-2) var(--espace-3);
- border: 1px solid var(--couleur-page-filet);
- border-radius: var(--rayon-1);
}
.bascules {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
- gap: var(--espace-2) var(--espace-4);
- margin: 0;
- padding: var(--espace-2) var(--espace-3);
- border: 1px solid var(--couleur-page-filet);
- border-radius: var(--rayon-1);
+ gap: var(--espace-2) var(--espace-5);
}
legend {
padding: 0 var(--espace-1);
+ color: var(--couleur-page-texte-secondaire);
+ font-size: var(--taille-intertitre);
+ font-weight: bold;
+ letter-spacing: var(--interlettre-capitales);
+ text-transform: uppercase;
}
.bascule {
@@ -311,4 +322,8 @@
p {
margin: 0;
}
+
+ .note {
+ color: var(--couleur-page-texte-secondaire);
+ }
diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte
index 52e34d0..e69fc4b 100644
--- a/src/interface/generation/SectionGeneration.svelte
+++ b/src/interface/generation/SectionGeneration.svelte
@@ -42,9 +42,15 @@
// colonne du tableau ; son survol ou son focus ouvre l'infobulle de la
// section, une seule, sur l'explication de cette colonne, et le quitter la
// referme ; le terme expliqué porte aria-describedby vers elle.
+ //
+ // Habillage : la meilleure proposition est une carte à elle, chaque valeur
+ // sous son intitulé ; les réglages, « Générer » — l'action principale de
+ // l'écran —, la question, l'avancement et l'issue vivent dans la carte des
+ // réglages, sous elle.
import { getContext, onDestroy, untrack } from 'svelte';
import { ErreurCommande } from '../../application/erreurs.js';
import { ErreurAnnulee } from '../../application/generation.js';
+ import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import { explicationDeColonne, texteCellule } from '../propositions/modele.js';
@@ -190,28 +196,32 @@
{/if}
-
-
+
{/if}
{#if aConfirmer !== null}
@@ -228,10 +239,13 @@
margin: 0;
}
+ /* La phrase de l'ordre, en tête de la carte, au retrait du contenu d'une
+ carte ; le tableau la suit, collé aux bords. */
.ordre {
display: flex;
flex-direction: column;
gap: var(--espace-1);
+ padding: var(--espace-3) var(--espace-5);
}
/* Un nom qui désigne une colonne se signale par un soulignement pointillé ;
@@ -263,4 +277,9 @@
align-items: flex-start;
gap: var(--espace-1);
}
+
+ .aide {
+ color: var(--couleur-page-texte-secondaire);
+ font-size: var(--taille-texte-petit);
+ }
diff --git a/src/interface/propositions/TableauPropositions.svelte b/src/interface/propositions/TableauPropositions.svelte
index eca1809..2a0fbea 100644
--- a/src/interface/propositions/TableauPropositions.svelte
+++ b/src/interface/propositions/TableauPropositions.svelte
@@ -28,6 +28,16 @@
// decrit('colonne:') rend l'identifiant de l'infobulle quand elle
// explique cet en-tête, pour son aria-describedby, undefined sinon.
//
+ // Deux rangées d'en-têtes : celle des groupes (GROUPES_COLONNES, modele.js),
+ // chacun en scope colgroup au-dessus de ses colonnes, une cellule vide au-
+ // dessus d'une colonne hors groupe ; puis celle des colonnes, seule à
+ // porter data-colonne et les explications. Les colonnes de nombres
+ // s'alignent à droite en chiffres tabulaires, en-tête compris. La ligne du
+ // retenu se peint de la teinte de l'état Retenu, sous l'encre de la page ;
+ // la proposition retenue porte la pastille « retenue » (Pastille,
+ // primitive). Le tableau se nomme par aria-label : la carte de la section
+ // en porte le titre visible.
+ //
// Désignations : data-retenu sur la ligne du retenu, data-proposition sur
// chaque autre ligne, data-colonne sur chaque cellule et chaque en-tête ;
// data-classement et data-hors-classement sur les deux groupes de lignes.
@@ -36,7 +46,9 @@
// 3:1 contre le fond, dans les deux thèmes. Les autres n'en portent pas.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
- import { texteCellule } from './modele.js';
+ import Icone from '../primitives/Icone.svelte';
+ import Pastille from '../primitives/Pastille.svelte';
+ import { GROUPES_COLONNES, texteCellule } from './modele.js';
let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
@@ -47,6 +59,9 @@
const t = getContext('t');
const format = getContext('format');
const outils = { t, format };
+
+ // Les noms des colonnes de nombres.
+ const nombres = $derived(new Set(vue.colonnes.filter((colonne) => colonne.nombre).map((colonne) => colonne.nom)));
{#snippet raisons(ligne)}
@@ -65,7 +80,7 @@
{#snippet mesures(ligne)}
{#if ligne.comparable}
{#each ligne.cellules as cellule (cellule.colonne)}
-
{texteCellule(cellule, outils)}
+
{texteCellule(cellule, outils)}
{/each}
{#each ligne.imposes as phrase, rang (rang)}
@@ -89,7 +104,7 @@
@@ -181,27 +210,55 @@
}
table {
+ min-width: 100%;
border-collapse: collapse;
font-size: var(--taille-texte);
}
- caption {
- padding-bottom: var(--espace-2);
- font-weight: bold;
- text-align: left;
- }
-
+ /* Les cellules : un filet léger entre deux lignes, aucun filet vertical. */
th,
td {
- padding: var(--espace-1) var(--espace-2);
- border: 1px solid var(--couleur-page-filet);
+ padding: var(--espace-2) var(--espace-3);
+ border: none;
+ border-bottom: 1px solid var(--couleur-ligne-filet);
vertical-align: top;
- text-align: left;
+ text-align: start;
+ }
+
+ /* Les en-têtes de colonne : sur le fond sourd de la carte, en gras et en
+ petit corps, sous un filet de carte. */
+ thead th,
+ thead td {
+ background-color: var(--couleur-carte-fond-sourd);
+ border-bottom-color: var(--couleur-carte-filet);
}
thead th {
min-width: 6rem;
+ font-size: var(--taille-texte-petit);
font-weight: bold;
+ vertical-align: bottom;
+ }
+
+ /* La rangée des groupes : des intertitres en petites capitales d'encre
+ secondaire, chacun soulignant l'étendue de ses colonnes. */
+ .groupes th {
+ padding-top: var(--espace-2);
+ padding-bottom: var(--espace-1);
+ color: var(--couleur-page-texte-secondaire);
+ font-size: var(--taille-intertitre);
+ letter-spacing: var(--interlettre-capitales);
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+
+ .groupes th,
+ .groupes td {
+ border-bottom: none;
+ }
+
+ .groupes th {
+ border-bottom: 2px solid var(--couleur-carte-filet);
}
.nombre {
@@ -231,23 +288,55 @@
font-style: italic;
}
- /* La ligne du retenu et la proposition retenue se distinguent par leur
- forme : texte gras, filet épais à gauche, et le mot « retenue ». */
- .retenu,
- .retenue {
- font-weight: bold;
+ /* L'en-tête du groupe hors classement : un intertitre sur le fond sourd. */
+ tbody[data-hors-classement] th[scope='rowgroup'] {
+ background-color: var(--couleur-carte-fond-sourd);
+ font-size: var(--taille-intertitre);
+ letter-spacing: var(--interlettre-capitales);
+ text-transform: uppercase;
}
+ /* La ligne du retenu : chaque cellule sur la teinte de l'état Retenu,
+ l'encre de la page dessus ; son titre en gras, précédé de la coche. */
+ .retenu > * {
+ background-color: var(--couleur-etat-retenu-fond);
+ }
+
+ .titre-retenu {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--espace-1);
+ font-weight: bold;
+ white-space: nowrap;
+ }
+
+ .signe-retenu {
+ display: inline-flex;
+ color: var(--couleur-etat-retenu-marque);
+ }
+
+ /* La ligne du retenu et la proposition retenue ouvrent sur un trait de la
+ marque de Retenu ; la ligne du retenu se nomme en mots, la proposition
+ retenue par sa pastille. */
.retenu > :first-child,
.retenue > :first-child {
- border-left: 4px solid var(--couleur-page-texte);
+ border-left: 3px solid var(--couleur-etat-retenu-marque);
+ }
+
+ td[data-colonne='proposition'] {
+ min-width: 7rem;
+ }
+
+ .identifiant {
+ margin-right: var(--espace-2);
+ font-weight: bold;
+ font-variant-numeric: tabular-nums;
}
/* La colonne que désigne la phrase de l'ordre : un filet de 2 px de la
couleur de sélection de chaque côté de son en-tête et de ses cellules,
- plus épais que le filet ordinaire, et l'en-tête souligné. Ni le fond ni
- la couleur du texte ne changent : les contrastes restent ceux du
- tableau. */
+ et l'en-tête souligné. Ni le fond ni la couleur du texte ne changent :
+ les contrastes restent ceux du tableau. */
th.surlignee,
td.surlignee {
border-left: 2px solid var(--couleur-selection);
@@ -267,13 +356,6 @@
text-underline-offset: 0.2em;
}
- .marque {
- margin-left: var(--espace-1);
- padding: 0 var(--espace-1);
- border: 1px solid var(--couleur-page-texte);
- border-radius: var(--rayon-1);
- }
-
.commandes {
display: flex;
flex-direction: column;
diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js
index 6a3050e..8a87775 100644
--- a/src/interface/propositions/modele.js
+++ b/src/interface/propositions/modele.js
@@ -62,17 +62,64 @@ const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
// sont des comptes.
const DECIMALES = new Map([['diversite', 2]]);
+// Les colonnes dont les cellules sont du texte : toutes les autres portent
+// un nombre, qui s'aligne à droite en chiffres tabulaires.
+const TEXTUELLES = new Set(['proposition', 'certificat', 'imposes', 'commandes']);
+
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
* l'identifiant, puis les mesures — les trois minimums des rencontres en
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom,
- * la clé de son en-tête, qui nomme son unité, et celle de son explication,
- * le texte de l'infobulle de son en-tête et du critère qui la désigne. */
+ * la clé de son en-tête, qui nomme son unité, celle de son explication,
+ * le texte de l'infobulle de son en-tête et du critère qui la désigne, et
+ * nombre, vrai quand ses cellules portent un nombre. */
export const COLONNES = Object.freeze(
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
- Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}` }),
+ Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(nom) }),
),
);
+// Les groupes d'en-têtes : chacun nomme des colonnes de mesure voisines dans
+// COLONNES, sous la clé propositions.groupe..
+const GROUPES = [
+ ['rencontres', ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']],
+ ['collisions', ['collisionsCumulees', 'pairesDistinctes', 'excedentCollisions']],
+ ['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
+ ['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
+ ['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']],
+];
+
+// Les segments de la rangée des groupes, dans l'ordre de COLONNES : un
+// groupe couvre ses colonnes d'un seul segment, une colonne hors groupe
+// forme le sien, sans clé. Un groupe dont les colonnes ne se suivent pas
+// dans COLONNES lève au chargement du module.
+function segmenter() {
+ const noms = COLONNES.map(({ nom }) => nom);
+ const segments = [];
+ for (let rang = 0; rang < noms.length; ) {
+ const groupe = GROUPES.find(([, colonnes]) => colonnes[0] === noms[rang]);
+ if (groupe === undefined) {
+ if (GROUPES.some(([, colonnes]) => colonnes.includes(noms[rang]))) throw new Error(`colonne groupée hors de son groupe : ${noms[rang]}`);
+ segments.push({ cle: null, colonnes: [noms[rang]] });
+ rang += 1;
+ continue;
+ }
+ const [nom, colonnes] = groupe;
+ colonnes.forEach((colonne, decalage) => {
+ if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
+ });
+ segments.push({ cle: `propositions.groupe.${nom}`, colonnes });
+ rang += colonnes.length;
+ }
+ return segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) }));
+}
+
+/** La rangée des groupes d'en-têtes, au-dessus de celle des colonnes :
+ * { cle, etendue, colonnes } par segment, dans l'ordre de COLONNES — cle,
+ * la clé du libellé du groupe, null pour une colonne hors groupe ;
+ * etendue, le nombre de colonnes couvertes ; colonnes, leurs noms. Les
+ * segments couvrent chaque colonne une fois. */
+export const GROUPES_COLONNES = Object.freeze(segmenter());
+
/**
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
* en-tête, et celui du nom de critère qui la désigne dans la phrase de
diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js
index 39bc9e5..8748c76 100644
--- a/src/interface/propositions/modele.test.js
+++ b/src/interface/propositions/modele.test.js
@@ -31,6 +31,7 @@ import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
COLONNES,
+ GROUPES_COLONNES,
colonneDuCritere,
demandeConfirmation,
evaluation,
@@ -769,3 +770,53 @@ describe('surlignee : la colonne surlignée, du survol et du focus (§ 5.7)', ()
assert.equal(surlignee(null, null), null);
});
});
+
+describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
+ const noms = COLONNES.map(({ nom }) => nom);
+
+ test('les segments couvrent chaque colonne une fois, dans l’ordre de COLONNES ; chaque étendue est le compte de ses colonnes', () => {
+ assert.ok(GROUPES_COLONNES.length > 0);
+ assert.deepEqual(GROUPES_COLONNES.flatMap(({ colonnes }) => colonnes), noms);
+ for (const segment of GROUPES_COLONNES) assert.equal(segment.etendue, segment.colonnes.length, JSON.stringify(segment));
+ assert.ok(Object.isFrozen(GROUPES_COLONNES));
+ for (const segment of GROUPES_COLONNES) {
+ assert.ok(Object.isFrozen(segment));
+ assert.ok(Object.isFrozen(segment.colonnes));
+ }
+ });
+
+ test('le rang, la proposition, le certificat, les imposés et les commandes restent hors groupe, une colonne par segment sans clé', () => {
+ const seules = GROUPES_COLONNES.filter(({ cle }) => cle === null);
+ assert.deepEqual(seules.map(({ colonnes }) => colonnes), [['rang'], ['proposition'], ['certificat'], ['imposes'], ['commandes']]);
+ });
+
+ test('cinq groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
+ const groupes = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
+ assert.deepEqual(
+ groupes.map(({ colonnes }) => colonnes),
+ [
+ ['rencontresTous', 'rencontresMobiles', 'rencontresAncres'],
+ ['collisionsCumulees', 'pairesDistinctes', 'excedentCollisions'],
+ ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'],
+ ['appartenancesVues', 'totalRedondance', 'diversite'],
+ ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'],
+ ],
+ );
+ const enTetes = new Set(COLONNES.map(({ cle }) => t(cle)));
+ for (const { cle } of groupes) {
+ assert.match(cle, /^propositions\.groupe\.[a-z]+$/u);
+ const texte = t(cle);
+ assert.ok(texte.length > 0, cle);
+ assert.ok(!enTetes.has(texte), cle);
+ assert.doesNotMatch(texte, /moyenne/i, cle);
+ }
+ assert.equal(new Set(groupes.map(({ cle }) => cle)).size, groupes.length);
+ });
+});
+
+describe('COLONNES : les colonnes de nombres', () => {
+ test('nombre est vrai pour le rang et chaque mesure chiffrée ; faux pour la proposition, le certificat en mots, les imposés et les commandes', () => {
+ const textes = new Set(['proposition', 'certificat', 'imposes', 'commandes']);
+ for (const colonne of COLONNES) assert.equal(colonne.nombre, !textes.has(colonne.nom), colonne.nom);
+ });
+});
diff --git a/src/interface/sorties/Papier.navigateur.test.js b/src/interface/sorties/Papier.navigateur.test.js
index 24d8091..a80c759 100644
--- a/src/interface/sorties/Papier.navigateur.test.js
+++ b/src/interface/sorties/Papier.navigateur.test.js
@@ -13,13 +13,16 @@
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
+import '../jetons.css';
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { PAPIERS } from '../../geometrie/papiers.js';
+import { couleurDeJeton } from '../../../test/navigateur/captures.js';
import { attendre } from '../../../test/navigateur/ecrans.js';
+import { composantes, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Papier from './Papier.svelte';
@@ -135,3 +138,39 @@ describe('la question du papier (§ 8.5, § 11.2)', () => {
expect(seance.demandes).toEqual(['A4']);
});
});
+
+describe('l’habillage de la question du papier', () => {
+ test('chaque papier est un choix distinct : une tuile à son filet, qui ne chevauche pas sa voisine, et son bouton la remplit sur 44 px de haut au moins', () => {
+ const conteneur = monter(seanceDEpreuve(null));
+ const tuiles = [...conteneur.querySelectorAll('[data-papier="question"] [data-cible]')];
+ expect(tuiles.map((tuile) => tuile.dataset.cible)).toEqual(Object.keys(PAPIERS));
+ const filet = couleurDeJeton('--couleur-carte-filet').join();
+ for (const tuile of tuiles) {
+ const style = getComputedStyle(tuile);
+ expect(style.borderTopWidth, tuile.dataset.cible).toBe('1px');
+ expect(composantes(style.borderTopColor).join(), tuile.dataset.cible).toBe(filet);
+ expect(Number.parseFloat(style.borderTopLeftRadius), tuile.dataset.cible).toBeGreaterThanOrEqual(8);
+ const bouton = tuile.querySelector('[data-commande="choisirPapier"]');
+ const b = bouton.getBoundingClientRect();
+ expect(b.height, tuile.dataset.cible).toBeGreaterThanOrEqual(44);
+ expect(b.width, tuile.dataset.cible).toBeGreaterThanOrEqual(tuile.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) - 1);
+ }
+ const [a, b] = tuiles.map((tuile) => tuile.getBoundingClientRect());
+ const chevauchent = a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+ expect(chevauchent).toBe(false);
+ });
+
+ test('le papier choisi se montre dans une pastille neutre, son nom de l’encre de la page, dans les deux thèmes', async () => {
+ const conteneur = monter(seanceDEpreuve('A4'));
+ const choisi = conteneur.querySelector('[data-papier="choisi"]');
+ const nom = [...choisi.querySelectorAll('span')].find((span) => texte(span) === t('impression.papier.nom.a4') && span.children.length === 0);
+ expect(nom).toBeDefined();
+ for (const theme of ['clair', 'sombre']) {
+ await sousTheme(theme, async () => {
+ const [fond] = fondPeintSous(nom);
+ expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-neutre-fond').join());
+ expect(couleurTexte(nom).join(), theme).toBe(couleurDeJeton('--couleur-page-texte').join());
+ });
+ }
+ });
+});
diff --git a/src/interface/sorties/Papier.svelte b/src/interface/sorties/Papier.svelte
index 74c30d0..ce402ee 100644
--- a/src/interface/sorties/Papier.svelte
+++ b/src/interface/sorties/Papier.svelte
@@ -18,8 +18,13 @@
// la commande, et la question reste. Le choix fait, onChoisi, quand il est
// donné, reçoit le papier : la section qui demandait une production la
// poursuit.
+ //
+ // Habillage : la question montre chaque papier comme un choix distinct,
+ // une tuile à son filet que son bouton remplit, haut d'une cible
+ // principale ; le papier choisi se montre dans une pastille neutre.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
+ import Pastille from '../primitives/Pastille.svelte';
import { PAPIERS_OFFERTS, clesPapier } from '../impression/modele.js';
let { onChoisi = null } = $props();
@@ -45,7 +50,7 @@