diff --git a/src/interface/APropos.navigateur.test.js b/src/interface/APropos.navigateur.test.js new file mode 100644 index 0000000..f9a0595 --- /dev/null +++ b/src/interface/APropos.navigateur.test.js @@ -0,0 +1,122 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le panneau « À propos » dans un vrai moteur de rendu (§ 18.6) : monté +// seul, sur un presse-papiers d'épreuve qui relève ce qu'on lui remet ou +// refuse. Le bloc copié est celui qu'on lui passe ; l'ouverture depuis le +// bandeau et la composition du bloc s'éprouvent dans App.navigateur.test.js. +// +// 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 { couleurDeJeton } from '../../test/navigateur/captures.js'; +import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../test/navigateur/rendu.js'; +import APropos from './APropos.svelte'; +import { TABLES_INTERFACE } from './libelles.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// Un bloc inventé, de la forme d'une ligne d'« À propos ». +const BLOC = 'Logiciel témoin \u{00B7} forme d\u{2019}épreuve \u{00B7} plateforme simulée'; + +const montes = []; +afterEach(() => { + for (const { composant, conteneur } of montes.splice(0)) { + unmount(composant); + conteneur.remove(); + } +}); + +// Un presse-papiers d'épreuve : relève chaque copie, ou lève refus. +function pressePapiersDEpreuve({ refus = null } = {}) { + const copies = []; + return { + copies, + async copier(texte) { + if (refus !== null) throw refus; + copies.push(texte); + }, + }; +} + +function monter(props) { + const conteneur = document.createElement('div'); + document.body.prepend(conteneur); + const contexte = new Map([ + ['t', t], + ['horloge', creerHorlogePilotee()], + ]); + const composant = mount(APropos, { target: conteneur, props, context: contexte }); + flushSync(); + montes.push({ composant, conteneur }); + return conteneur; +} + +// 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('« À propos » : l’habillage (§ 18.6)', () => { + test('le titre au-dessus d’une carte qui porte le bloc, sur le fond sourd, et les commandes ; « Copier » est l’action principale, dans les deux thèmes', async () => { + const conteneur = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve(), fermer: () => {} }); + const ecran = conteneur.querySelector('[data-ecran="apropos"]'); + const titre = ecran.querySelector('h1'); + expect(titre.textContent).toBe(t('coquille.apropos.titre')); + const bloc = ecran.querySelector('[data-bloc]'); + expect(bloc.textContent).toBe(BLOC); + const carte = carteDe(bloc); + expect(carte, 'carte du bloc').not.toBeNull(); + expect(ecran.contains(carte)).toBe(true); + expect(carte.contains(titre)).toBe(false); + expect(carte.contains(ecran.querySelector('[data-commande="copier"]'))).toBe(true); + expect(carte.contains(ecran.querySelector('[data-commande="fermerApropos"]'))).toBe(true); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + const [fond] = fondPeintSous(bloc); + expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join()); + expect(contraste(couleurTexte(bloc), fond), theme).toBeGreaterThanOrEqual(4.5); + const copier = getComputedStyle(ecran.querySelector('[data-commande="copier"]')); + expect(composantes(copier.backgroundColor).join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join()); + }); + } + }); + + test('« Copier » remet exactement le bloc affiché et le dit ; refusée, la copie le dit dans l’encre du refus', async () => { + const presse = pressePapiersDEpreuve(); + const conteneur = monter({ bloc: BLOC, pressePapiers: presse, fermer: () => {} }); + conteneur.querySelector('[data-commande="copier"]').click(); + await vi.waitFor(() => expect(presse.copies).toEqual([BLOC])); + await vi.waitFor(() => { + flushSync(); + expect(conteneur.querySelector('[role="status"]')?.textContent).toBe(t('coquille.apropos.copie')); + }); + + const refuse = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve({ refus: new Error('refusé') }), fermer: () => {} }); + refuse.querySelector('[data-commande="copier"]').click(); + await vi.waitFor(() => { + flushSync(); + expect(refuse.querySelector('[data-refus="copier"]')?.textContent).toBe(t('coquille.apropos.copieRefusee')); + }); + const message = refuse.querySelector('[data-refus="copier"]'); + expect(couleurTexte(message).join()).toBe(couleurDeJeton('--couleur-refus').join()); + }); + + test('« Fermer » appelle fermer une fois', () => { + const fermer = vi.fn(); + const conteneur = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve(), fermer }); + conteneur.querySelector('[data-commande="fermerApropos"]').click(); + expect(fermer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/interface/APropos.svelte b/src/interface/APropos.svelte index 46e56a3..68fade0 100644 --- a/src/interface/APropos.svelte +++ b/src/interface/APropos.svelte @@ -6,7 +6,14 @@ // copie dans un courriel, tel que blocAPropos l'a composé, et sa copie dans // le presse-papiers de la plateforme. « Copier » remet exactement le texte // affiché ; l'issue de la copie se lit sous les commandes. + // + // Habillage : le titre de l'écran, puis une carte qui porte le bloc, sur + // le fond sourd de la carte, et les commandes — « Copier », l'action + // principale, et « Fermer ». Une copie refusée se dit dans l'encre du + // refus. import { getContext } from 'svelte'; + import Carte from './primitives/Carte.svelte'; + import Commande from './primitives/Commande.svelte'; const t = getContext('t'); @@ -27,23 +34,51 @@

