[IMP] proposals, generation, badges, preview: cards and grouped headers

The proposal table had a dozen measure columns with no grouping. Five
group headers now gather neighbouring measures without changing any
column text; numbers align right in tabular figures, and the kept
placement row wears the Retenu tint and a « retenue » pill. Generation
settings and progress, badges and the paper choice become cards, each
paper a distinct choice; the preview toolbar groups its commands. The
printed paper is unchanged. Checked: node 172, browser 162.

--- FR ---

[IMP] propositions, génération, badges, aperçu : cartes, intitulés

Le tableau des propositions alignait une douzaine de mesures sans
groupe. Cinq intitulés de groupe réunissent les mesures voisines sans
changer le texte d'aucune colonne ; les nombres s'alignent à droite en
chiffres tabulaires, la ligne du retenu porte la teinte Retenu et une
pastille « retenue ». Réglages et avancement, badges et papier
deviennent des cartes, chaque papier un choix distinct ; la barre de
l'aperçu groupe ses commandes. Le papier imprimé ne change pas.
Vérifié : node 172, navigateur 162.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 00:58:07 -04:00
parent 901095a1ef
commit c6082c3af2
25 changed files with 1332 additions and 317 deletions

View file

@ -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);
});
});

View file

@ -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 @@
<section class="apropos" data-ecran="apropos">
<h1>{t('coquille.apropos.titre')}</h1>
<p class="bloc" data-bloc>{bloc}</p>
<div class="commandes">
<button type="button" data-commande="copier" onclick={copier}>{t('coquille.apropos.copier')}</button>
<button type="button" data-commande="fermerApropos" onclick={fermer}>{t('coquille.apropos.fermer')}</button>
</div>
{#if issue === 'faite'}
<p role="status">{t('coquille.apropos.copie')}</p>
{:else if issue === 'refusee'}
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
{/if}
<Carte>
<div class="contenu">
<p class="bloc" data-bloc>{bloc}</p>
<div class="commandes">
<Commande cle="coquille.apropos.copier" nom="copier" variante="principale" executer={copier} />
<Commande cle="coquille.apropos.fermer" nom="fermerApropos" executer={fermer} />
</div>
{#if issue === 'faite'}
<p role="status">{t('coquille.apropos.copie')}</p>
{:else if issue === 'refusee'}
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
{/if}
</div>
</Carte>
</section>
<style>
.apropos {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
h1 {
margin: 0;
}
.contenu {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
p {
margin: 0;
}
/* Le bloc se sélectionne d'un clic, entier, et se coupe où il faut
plutôt que de déborder. */
.bloc {
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond-sourd);
font-variant-numeric: tabular-nums;
user-select: all;
overflow-wrap: anywhere;
}
@ -51,24 +86,10 @@
.commandes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.refus {
color: var(--couleur-refus);
}

View file

@ -26,7 +26,9 @@ import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js'
import { PAPIERS } from '../../geometrie/papiers.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js';
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { composantes, contraste } from '../../../test/navigateur/rendu.js';
import { BASE, attendre, chargeBloquee, chargePrincipale, cliquer, creerContexte, fichierReglages, ouvrirEvenement } from '../../../test/navigateur/ecrans.js';
import { FORME_TEMOIN, chargeTemoin } from '../../../test/fixtures/badges/temoin.js';
import { clesPapier } from '../impression/modele.js';
@ -470,3 +472,46 @@ describe('la langue témoin (§ 14.6)', () => {
}
});
});
// --- L'habillage : cartes, 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 section des badges', () => {
test('les réglages, le papier, les commandes et l’annonce de la planche dans une carte ; la réédition dans une autre', async () => {
const m = await ouvrirBadges(chargeRetenue());
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
await attendre('l’annonce', () => annonce(m), IMAGES);
const planche = carteDe(dans(m, '[data-section="badges"] [data-champ="imposition"]'));
expect(planche, 'carte de la planche').not.toBeNull();
expect(section(m).contains(planche)).toBe(true);
for (const selecteur of ['[data-champ="ordre"]', '[data-papier="choisi"]', '[data-commande="preparerPlanche"]', '[data-commande="produirePlanche"]', '[data-annonce-planche]']) {
expect(planche.contains(dans(m, `[data-section="badges"] ${selecteur}`)), selecteur).toBe(true);
}
const reedition = carteDe(dans(m, '[data-section="badges"] [data-champ="reedition"]'));
expect(reedition, 'carte de la réédition').not.toBeNull();
expect(reedition).not.toBe(planche);
expect(planche.contains(reedition)).toBe(false);
});
test('l’annonce sur le fond sourd de la carte ; « Produire la planche » est l’action principale, son texte à 4,5:1', async () => {
const m = await ouvrirBadges(chargeRetenue());
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
await attendre('l’annonce', () => annonce(m), IMAGES);
expect(composantes(getComputedStyle(annonce(m)).backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join());
const style = getComputedStyle(produire(m));
const fond = composantes(style.backgroundColor);
expect(fond.join()).toBe(couleurDeJeton('--couleur-action-fond').join());
expect(contraste(composantes(style.color), fond)).toBeGreaterThanOrEqual(4.5);
expect(produire(m).getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
});

View file

@ -12,6 +12,10 @@
// sélection — dans l'ordre de la liste — à executer, la production de la
// section, qui annonce puis produit. Un refus s'affiche à côté de la
// commande, avec son remède, que gestes exécute quand l'écran le fournit.
//
// Habillage : le groupe est une carte, titrée de son intitulé ; la liste
// défile dans un cadre au filet de la carte, l'appartenance d'une personne
// en encre secondaire.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { filtrerPersonnes, personnesReeditables, selectionRetenue } from './modele.js';
@ -65,16 +69,23 @@
</div>
<style>
/* Une carte : son fond, son filet, son rayon, son ombre. */
.reedition {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
gap: var(--espace-3);
min-width: 0;
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);
}
p {
@ -84,15 +95,15 @@
.filtre {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: var(--espace-2);
}
input[type='search'] {
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;
}
@ -108,12 +119,14 @@
display: flex;
flex-direction: column;
gap: var(--espace-1);
box-sizing: border-box;
width: min(36rem, 100%);
max-height: 16rem;
margin: 0;
padding: var(--espace-1) var(--espace-2);
padding: var(--espace-2) var(--espace-3);
overflow-y: auto;
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
list-style: none;
}

View file

@ -35,7 +35,14 @@
//
// La réédition (Reedition.svelte) passe par la même production, sa
// sélection dans la demande.
//
// Habillage : les réglages, le papier, les commandes, l'annonce et la
// planche produite vivent dans la carte de la planche ; « Produire la
// planche » en est l'action principale. L'annonce se pose sur le fond
// sourd de la carte, la planche produite sur le fond de l'information. La
// réédition est une carte à elle.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import Papier from '../sorties/Papier.svelte';
@ -161,83 +168,87 @@
<p>{t('badges.introduction')}</p>
{#if charge !== null}
<div class="reglages">
{#if offertes.length > 1}
<label class="reglage">
<span>{t('badges.source.titre')}</span>
<select data-champ="source" value={cleSource(source)} onchange={(evenement) => (cleChoisie = evenement.currentTarget.value)}>
{#each offertes as offerte (cleSource(offerte))}
<option value={cleSource(offerte)}>{t(libelleSource(offerte).cle, libelleSource(offerte).details)}</option>
{/each}
</select>
</label>
{/if}
<label class="reglage">
<span>{t('badges.imposition.titre')}</span>
<select data-champ="imposition" value={imposition} onchange={(evenement) => (imposition = evenement.currentTarget.value)}>
{#each IMPOSITIONS_OFFERTES as valeur (valeur)}
<option value={valeur}>{t(cleImposition(valeur))}</option>
{/each}
</select>
</label>
<label class="reglage">
<span>{t('badges.ordre.titre')}</span>
<select data-champ="ordre" value={ordre} onchange={(evenement) => (ordre = evenement.currentTarget.value)}>
{#each ORDRES_OFFERTS as valeur (valeur)}
<option value={valeur}>{t(cleOrdre(valeur))}</option>
{/each}
</select>
</label>
</div>
{#if $seance.papier !== null || attente !== null}
<Papier onChoisi={papierChoisi} />
{/if}
<div class="commandes">
<Commande cle="badges.commande.preparer" nom="preparerPlanche" executer={() => preparer(null)} {gestes} />
{#if bloque}
<button type="button" data-commande="produirePlanche" aria-disabled="true" aria-describedby={`${uid}-bloque`}
>{t('badges.commande.produire')}</button
>
<p class="explication" id={`${uid}-bloque`}>{t('badges.produire.bloque')}</p>
{:else}
<Commande cle="badges.commande.produire" nom="produirePlanche" executer={() => produire(null)} {gestes} />
{/if}
</div>
{#if refusRepris !== null}
<Refus refus={refusRepris} lever={() => (refusRepris = null)} {gestes} />
{/if}
{#if montree !== null}
<div class="annonce" data-annonce-planche role="group" aria-labelledby={`${uid}-annonce`}>
<h3 id={`${uid}-annonce`}>{t('badges.annonce.titre')}</h3>
<ul>
{#each phrases as phrase, rang (rang)}
<li>{t(phrase.cle, phrase.details)}</li>
{/each}
</ul>
{#if montree.annonce.refus !== null}
<Refus refus={montree.annonce.refus} {gestes} />
{/if}
</div>
{/if}
{#if produite !== null}
<div class="produite">
{#if produite.chemin !== null}
<p>
<span>{t('badges.produite.chemin')}</span>
<span class="chemin" data-chemin-planche>{produite.chemin}</span>
</p>
{#if explorateur.disponible && !produite.ailleurs}
<Commande cle="badges.commande.ouvrirDossier" nom="ouvrirDossier" executer={() => facade.ouvrirExplorateur()} />
<Carte titre={t('badges.planche.titre')} niveau={3}>
<div class="planche">
<div class="reglages">
{#if offertes.length > 1}
<label class="reglage">
<span>{t('badges.source.titre')}</span>
<select data-champ="source" value={cleSource(source)} onchange={(evenement) => (cleChoisie = evenement.currentTarget.value)}>
{#each offertes as offerte (cleSource(offerte))}
<option value={cleSource(offerte)}>{t(libelleSource(offerte).cle, libelleSource(offerte).details)}</option>
{/each}
</select>
</label>
{/if}
{:else}
<p data-chemin-planche>{t('badges.produite.telechargee', { nom: produite.nom })}</p>
<label class="reglage">
<span>{t('badges.imposition.titre')}</span>
<select data-champ="imposition" value={imposition} onchange={(evenement) => (imposition = evenement.currentTarget.value)}>
{#each IMPOSITIONS_OFFERTES as valeur (valeur)}
<option value={valeur}>{t(cleImposition(valeur))}</option>
{/each}
</select>
</label>
<label class="reglage">
<span>{t('badges.ordre.titre')}</span>
<select data-champ="ordre" value={ordre} onchange={(evenement) => (ordre = evenement.currentTarget.value)}>
{#each ORDRES_OFFERTS as valeur (valeur)}
<option value={valeur}>{t(cleOrdre(valeur))}</option>
{/each}
</select>
</label>
</div>
{#if $seance.papier !== null || attente !== null}
<Papier onChoisi={papierChoisi} />
{/if}
<div class="commandes">
<Commande cle="badges.commande.preparer" nom="preparerPlanche" executer={() => preparer(null)} {gestes} />
{#if bloque}
<button type="button" data-commande="produirePlanche" aria-disabled="true" aria-describedby={`${uid}-bloque`}
>{t('badges.commande.produire')}</button
>
<p class="explication" id={`${uid}-bloque`}>{t('badges.produire.bloque')}</p>
{:else}
<Commande cle="badges.commande.produire" nom="produirePlanche" variante="principale" executer={() => produire(null)} {gestes} />
{/if}
</div>
{#if refusRepris !== null}
<Refus refus={refusRepris} lever={() => (refusRepris = null)} {gestes} />
{/if}
{#if montree !== null}
<div class="annonce" data-annonce-planche role="group" aria-labelledby={`${uid}-annonce`}>
<h3 id={`${uid}-annonce`}>{t('badges.annonce.titre')}</h3>
<ul>
{#each phrases as phrase, rang (rang)}
<li>{t(phrase.cle, phrase.details)}</li>
{/each}
</ul>
{#if montree.annonce.refus !== null}
<Refus refus={montree.annonce.refus} {gestes} />
{/if}
</div>
{/if}
{#if produite !== null}
<div class="produite">
{#if produite.chemin !== null}
<p>
<span>{t('badges.produite.chemin')}</span>
<span class="chemin" data-chemin-planche>{produite.chemin}</span>
</p>
{#if explorateur.disponible && !produite.ailleurs}
<Commande cle="badges.commande.ouvrirDossier" nom="ouvrirDossier" executer={() => facade.ouvrirExplorateur()} />
{/if}
{:else}
<p data-chemin-planche>{t('badges.produite.telechargee', { nom: produite.nom })}</p>
{/if}
</div>
{/if}
</div>
{/if}
</Carte>
<Reedition {charge} executer={produire} {gestes} />
{/if}
@ -247,7 +258,7 @@
section {
display: flex;
flex-direction: column;
gap: var(--espace-3);
gap: var(--espace-4);
min-width: 0;
}
@ -265,40 +276,67 @@
margin: 0;
}
.reglages,
.commandes {
.planche {
display: flex;
flex-direction: column;
gap: var(--espace-4);
}
/* Les réglages : chaque libellé, en petites capitales d'encre secondaire,
au-dessus de son sélecteur. */
.reglages {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-3);
align-items: flex-end;
gap: var(--espace-3) var(--espace-5);
}
.reglage {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
.reglage > span {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
.commandes {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2);
align-items: center;
gap: var(--espace-3);
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-ligne-filet);
}
select {
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;
}
/* « Produire » désactivé tant qu'un refus tient : son filet tireté et son
texte secondaire le distinguent, et son explication le suit. */
/* « Produire » désactivé tant qu'un refus tient : à la hauteur de l'action
principale, son filet tireté et son texte secondaire le distinguent, et
son explication le suit. */
button {
padding: var(--espace-1) var(--espace-3);
min-height: var(--hauteur-cible);
padding: 0 var(--espace-4);
border: 1px dashed 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-secondaire);
font: inherit;
font-size: var(--taille-texte);
font-weight: bold;
cursor: not-allowed;
}
@ -317,9 +355,10 @@
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond-sourd);
}
ul {
@ -333,8 +372,12 @@
.produite {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-information-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-information-fond);
}
/* Un chemin long se coupe où il faut, plutôt que de déborder. */

View file

@ -0,0 +1,77 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'avancement d'une génération dans un vrai moteur de rendu (§ 5.10) : la
// barre de progression se voit. Le composant est monté seul, dans un
// conteneur de largeur fixée ; la barre se lit par son rôle, son
// remplissage par son premier enfant, les couleurs par leurs jetons, dans
// les deux thèmes.
//
// 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 } 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, sousTheme } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Avancement from './Avancement.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const montes = [];
afterEach(() => {
for (const { composant, conteneur } of montes.splice(0)) {
unmount(composant);
conteneur.remove();
}
});
// Monte Avancement dans un conteneur de 600 px.
function monter(props) {
const conteneur = document.createElement('div');
conteneur.style.width = '600px';
document.body.prepend(conteneur);
const contexte = new Map([
['t', t],
['horloge', creerHorlogePilotee()],
]);
const composant = mount(Avancement, { target: conteneur, props, context: contexte });
flushSync();
montes.push({ composant, conteneur });
return conteneur;
}
describe('l’avancement se voit (§ 5.10)', () => {
test('la barre occupe la largeur de son conteneur ; son remplissage, au prorata, se peint de la teinte de l’action à 3:1 sur la piste, dans les deux thèmes', async () => {
const conteneur = monter({ fait: 250, total: 1000, annuler: () => {} });
const barre = conteneur.querySelector('[role="progressbar"]');
expect(barre.getAttribute('aria-valuenow')).toBe('250');
expect(barre.getBoundingClientRect().width).toBeGreaterThanOrEqual(0.9 * conteneur.getBoundingClientRect().width);
const plein = barre.firstElementChild;
expect(plein.getBoundingClientRect().width / barre.clientWidth).toBeCloseTo(0.25, 2);
expect(barre.getBoundingClientRect().height).toBeGreaterThanOrEqual(8);
for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => {
const remplissage = composantes(getComputedStyle(plein).backgroundColor);
const piste = composantes(getComputedStyle(barre).backgroundColor);
expect(remplissage.join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
expect(piste.join(), theme).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join());
expect(contraste(remplissage, piste), theme).toBeGreaterThanOrEqual(3);
});
}
});
test('le compte se lit en mots au-dessus de la barre, et « Annuler la génération » la suit', () => {
const conteneur = monter({ fait: 250, total: 1000, annuler: () => {} });
const barre = conteneur.querySelector('[role="progressbar"]');
const texte = [...conteneur.querySelectorAll('p')].find((p) => p.textContent === t('generation.avancement.texte', { fait: 250, total: 1000 }));
expect(texte).toBeDefined();
expect(texte.getBoundingClientRect().bottom).toBeLessThanOrEqual(barre.getBoundingClientRect().top);
const annuler = conteneur.querySelector('[data-commande="annuler"]');
expect(annuler.getBoundingClientRect().top).toBeGreaterThanOrEqual(barre.getBoundingClientRect().bottom);
});
});

View file

@ -8,6 +8,10 @@
// génération », qui appelle annuler. fait et total viennent du contrôleur
// de génération ; total est le produit du nombre de propositions et du
// compte d'arrêt, jamais nul pendant un calcul.
//
// La barre occupe la largeur de son bloc : sa piste sur le fond sourd de la
// carte, son remplissage de la teinte de l'action principale, qui s'y
// détache à 3:1 (SIGNES, actionPrincipale).
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
@ -39,23 +43,28 @@
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
}
p {
margin: 0;
font-variant-numeric: tabular-nums;
}
.barre {
box-sizing: border-box;
width: min(30rem, 100%);
height: var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
width: 100%;
height: 0.75rem;
overflow: hidden;
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-pilule);
background-color: var(--couleur-carte-fond-sourd);
}
.plein {
height: 100%;
background-color: var(--couleur-page-texte);
background-color: var(--couleur-action-fond);
}
</style>

View file

@ -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);
});
}
});
});

View file

@ -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');
});
});

View file

@ -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 @@
<p>{t('generation.coeurs.note')}</p>
</fieldset>
{#if mesure === null}
<p data-duree>{t('generation.duree.inconnue')}</p>
<p class="note" data-duree>{t('generation.duree.inconnue')}</p>
{:else}
<p data-duree>{t('generation.duree.observee', mesure)}</p>
<p data-estimation>{t('generation.duree.estimee', estimation)}</p>
<p class="note" data-duree>{t('generation.duree.observee', mesure)}</p>
<p class="note" data-estimation>{t('generation.duree.estimee', estimation)}</p>
{/if}
</div>
@ -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);
}
</style>

View file

@ -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}
</section>
<Infobulle id={idExplication} ouverte={expliquee} fermer={taire} />
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} bind:refusCoeurs />
<div class="lancer">
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
</div>
{#if question !== null}
<div class="question" data-question="longue" role="status">
<p>{t('generation.longue.question', question)}</p>
<div class="issues">
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
<Carte titre={t('generation.reglages.titre')} niveau={3}>
<div class="reglages">
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} bind:refusCoeurs />
<div class="lancer">
<Commande cle="generation.commande.generer" nom="generer" variante="principale" executer={() => lancer(false)} />
</div>
{#if question !== null}
<div class="question" data-question="longue" role="status">
<p>{t('generation.longue.question', question)}</p>
<div class="issues">
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
</div>
</div>
{/if}
{#if $generation.enCours}
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
{:else if issue !== null}
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
{:else if $generation.annulee === 'commande'}
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
{:else if $generation.annulee === 'seance'}
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
{/if}
</div>
{/if}
{#if $generation.enCours}
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
{:else if issue !== null}
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
{:else if $generation.annulee === 'commande'}
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
{:else if $generation.annulee === 'seance'}
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
{/if}
</Carte>
{/if}
</section>
@ -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 {

View file

@ -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);
}
});
});

View file

@ -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 @@
<div class="apercu" data-ecran="apercu">
<div class="barre" role="group" aria-label={t('impression.barre')}>
<h2>{t('impression.titre')}</h2>
{#if cadre !== null}
<span class="mode" data-mode-libelle="cadre">{t(cadre.cle)}</span>
{/if}
<div class="vues" role="group" aria-label={t('impression.vue.titre')}>
<div class="tete">
<h2>{t('impression.titre')}</h2>
{#if cadre !== null}
<span class="mode" data-mode-libelle="cadre">{t(cadre.cle)}</span>
{/if}
</div>
<div class="groupe vues" role="group" aria-label={t('impression.vue.titre')}>
{#each VUES_IMPRESSION as vue (vue)}
<button type="button" data-commande="vue" data-cible={vue} aria-pressed={options.vue === vue} onclick={() => choisirVue(vue)}
>{t(cleVue(vue))}</button
>
{/each}
</div>
{#if options.vue === 'plan'}
<label class="reglage">
<span>{t('impression.tour.titre')}</span>
<select data-champ="tour" value={options.tour} onchange={(evenement) => ui.reglerApercu({ tour: Number(evenement.currentTarget.value) })}>
{#each tours as tour (tour)}
<option value={tour}>{t('impression.tour.option', { tour })}</option>
{/each}
</select>
</label>
<button type="button" data-commande="listes" aria-pressed={options.listes} onclick={() => ui.reglerApercu({ listes: !options.listes })}
>{t('impression.listes')}</button
>
<label class="reglage">
<span>{t('impression.decoupe.titre')}</span>
<select data-champ="decoupe" value={options.decoupe} onchange={(evenement) => ui.reglerApercu({ decoupe: evenement.currentTarget.value })}>
{#each DECOUPES as decoupe (decoupe)}
<option value={decoupe}>{t(cleDecoupe(decoupe))}</option>
{/each}
</select>
</label>
{:else if options.vue === 'tables'}
<button type="button" data-commande="parTour" aria-pressed={options.parTour} onclick={() => ui.reglerApercu({ parTour: !options.parTour })}
>{t('impression.parTour')}</button
>
{#if options.vue === 'plan' || options.vue === 'tables'}
<div class="groupe" role="group" aria-label={t('impression.groupe.reglages')}>
{#if options.vue === 'plan'}
<label class="reglage">
<span>{t('impression.tour.titre')}</span>
<select data-champ="tour" value={options.tour} onchange={(evenement) => ui.reglerApercu({ tour: Number(evenement.currentTarget.value) })}>
{#each tours as tour (tour)}
<option value={tour}>{t('impression.tour.option', { tour })}</option>
{/each}
</select>
</label>
<button type="button" data-commande="listes" aria-pressed={options.listes} onclick={() => ui.reglerApercu({ listes: !options.listes })}
>{t('impression.listes')}</button
>
<label class="reglage">
<span>{t('impression.decoupe.titre')}</span>
<select data-champ="decoupe" value={options.decoupe} onchange={(evenement) => ui.reglerApercu({ decoupe: evenement.currentTarget.value })}>
{#each DECOUPES as decoupe (decoupe)}
<option value={decoupe}>{t(cleDecoupe(decoupe))}</option>
{/each}
</select>
</label>
{:else}
<button type="button" data-commande="parTour" aria-pressed={options.parTour} onclick={() => ui.reglerApercu({ parTour: !options.parTour })}
>{t('impression.parTour')}</button
>
{/if}
</div>
{/if}
{#if !options.question}
<Papier />
<Commande cle="impression.commande.imprimer" nom="imprimer" executer={imprimer} />
<div class="groupe" role="group" aria-label={t('impression.groupe.sortie')}>
<Papier />
<Commande cle="impression.commande.imprimer" nom="imprimer" variante="principale" executer={imprimer} />
</div>
{/if}
<Commande cle="impression.commande.fermer" nom="fermerApercu" executer={fermer} />
<div class="fermer">
<Commande cle="impression.commande.fermer" nom="fermerApercu" executer={fermer} />
</div>
</div>
{#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 {

View file

@ -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}`],

View file

@ -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)"],

View file

@ -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'],

View file

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

View file

@ -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']),

View file

@ -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'));
});
}
});
});

View file

@ -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);
</script>
{#snippet effacerListe()}
<Commande cle="propositions.commande.effacer" executer={effacer} />
{/snippet}
<section data-section="propositions">
<h2>{t('coquille.section.propositions')}</h2>
{#if charge !== null && charge.propositions.length === 0}
<p>{t('propositions.aucune')}</p>
<Carte>
<p>{t('propositions.aucune')}</p>
</Carte>
{/if}
{#if evaluee.refus !== null}
@ -149,55 +160,55 @@
{/if}
{#if vue !== null}
<div class="ordre" data-ordre>
{#each vue.ordre as morceaux, rang (rang)}
<p>
{#each morceaux as morceau, place (place)}
{#if morceau.colonne === null}
{t(morceau.cle, morceau.details)}
{:else}
<!-- Le nom est un terme, non une commande : aucun rôle n'annonce
d'action, et le focus ne sert qu'à montrer au clavier la
colonne que le pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
class="critere"
class:actif={surlignee === morceau.colonne}
data-critere={morceau.critere}
tabindex="0"
aria-describedby={decrit(`critere:${morceau.critere}`)}
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
onmouseleave={() => sortir(morceau, false)}
onfocus={(evenement) => entrer(evenement, morceau, true)}
onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)}</span
>
{/if}
{/each}
</p>
{/each}
</div>
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
<Carte titre={t('propositions.tableau.titre')} niveau={3} plein actions={charge.propositions.length > 0 ? effacerListe : null}>
<div class="ordre" data-ordre>
{#each vue.ordre as morceaux, rang (rang)}
<p>
{#each morceaux as morceau, place (place)}
{#if morceau.colonne === null}
{t(morceau.cle, morceau.details)}
{:else}
<!-- Le nom est un terme, non une commande : aucun rôle n'annonce
d'action, et le focus ne sert qu'à montrer au clavier la
colonne que le pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
class="critere"
class:actif={surlignee === morceau.colonne}
data-critere={morceau.critere}
tabindex="0"
aria-describedby={decrit(`critere:${morceau.critere}`)}
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
onmouseleave={() => sortir(morceau, false)}
onfocus={(evenement) => entrer(evenement, morceau, true)}
onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)}</span
>
{/if}
{/each}
</p>
{/each}
</div>
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
</Carte>
{:else if charge !== null && charge.propositions.length > 0}
<div class="effacer">{@render effacerListe()}</div>
{/if}
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{#if charge !== null}
<div class="cible" data-cible-redondance>
<Champ
cle={entreeCible.cle}
etiquette={entreeCible.etiquette}
valeur={cibleRedondance(charge)}
lire={lireEntier}
executer={changerCible}
/>
<p>{t('propositions.cible.aide')}</p>
</div>
{/if}
{#if charge !== null && charge.propositions.length > 0}
<div class="effacer">
<Commande cle="propositions.commande.effacer" executer={effacer} />
</div>
<Carte>
<div class="cible" data-cible-redondance>
<Champ
cle={entreeCible.cle}
etiquette={entreeCible.etiquette}
valeur={cibleRedondance(charge)}
lire={lireEntier}
executer={changerCible}
/>
<p class="aide">{t('propositions.cible.aide')}</p>
</div>
</Carte>
{/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);
}
</style>

View file

@ -28,6 +28,16 @@
// decrit('colonne:<nom>') 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)));
</script>
{#snippet raisons(ligne)}
@ -65,7 +80,7 @@
{#snippet mesures(ligne)}
{#if ligne.comparable}
{#each ligne.cellules as cellule (cellule.colonne)}
<td data-colonne={cellule.colonne} data-marque={marque(cellule.colonne)} class="nombre" class:surlignee={cellule.colonne === surlignee}>{texteCellule(cellule, outils)}</td>
<td data-colonne={cellule.colonne} data-marque={marque(cellule.colonne)} class:nombre={nombres.has(cellule.colonne)} class:surlignee={cellule.colonne === surlignee}>{texteCellule(cellule, outils)}</td>
{/each}
<td data-colonne="imposes">
{#each ligne.imposes as phrase, rang (rang)}
@ -89,7 +104,7 @@
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(ligne.id)}</span>
{#if ligne.retenue}
<span class="marque">{t('propositions.marque.retenue')}</span>
<Pastille sorte="statut" valeur="retenu" icone="coche" libelle={t('propositions.marque.retenue')} />
{/if}
{@render violations(ligne)}
</td>
@ -105,9 +120,20 @@
{/snippet}
<div class="defilement">
<table>
<caption>{t('propositions.tableau.titre')}</caption>
<table aria-label={t('propositions.tableau.titre')}>
{#each GROUPES_COLONNES as segment, rang (rang)}
<colgroup span={segment.etendue}></colgroup>
{/each}
<thead>
<tr class="groupes">
{#each GROUPES_COLONNES as segment, rang (rang)}
{#if segment.cle === null}
<td></td>
{:else}
<th scope="colgroup" colspan={segment.etendue}>{t(segment.cle)}</th>
{/if}
{/each}
</tr>
<tr>
{#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}
<tbody>
<tr data-retenu={vue.retenu.id} class="retenu">
<th scope="row" data-colonne="rang">{t('propositions.retenu.titre')}</th>
<th scope="row" data-colonne="rang"
><span class="titre-retenu"><span class="signe-retenu"><Icone nom="coche" /></span>{t('propositions.retenu.titre')}</span></th
>
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(vue.retenu.id)}</span>
<p>{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}</p>
@ -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;

View file

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

View file

@ -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);
});
});

View file

@ -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());
});
}
});
});

View file

@ -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 @@
<p class="explication">{t('impression.papier.explication')}</p>
<div class="choix">
{#each PAPIERS_OFFERTS as choix (choix)}
<div data-cible={choix}>
<div class="tuile" data-cible={choix}>
<Commande cle={clesPapier(choix).choix} nom="choisirPapier" executer={() => choisir(choix)} />
</div>
{/each}
@ -53,8 +58,8 @@
</div>
{:else}
<div class="papier choisi" data-papier="choisi">
<span>{t('impression.papier.titre')}</span>
<span class="donnee">{t(clesPapier(papier).nom)}</span>
<span class="titre">{t('impression.papier.titre')}</span>
<Pastille sorte="statut" valeur="neutre" icone="imprimer" libelle={t(clesPapier(papier).nom)} />
<Commande cle="impression.papier.changer" nom="changerPapier" executer={() => (changer = true)} />
</div>
{/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;
}
</style>