{t('coquille.apropos.titre')}

-

{bloc}

-
- - -
- {#if issue === 'faite'} -

{t('coquille.apropos.copie')}

- {:else if issue === 'refusee'} -

{t('coquille.apropos.copieRefusee')}

- {/if} + +
+

{bloc}

+
+ + +
+ {#if issue === 'faite'} +

{t('coquille.apropos.copie')}

+ {:else if issue === 'refusee'} +

{t('coquille.apropos.copieRefusee')}

+ {/if} +
+
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} - -
- lancer(false)} /> -
- {#if question !== null} -
-

{t('generation.longue.question', question)}

-
- lancer(true)} /> - (question = null)} /> + +
+ +
+ lancer(false)} />
+ {#if question !== null} +
+

{t('generation.longue.question', question)}

+
+ lancer(true)} /> + (question = null)} /> +
+
+ {/if} + {#if $generation.enCours} + generation.annuler()} /> + {:else if issue !== null} +

{t('generation.issue.ajoutees', issue)}

+ {:else if $generation.annulee === 'commande'} +

{t('generation.issue.annuleeCommande')}

+ {:else if $generation.annulee === 'seance'} +

{t('generation.issue.annuleeSeance')}

+ {/if}
- {/if} - {#if $generation.enCours} - generation.annuler()} /> - {:else if issue !== null} -

{t('generation.issue.ajoutees', issue)}

- {:else if $generation.annulee === 'commande'} -

{t('generation.issue.annuleeCommande')}

- {:else if $generation.annulee === 'seance'} -

{t('generation.issue.annuleeSeance')}

- {/if} +
{/if} @@ -220,6 +230,7 @@ display: flex; flex-direction: column; gap: var(--espace-4); + min-width: 0; } h2 { @@ -227,37 +238,47 @@ font-size: var(--taille-titre); } - /* La meilleure proposition : un bloc d'information encadré d'un filet, - sur le fond de la page ; chaque libellé suivi de sa valeur, en gras. */ + /* La meilleure proposition : une carte, son titre en tête, puis chaque + valeur en grand, sous son intitulé en petites capitales. */ .meilleure { display: flex; flex-direction: column; - gap: var(--espace-2); - padding: var(--espace-2) var(--espace-3); - border: 1px solid var(--couleur-page-filet); - border-radius: var(--rayon-1); + gap: var(--espace-3); + padding: var(--espace-4) var(--espace-5); + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-3); + background-color: var(--couleur-carte-fond); + box-shadow: var(--ombre-carte); } h3 { margin: 0; - font-size: var(--taille-texte); + font-size: var(--taille-titre-carte); } dl { display: flex; flex-wrap: wrap; - gap: var(--espace-2) var(--espace-5); + gap: var(--espace-3) var(--espace-6); margin: 0; } dl > div { display: flex; - gap: var(--espace-2); - align-items: baseline; + flex-direction: column; + gap: var(--espace-1); + } + + dt { + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-intertitre); + letter-spacing: var(--interlettre-capitales); + text-transform: uppercase; } dd { margin: 0; + font-size: var(--taille-titre); font-weight: bold; font-variant-numeric: tabular-nums; } @@ -274,13 +295,28 @@ outline-offset: 2px; } + .reglages { + display: flex; + flex-direction: column; + gap: var(--espace-4); + } + + .lancer { + display: flex; + padding-top: var(--espace-3); + border-top: 1px solid var(--couleur-ligne-filet); + } + .question { display: flex; flex-direction: column; align-items: flex-start; gap: var(--espace-2); - padding: var(--espace-2) var(--espace-3); - border-left: 3px solid var(--couleur-avis); + padding: var(--espace-3) var(--espace-4); + border: 1px solid var(--couleur-avis-filet); + border-left: 3px solid var(--couleur-avis-marque); + border-radius: var(--rayon-2); + background-color: var(--couleur-avis-fond); } .issues { diff --git a/src/interface/impression/Apercu.navigateur.test.js b/src/interface/impression/Apercu.navigateur.test.js index e446adb..2e383d6 100644 --- a/src/interface/impression/Apercu.navigateur.test.js +++ b/src/interface/impression/Apercu.navigateur.test.js @@ -32,6 +32,7 @@ import { analyser } from '../../stockage/document.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js'; import { VERSION } from '../../version.genere.js'; +import { couleurDeJeton } from '../../../test/navigateur/captures.js'; import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { attendre, @@ -43,7 +44,7 @@ import { ouvrirApercu, ouvrirEvenement, } from '../../../test/navigateur/ecrans.js'; -import { attenuations, composantes, contraste } from '../../../test/navigateur/rendu.js'; +import { attenuations, composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js'; import { creerImprimanteSimulee } from '../../../test/imprimante_simulee.js'; import { TABLES_INTERFACE } from '../libelles.js'; import { MARQUES } from '../marques.js'; @@ -525,3 +526,101 @@ describe('la bande mesurée (HAUTEUR_BANDE_IMPRESSION, § 14.1)', () => { } }); }); + +// --- L'habillage de l'écran : la barre d'outils groupée --------------------------- + +describe('la barre d’outils de l’aperçu', () => { + const barre = (m) => dans(m, `[data-ecran="apercu"] [role="group"][aria-label="${t('impression.barre')}"]`); + + // 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; + } + + test('sans papier, la question se pose dans une carte sous la barre, dans les deux thèmes ; « Fermer l’aperçu » se range au bord droit de la barre', async () => { + const m = await ouvrirApercu(contexte, chargePrincipale(), { papier: null }); + const question = dans(m, '[data-ecran="apercu"] [data-papier="question"]'); + expect(question, 'la question du papier').not.toBeNull(); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + const carte = carteDe(question); + expect(carte, `${theme} : carte de la question`).not.toBeNull(); + expect(carte.contains(barre(m)), theme).toBe(false); + expect(barre(m).contains(carte), theme).toBe(false); + expect(carte.getBoundingClientRect().top, theme).toBeGreaterThanOrEqual(barre(m).getBoundingClientRect().bottom); + }); + } + // Le bord droit de la barre, moins son filet et sa marge intérieure : + // la fermeture s'y range, à un pixel près, quelle que soit la place que + // les groupes laissent libre. + const style = getComputedStyle(barre(m)); + const bord = barre(m).getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight); + const fermer = dans(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]').getBoundingClientRect(); + expect(fermer.right).toBeGreaterThanOrEqual(bord - 1); + expect(fermer.right).toBeLessThanOrEqual(bord + 1); + }); + + test('une carte, ses commandes en groupes nommés côte à côte : vue, réglages de la vue, papier et impression, fermeture', async () => { + const m = await ouvrirApercu(contexte, chargePrincipale()); + const style = getComputedStyle(barre(m)); + expect(composantes(style.backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond').join()); + expect(style.borderTopLeftRadius).toBe('12px'); + const groupes = [...barre(m).querySelectorAll('[role="group"]')]; + expect(groupes.map((groupe) => groupe.getAttribute('aria-label'))).toEqual([ + t('impression.vue.titre'), + t('impression.groupe.reglages'), + t('impression.groupe.sortie'), + ]); + const boites = groupes.map((groupe) => groupe.getBoundingClientRect()); + for (const [rang, a] of boites.entries()) { + expect(a.width * a.height, groupes[rang].getAttribute('aria-label')).toBeGreaterThan(0); + for (const b of boites.slice(rang + 1)) { + const chevauchent = a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + expect(chevauchent).toBe(false); + } + } + const [, reglages, sortie] = groupes; + expect(reglages.querySelector('[data-champ="tour"]')).not.toBeNull(); + expect(sortie.querySelector('[data-papier="choisi"]')).not.toBeNull(); + expect(sortie.querySelector('[data-commande="imprimer"]')).not.toBeNull(); + expect(groupes.some((groupe) => groupe.contains(dans(m, '[data-commande="fermerApercu"]')))).toBe(false); + }); + + test('la vue retenue se lit enfoncée — teinte de l’action, gras, texte à 4,5:1 — et « Imprimer » est l’action principale, dans les deux thèmes', async () => { + const m = await ouvrirApercu(contexte, chargePrincipale()); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + const vues = tous(m, '[data-ecran="apercu"] [data-commande="vue"]'); + const enfoncee = vues.find((vue) => vue.getAttribute('aria-pressed') === 'true'); + const style = getComputedStyle(enfoncee); + const fond = composantes(style.backgroundColor); + expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-action-teinte').join()); + expect(style.fontWeight, theme).toBe('700'); + expect(contraste(composantes(style.color), fond), theme).toBeGreaterThanOrEqual(4.5); + for (const vue of vues.filter((autre) => autre !== enfoncee)) { + expect(composantes(getComputedStyle(vue).backgroundColor).join(), theme).not.toBe(fond.join()); + } + const imprimer = getComputedStyle(dans(m, '[data-ecran="apercu"] [data-commande="imprimer"]')); + expect(composantes(imprimer.backgroundColor).join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join()); + }); + } + }); + + test('à 1 024 px de large, la barre tient dans la fenêtre sans défilement interne', async () => { + await fixerCadre(1024, 700); + const m = await ouvrirApercu(contexte, chargePrincipale()); + const b = barre(m).getBoundingClientRect(); + expect(b.left).toBeGreaterThanOrEqual(0); + expect(b.right).toBeLessThanOrEqual(document.documentElement.clientWidth); + expect(barre(m).scrollWidth).toBeLessThanOrEqual(barre(m).clientWidth); + for (const enfant of barre(m).querySelectorAll('button, select')) { + expect(enfant.getBoundingClientRect().right, enfant.textContent).toBeLessThanOrEqual(b.right); + } + }); +}); diff --git a/src/interface/impression/Apercu.svelte b/src/interface/impression/Apercu.svelte index cf9f198..0ec021a 100644 --- a/src/interface/impression/Apercu.svelte +++ b/src/interface/impression/Apercu.svelte @@ -29,6 +29,12 @@ // la commande, une annulation ne dit rien. Rien de l'événement ne // s'écrit : l'aperçu ne reçoit de la séance que son état, et le papier // passe par choisirPapier. + // + // Habillage de l'écran : la barre est une carte, ses commandes en groupes + // nommés, côte à côte — la vue imprimée, en commandes enfoncées ; les + // réglages de la vue, quand elle en a ; le papier et « Imprimer », l'action + // principale — puis « Fermer l'aperçu », à part. Le document imprimé n'en + // prend rien. import '../impression.css'; import { getContext } from 'svelte'; import { estBrouillon, piedDePage, refusImpression } from '../../application/sorties.js'; @@ -105,47 +111,57 @@
-

{t('impression.titre')}

- {#if cadre !== null} - {t(cadre.cle)} - {/if} -
+
+

{t('impression.titre')}

+ {#if cadre !== null} + {t(cadre.cle)} + {/if} +
+
{#each VUES_IMPRESSION as vue (vue)} {/each}
- {#if options.vue === 'plan'} - - - - {:else if options.vue === 'tables'} - + {#if options.vue === 'plan' || options.vue === 'tables'} +
+ {#if options.vue === 'plan'} + + + + {:else} + + {/if} +
{/if} {#if !options.question} - - +
+ + +
{/if} - +
+ +
{#if options.question} @@ -173,28 +189,66 @@ gap: var(--espace-4); } + /* La barre : une carte, ses groupes côte à côte, chacun ouvert par un + filet ; elle se replie à la ligne sous une largeur étroite. */ .barre { display: flex; flex-wrap: wrap; align-items: center; - gap: var(--espace-2) var(--espace-4); - padding-bottom: var(--espace-3); - border-bottom: 1px solid var(--couleur-page-filet); + gap: var(--espace-3) var(--espace-4); + padding: var(--espace-3) var(--espace-4); + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-3); + background-color: var(--couleur-carte-fond); + box-shadow: var(--ombre-carte); + } + + .tete { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--espace-2) var(--espace-3); } h2 { margin: 0; - font-size: var(--taille-titre); + font-size: var(--taille-titre-carte); } .mode { font-weight: bold; } - .vues { + .groupe { display: flex; flex-wrap: wrap; - gap: var(--espace-2); + align-items: center; + gap: var(--espace-2) var(--espace-3); + padding-left: var(--espace-4); + border-left: 1px solid var(--couleur-carte-filet); + } + + /* Les vues se touchent, en une rangée de commandes enfoncées. */ + .vues { + gap: 0; + } + + .vues button + button { + margin-left: -1px; + } + + .vues button:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .vues button:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .fermer { + margin-left: auto; } .reglage { @@ -203,13 +257,18 @@ gap: var(--espace-2); } + .reglage > span { + color: var(--couleur-page-texte-secondaire); + } + button, select { 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); } @@ -217,19 +276,34 @@ cursor: pointer; } - /* Une vue ou un réglage enfoncé se distingue par sa forme : gras et - souligné. */ + button:hover { + background-color: var(--couleur-carte-fond-sourd); + } + + /* Une vue ou un réglage enfoncé : la teinte de l'action, le texte en gras + de l'encre de la page ; le gras le distingue sans la couleur. */ button[aria-pressed='true'] { + position: relative; + background-color: var(--couleur-action-teinte); font-weight: bold; - text-decoration: underline; } button:focus-visible, select:focus-visible { + position: relative; outline: 2px solid var(--couleur-focus); outline-offset: 2px; } + /* La question du papier, sans document : une carte sous la barre. */ + .question { + padding: var(--espace-4) var(--espace-5); + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-3); + background-color: var(--couleur-carte-fond); + box-shadow: var(--ombre-carte); + } + /* La feuille gabarit se rend hors de la vue : elle se mesure, elle ne se voit ni ne s'atteint. */ .gabarit { diff --git a/src/interface/libelles/badges.js b/src/interface/libelles/badges.js index 6d39ee5..d040d84 100644 --- a/src/interface/libelles/badges.js +++ b/src/interface/libelles/badges.js @@ -16,6 +16,7 @@ export const TABLE = new Map([ ['badges.introduction', "Un badge A6 par personne présente, imposé sur le papier de l'imprimante, précédé d'une feuille de contrôle."], // --- Les réglages de la planche (§ 11.6, Q3) ------------------------------ + ['badges.planche.titre', 'Planche de badges'], ['badges.source.titre', 'Placement imprimé'], ['badges.source.retenu', 'Le placement retenu'], ['badges.source.proposition', ({ id }) => `La proposition n° ${id}`], diff --git a/src/interface/libelles/generation.js b/src/interface/libelles/generation.js index e742b75..c5027b5 100644 --- a/src/interface/libelles/generation.js +++ b/src/interface/libelles/generation.js @@ -41,6 +41,7 @@ export const TABLE = new Map([ ], // --- Les réglages (§ 5.7, § 5.10) --------------------------------------------- + ['generation.reglages.titre', 'Réglages de la génération'], ['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'], ['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'], ['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"], diff --git a/src/interface/libelles/impression.js b/src/interface/libelles/impression.js index 2300e4f..fee2a56 100644 --- a/src/interface/libelles/impression.js +++ b/src/interface/libelles/impression.js @@ -14,6 +14,8 @@ export const TABLE = new Map([ ['impression.commande.ouvrir', 'Imprimer…'], ['impression.commande.imprimer', 'Imprimer'], ['impression.commande.fermer', "Fermer l'aperçu"], + ['impression.groupe.reglages', 'Réglages de la vue'], + ['impression.groupe.sortie', 'Papier et impression'], // --- Ce que l'aperçu imprime ---------------------------------------------- ['impression.vue.titre', 'Vue imprimée'], diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index afa0c64..7f9a4ca 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -64,6 +64,15 @@ export const TABLE = new Map([ ['propositions.colonne.imposes', 'Imposé par les réservations'], ['propositions.colonne.commandes', 'Commandes'], + // --- Les groupes d'en-têtes, au-dessus des colonnes ------------------------- + // Chaque groupe réunit des colonnes voisines ; les en-têtes de colonne + // gardent chacun son unité. + ['propositions.groupe.rencontres', 'Personnes rencontrées'], + ['propositions.groupe.collisions', "Collisions d'appartenance"], + ['propositions.groupe.repetitions', 'Rencontres répétées et retours'], + ['propositions.groupe.diversite', "Diversité d'appartenance"], + ['propositions.groupe.manque', 'Manque et plafond a priori'], + // --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) -------- // Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui // désigne la colonne dans la phrase de l'ordre : un critère et sa colonne diff --git a/src/interface/marques.js b/src/interface/marques.js index 5ac151e..d8b5909 100644 --- a/src/interface/marques.js +++ b/src/interface/marques.js @@ -131,12 +131,14 @@ export const MARQUES = Object.freeze([ // La désignation se pose sur la page, le sol, le plateau et une chaise // vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond. marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), - marque('selection', 'selection', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + // La sélection marque aussi une colonne du tableau des propositions, sur la + // carte, son fond sourd et la ligne du retenu. + marque('selection', 'selection', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), // Le cadre de mode (§ 8.4) entoure le contenu de l'événement, sur la page. 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', 'carte-fond', 'carte-fond-sourd', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]), + marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'action-teinte', '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']), diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js index c8edba6..ab4d7ef 100644 --- a/src/interface/propositions/Propositions.navigateur.test.js +++ b/src/interface/propositions/Propositions.navigateur.test.js @@ -34,11 +34,13 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../. import { SUFFIXES } from '../../stockage/noms.js'; import { planDepuisFichier } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; +import { couleurDeJeton } from '../../../test/navigateur/captures.js'; import { centre } from '../../../test/navigateur/ecrans.js'; import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; +import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; -import { evaluation, tableau } from './modele.js'; +import { GROUPES_COLONNES, evaluation, tableau } from './modele.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const format = creerFormat(t); @@ -1005,3 +1007,116 @@ describe('la cible de la somme de redondance : un champ de la section', () => { expect(revision(ecriture)).toBe(revisionEcriture); }); }); + +// --- L'habillage : carte, en-têtes groupés, ligne du retenu (§ 5.7, § 12.7) ------- + +// 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; +} + +// Les colonnes de nombres : le rang et chaque mesure, sauf le certificat. +const COLONNES_NOMBRES = NOMS_DES_COLONNES.filter((nom) => !['proposition', 'certificat', 'imposes', 'commandes'].includes(nom)); + +describe('l’habillage du tableau des propositions', () => { + const chargeRetenue = () => executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }); + + test('le tableau vit dans une carte de la section, et son conteneur défilant reste dans la carte', async () => { + const m = await ouvrir(chargeRetenue()); + const table = section(m).querySelector('table'); + const carte = carteDe(table); + expect(carte, 'carte du tableau').not.toBeNull(); + expect(section(m).contains(carte)).toBe(true); + const defilement = table.parentElement; + const b = carte.getBoundingClientRect(); + const d = defilement.getBoundingClientRect(); + expect(d.left).toBeGreaterThanOrEqual(b.left); + expect(d.right).toBeLessThanOrEqual(b.right); + }); + + test('deux rangées d’en-têtes : chaque groupe, scope colgroup, couvre exactement ses colonnes ; les en-têtes de colonne restent dans la seconde', async () => { + const m = await ouvrir(chargeRetenue()); + const thead = section(m).querySelector('thead'); + expect(thead.rows.length).toBe(2); + const groupes = [...thead.rows[0].querySelectorAll('th')]; + const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null); + expect(groupes.map(texteDe)).toEqual(attendus.map(({ cle }) => t(cle))); + expect(thead.rows[0].querySelectorAll('[data-colonne]').length).toBe(0); + expect([...thead.rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES); + for (const [rang, groupe] of groupes.entries()) { + const { colonnes } = attendus[rang]; + expect(groupe.getAttribute('scope')).toBe('colgroup'); + const premiere = thead.querySelector(`th[data-colonne="${colonnes[0]}"]`).getBoundingClientRect(); + const derniere = thead.querySelector(`th[data-colonne="${colonnes.at(-1)}"]`).getBoundingClientRect(); + const g = groupe.getBoundingClientRect(); + expect(Math.abs(g.left - premiere.left), texteDe(groupe)).toBeLessThanOrEqual(1); + expect(Math.abs(g.right - derniere.right), texteDe(groupe)).toBeLessThanOrEqual(1); + expect(g.bottom).toBeLessThanOrEqual(premiere.top + 1); + } + const colgroups = [...section(m).querySelectorAll('table > colgroup')]; + expect(colgroups.map((colgroup) => colgroup.span)).toEqual(GROUPES_COLONNES.map(({ etendue }) => etendue)); + }); + + test('les nombres, en-têtes compris, s’alignent à droite en chiffres tabulaires', async () => { + const m = await ouvrir(chargeRetenue()); + for (const nom of COLONNES_NOMBRES) { + const cellulesLues = [...section(m).querySelectorAll(`tbody[data-classement] td[data-colonne="${nom}"], thead th[data-colonne="${nom}"]`)]; + expect(cellulesLues.length, nom).toBe(4); + for (const cellule of cellulesLues) { + const style = getComputedStyle(cellule); + expect(style.textAlign, nom).toBe('right'); + expect(style.fontVariantNumeric, nom).toContain('tabular-nums'); + } + } + }); + + test('la ligne du retenu sur la teinte de Retenu, son texte à 4,5:1, dans les deux thèmes ; les autres lignes sur la carte', async () => { + const m = await ouvrir(chargeRetenue()); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + flushSync(); + const teinte = couleurDeJeton('--couleur-etat-retenu-fond'); + const carte = couleurDeJeton('--couleur-carte-fond'); + for (const cellule of ligneRetenu(m).children) { + cellule.scrollIntoView({ block: 'center', inline: 'center' }); + const [fond] = fondPeintSous(cellule); + expect(fond.join(), `${theme} ${cellule.dataset.colonne}`).toBe(teinte.join()); + } + const titre = ligneRetenu(m).querySelector('[data-colonne="rang"]'); + expect(contraste(couleurTexte(titre), teinte), theme).toBeGreaterThanOrEqual(4.5); + for (const cellule of ligne(m, 1).querySelectorAll('td[data-colonne]')) { + cellule.scrollIntoView({ block: 'center', inline: 'center' }); + const [fond] = fondPeintSous(cellule); + expect(fond.join(), `${theme} ligne 1 ${cellule.dataset.colonne}`).toBe(carte.join()); + } + }); + } + }); + + test('la proposition retenue porte la pastille « retenue » : fond de Retenu, signe à 3:1, texte de l’encre de la page', async () => { + const m = await ouvrir(chargeRetenue()); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + flushSync(); + const cellule = ligne(m, 3).querySelector('td[data-colonne="proposition"]'); + const libelle = [...cellule.querySelectorAll('span')].find((span) => texteDe(span) === t('propositions.marque.retenue') && span.children.length === 0); + expect(libelle, theme).toBeDefined(); + libelle.scrollIntoView({ block: 'center', inline: 'center' }); + const [fond] = fondPeintSous(libelle); + expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-etat-retenu-fond').join()); + expect(couleurTexte(libelle).join(), theme).toBe(couleurDeJeton('--couleur-page-texte').join()); + const signe = libelle.parentElement.querySelector('svg'); + expect(signe, theme).not.toBeNull(); + expect(composantes(getComputedStyle(signe).color).join(), theme).toBe(couleurDeJeton('--couleur-etat-retenu-marque').join()); + expect(contraste(composantes(getComputedStyle(signe).color), fond), theme).toBeGreaterThanOrEqual(3); + for (const autre of [1, 2]) expect(texteDe(ligne(m, autre))).not.toContain(t('propositions.marque.retenue')); + }); + } + }); +}); diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index 6b349b2..0b64c0c 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -39,6 +39,10 @@ // explication (explicationDeColonne, modele.js). Le terme expliqué porte // aria-describedby vers elle, et lui seul. // + // Habillage : le tableau et la phrase de l'ordre vivent dans une carte + // titrée, « Effacer les propositions » à droite de son en-tête ; le message + // d'une liste vide et le champ de la cible, chacun dans sa carte. + // // Le champ de la cible de la somme de redondance montre la cible de // l'événement (cibleRedondance), et exécute changerCibleRedondance à la // sortie du champ — une entrée ; la saisie se lit par lireEntier, et un @@ -47,6 +51,7 @@ import { lireEntier } from '../../application/capacite.js'; import { cibleRedondance } from '../../application/evaluation.js'; import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js'; + import Carte from '../primitives/Carte.svelte'; import Champ from '../primitives/Champ.svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; @@ -137,11 +142,17 @@ const nommer = (id) => nomDe(charge, id); +{#snippet effacerListe()} + +{/snippet} +

{t('coquille.section.propositions')}

{#if charge !== null && charge.propositions.length === 0} -

{t('propositions.aucune')}

+ +

{t('propositions.aucune')}

+
{/if} {#if evaluee.refus !== null} @@ -149,55 +160,55 @@ {/if} {#if vue !== null} -
- {#each vue.ordre as morceaux, rang (rang)} -

- {#each morceaux as morceau, place (place)} - {#if morceau.colonne === null} - {t(morceau.cle, morceau.details)} - {:else} - - - entrer(evenement, morceau, false)} - onmouseleave={() => sortir(morceau, false)} - onfocus={(evenement) => entrer(evenement, morceau, true)} - onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)} - {/if} - {/each} -

- {/each} -
- + 0 ? effacerListe : null}> +
+ {#each vue.ordre as morceaux, rang (rang)} +

+ {#each morceaux as morceau, place (place)} + {#if morceau.colonne === null} + {t(morceau.cle, morceau.details)} + {:else} + + + entrer(evenement, morceau, false)} + onmouseleave={() => sortir(morceau, false)} + onfocus={(evenement) => entrer(evenement, morceau, true)} + onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)} + {/if} + {/each} +

+ {/each} +
+ +
+ {:else if charge !== null && charge.propositions.length > 0} +
{@render effacerListe()}
{/if} {#if charge !== null} -
- -

{t('propositions.cible.aide')}

-
- {/if} - - {#if charge !== null && charge.propositions.length > 0} -
- -
+ +
+ +

{t('propositions.cible.aide')}

+
+
{/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 @@ {format.nombre(ligne.id)} {#if ligne.retenue} - {t('propositions.marque.retenue')} + {/if} {@render violations(ligne)} @@ -105,9 +120,20 @@ {/snippet}
- - +
{t('propositions.tableau.titre')}
+ {#each GROUPES_COLONNES as segment, rang (rang)} + + {/each} + + {#each GROUPES_COLONNES as segment, rang (rang)} + {#if segment.cle === null} + + {:else} + + {/if} + {/each} + {#each vue.colonnes as colonne (colonne.nom)} {@const terme = `colonne:${colonne.nom}`} @@ -118,6 +144,7 @@ data-colonne={colonne.nom} data-marque={marque(colonne.nom)} class:surlignee={colonne.nom === surlignee} + class:nombre={colonne.nombre} tabindex="0" aria-describedby={decrit(terme)} onmouseenter={(evenement) => expliquer(evenement, terme, colonne.nom)} @@ -131,7 +158,9 @@ {#if vue.retenu !== null} - +
{t(segment.cle)}
{t('propositions.retenu.titre')}{t('propositions.retenu.titre')} {format.nombre(vue.retenu.id)}

{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}

@@ -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 @@

{t('impression.papier.explication')}

{#each PAPIERS_OFFERTS as choix (choix)} -
+
choisir(choix)} />
{/each} @@ -53,8 +58,8 @@
{:else}
- {t('impression.papier.titre')} - {t(clesPapier(papier).nom)} + {t('impression.papier.titre')} + (changer = true)} />
{/if} @@ -63,13 +68,13 @@ .papier { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: var(--espace-2); } .question { flex-direction: column; - align-items: flex-start; + align-items: stretch; } p { @@ -80,17 +85,37 @@ font-weight: bold; } - .explication { + .explication, + .titre { color: var(--couleur-page-texte-secondaire); } + /* Les choix : une tuile par papier, côte à côte tant que la largeur le + permet, l'une sous l'autre sinon. */ .choix { - display: flex; - flex-wrap: wrap; - gap: var(--espace-2); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--espace-3); + max-width: 36rem; } - .donnee { + .tuile { + padding: var(--espace-2); + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-2); + background-color: var(--couleur-carte-fond); + } + + /* Le bouton de la Commande remplit sa tuile, haut d'une cible principale ; + son refus se pose dessous. */ + .tuile :global(.commande) { + display: flex; + width: 100%; + } + + .tuile :global(button) { + flex: 1 1 100%; + min-height: var(--hauteur-cible); font-weight: bold; }