[IMP] event screen: header card, state steps, mode band, phase tabs

The event screen showed its states and sections as flat button rows. A
header card now carries the name, the counts and the fixed « Modifier »
place; the four plan states read as steps; sections group into
Préparer, Placer and Produire. A band under the app header states the
mode edge to edge and names the command that leaves it; the mode frame
stays around the work surface. A warning's action is no longer clipped.
Checked: evenement node and browser tests, 7 mutations caught.

--- FR ---

[IMP] écran d'événement : carte d'en-tête, étapes, bande de mode, phases

L'écran montrait ses états et ses sections en rangées de boutons plates.
Une carte d'en-tête porte le nom, les comptes et la place fixe de
« Modifier » ; les quatre états du plan se lisent en étapes ; les
sections se groupent en Préparer, Placer et Produire. Une bande sous le
bandeau dit le mode de bord à bord et nomme la commande qui en sort ; le
cadre de mode reste autour de la surface. L'action d'un avertissement
n'est plus rognée. Vérifié : node et navigateur, 7 mutations prises.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 00:58:07 -04:00
parent 229a2d4067
commit 8b69b63a32
14 changed files with 1434 additions and 139 deletions

View file

@ -9,8 +9,19 @@
// puis son remède en Commande, par actionRemede (primitives/commande.js),
// confirmé en place quand actionRemede le demande — « Débloquer ».
// Un avertissement n'empêche rien.
//
// Chacun est un message d'avis : le fond, le filet et l'encre de l'avis,
// le triangle de sa marque devant le texte. Le remède se pose en haut à
// droite, sur la première ligne du texte, et non sous lui : dans la zone
// des annonces, qui défile à la hauteur de la tête, l'action d'un
// avertissement se voit dès que son début se voit, et n'est jamais
// rognée par le bas de la zone. Ce qu'il nomme — propositions écartées,
// placements en dérive — vient dessous, sur toute la largeur du message :
// l'action n'étroitise que la première phrase. Une zone trop étroite pour
// le texte et l'action côte à côte passe l'action sous le texte.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
import { actionRemede } from '../primitives/commande.js';
import { avertissementsOrdonnes } from './modele.js';
@ -26,7 +37,19 @@
{#each liste as avertissement, rang (rang)}
{@const action = actionRemede(avertissement.remede, { seance, ui })}
<div class="avertissement" role="status" data-avertissement={avertissement.code}>
<p>{t(avertissement.code, avertissement.details)}</p>
<span class="signe"><Icone nom="triangle" taille={18} /></span>
<p class:seul={action === null}>{t(avertissement.code, avertissement.details)}</p>
{#if action !== null}
<div class="action">
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
</div>
{/if}
{#if avertissement.fautives !== undefined && avertissement.fautives.length > 0}
<ul>
{#each avertissement.fautives as fautive, n (n)}
@ -62,37 +85,77 @@
{/each}
</ul>
{/if}
{#if action !== null}
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
</div>
{/each}
</section>
{/if}
<style>
/* Des messages serrés : dans la zone des annonces, à la hauteur de la
tête, les deux avertissements d'un journal abîmé, de deux lignes
chacun, se voient l'un et l'autre. La liste est un conteneur de
largeur, que lit la disposition étroite ci-dessous. */
.avertissements {
display: flex;
flex-direction: column;
gap: var(--espace-2);
gap: var(--espace-1);
container-type: inline-size;
}
/* Une grille : le signe, le texte, l'action en haut à droite ; sous le
texte et l'action, les listes, sur toute leur largeur. Sans action, le
texte prend sa place. */
.avertissement {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--espace-1) var(--espace-2);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
border: 1px solid var(--couleur-avis-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-avis-fond);
color: var(--couleur-avis);
font-size: var(--taille-texte);
}
.signe {
display: flex;
grid-row: 1;
padding-top: 0.125rem;
color: var(--couleur-avis-marque);
}
p {
grid-row: 1;
grid-column: 2;
}
p.seul {
grid-column: 2 / 4;
}
.action {
grid-row: 1;
grid-column: 3;
}
ul {
grid-column: 2 / 4;
}
/* Une liste trop étroite pour le texte et l'action côte à côte — moins
de 26rem — passe l'action sous le texte. */
@container (max-width: 26rem) {
p {
grid-column: 2 / 4;
}
.action {
grid-row: auto;
grid-column: 2 / 4;
}
}
p,
ul {
margin: 0;

View file

@ -0,0 +1,85 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La bande de mode (§ 8.4) : sous le bandeau, de bord à bord de la
// fenêtre, l'icône du mode, son libellé — celui du cadre et du
// bandeau — en gras, puis la phrase qui dit ce que le mode permet et la
// commande qui en sort (bandeDeMode, modele.js). Elle s'ajoute au cadre de
// mode, qui reste autour de la surface de travail, et ne le remplace pas.
//
// Son fond et son filet sont ceux du mode, son icône de la teinte du
// cadre, son texte de l'encre de la page (marques.js : bandeMode et les
// signes bandeLecture, bandeEcriture, bandeBloque). Région d'état, elle
// annonce le mode quand il change.
//
// Sa marge haute négative vaut le retrait de l'écran (App.svelte) : la
// bande touche le bandeau. Ses marges latérales négatives valent la
// distance du contenu de la colonne au bord de la fenêtre : le retrait de
// l'écran tant que la fenêtre ne dépasse pas --largeur-page, ce retrait
// plus la moitié de l'excédent au-delà, où la colonne se centre. La bande
// va donc d'un bord à l'autre de la fenêtre à toute largeur, et son retrait
// intérieur, de même valeur, aligne son texte sur la colonne. 100vw égale
// la largeur utile de la fenêtre parce que l'écran de l'événement tient
// dans sa hauteur (Evenement.svelte) : aucune barre de défilement
// verticale n'en retranche, et la page ne défile pas en largeur. Elle tient en
// une ligne aux largeurs usuelles, de même hauteur dans les trois modes :
// changer de mode ne déplace pas la place fixe du cadre, sous elle. Mince
// — une ligne de petit corps —, elle prend au plan à peine plus que le
// retrait de l'écran qu'elle remplace.
import { getContext } from 'svelte';
import Icone from '../primitives/Icone.svelte';
import { bandeDeMode } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const bande = $derived(bandeDeMode($seance));
</script>
{#if bande !== null}
<div class="bande" class:ecriture={bande.mode === 'ecriture'} class:bloque={bande.mode === 'bloque'} role="status">
<span class="signe"><Icone nom={bande.icone} /></span>
<strong>{t(bande.cle)}</strong>
<span>{t(bande.explication)}</span>
</div>
{/if}
<style>
.bande {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1) var(--espace-2);
margin: calc(-1 * var(--espace-4)) calc(-1 * max(var(--espace-4), (100vw - var(--largeur-page)) / 2 + var(--espace-4))) 0;
padding: 0.0625rem max(var(--espace-4), (100vw - var(--largeur-page)) / 2 + var(--espace-4));
border-bottom: 1px solid var(--couleur-mode-lecture-filet);
background-color: var(--couleur-mode-lecture-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte-petit);
}
.bande.ecriture {
border-bottom-color: var(--couleur-mode-ecriture-filet);
background-color: var(--couleur-mode-ecriture-fond);
}
.bande.bloque {
border-bottom-color: var(--couleur-mode-bloque-filet);
background-color: var(--couleur-mode-bloque-fond);
}
.signe {
display: flex;
color: var(--couleur-cadre-lecture);
}
.ecriture .signe {
color: var(--couleur-cadre-ecriture);
}
.bloque .signe {
color: var(--couleur-cadre-bloque);
}
</style>

View file

@ -6,13 +6,24 @@
// de travail, dont la forme dit le mode — tirets en lecture, plein en
// écriture, double sur un plan bloqué —, et une barre qui porte, de gauche
// à droite, la place fixe, le libellé de mode, puis « Défaire » et
// « Refaire » (§ 8.3). Sous la barre vient la tête, tete, quand elle est
// donnée — l'en-tête de l'événement et la navigation des sections —, puis
// les annonces, annonces, quand il y en a — support rompu et
// avertissements —, puis la surface, children. La barre ouvre le cadre :
// rien ne la précède dans l'écran de l'événement, si bien qu'un refus posé
// dans la tête, ou un nom qui s'étend sur plusieurs lignes, allonge la tête
// sans déplacer la place fixe (§ 8.4). Tête, annonces et surface défilent
// « Refaire » (§ 8.3), puis les outils, outils — la suite des états du
// plan —, et les actions, actions — « Imprimer… », calée à droite —, quand
// ils sont donnés. Outils et actions suivent la place fixe et ne la
// déplacent donc jamais : un refus posé à côté de la place fixe les
// repousse sur la ligne suivante, à côté du libellé, sans ligne de plus.
// Une barre de moins de 80rem — la fenêtre de 1 366 px lui en donne
// davantage — passe les outils sous les commandes et les actions, sur une
// ligne à eux : la barre garde deux lignes là où, dans l'ordre du
// document, elle en prendrait trois.
//
// Sous la barre vient la tête, tete, quand elle est donnée — l'en-tête de
// l'événement et la navigation des sections —, puis les annonces,
// annonces, quand il y en a — support rompu et avertissements —, puis la
// surface, children. La barre ouvre le cadre, et seule la bande de mode,
// de hauteur constante, le précède dans l'écran de l'événement : un refus
// posé dans la barre ou la tête, ou un nom qui s'étend sur plusieurs
// lignes, allonge la barre ou la tête sans déplacer la place fixe
// (§ 8.4). Tête, annonces et surface défilent
// chacune dans sa zone : des avertissements nombreux ne poussent pas la
// surface hors du cadre, ni le cadre hors de la fenêtre. La surface porte
// le plan, que l'opérateur regarde : elle réclame les trois quarts du
@ -43,13 +54,26 @@
// côté de ce qui le nomme (§ 8.8). « Refaire » n'est là que lorsqu'un
// retour se refait ; « Défaire » est toujours là, et la séance refuse à
// côté de lui ce qui ne se défait pas.
//
// « Modifier » est l'action principale de la lecture, pleine de la teinte
// de l'action ; « Revenir en lecture » et « Débloquer » ne le sont pas.
// Chaque commande de la barre a la hauteur d'une cible principale, et la
// barre celle d'une ligne de ces commandes : passer d'une commande de la
// place fixe à l'autre ne déplace ni la tête ni la surface. Le libellé de
// mode est une pastille aux couleurs de la bande de mode, centrée dans
// cette hauteur, d'encre neutre.
//
// La barre et la tête forment la carte d'en-tête de l'événement : la
// barre sur le fond sourd de la carte, la tête sur son fond, séparées et
// closes par le filet de la carte. Le cadre, lui, reste sur le fond de la
// page, que son trait contraste (marques.js).
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { CONFIRMATION_DEBLOCAGE } from '../primitives/commande.js';
import Refus from '../primitives/Refus.svelte';
import { cadreDeMode, commandeFixe, repriseOfferte } from './modele.js';
let { children, tete = null, annonces = null } = $props();
let { children, tete = null, annonces = null, outils = null, actions = null } = $props();
const t = getContext('t');
const seance = getContext('seance');
@ -75,7 +99,7 @@
{#if commande === 'format'}
<Refus refus={$seance.refus} />
{:else if commande === 'modifier'}
<Commande cle="communs.commande.modifier" nom="modifier" executer={() => seance.passerEnEcriture()} />
<Commande cle="communs.commande.modifier" nom="modifier" variante="principale" executer={() => seance.passerEnEcriture()} />
{:else if commande === 'debloquer'}
<Commande
cle="communs.commande.debloquer"
@ -88,7 +112,11 @@
{/if}
</div>
{#if cadre !== null}
<span class="libelle" data-mode-libelle="cadre">{t(cadre.cle)}</span>
<span class="porte-libelle">
<span class="libelle" class:plein={forme === 'plein'} class:double={forme === 'double'} data-mode-libelle="cadre"
>{t(cadre.cle)}</span
>
</span>
{/if}
{#if commande !== 'format'}
<div class="historique" data-historique>
@ -98,6 +126,16 @@
{/if}
</div>
{/if}
{#if outils !== null}
<div class="outils">
{@render outils()}
</div>
{/if}
{#if actions !== null}
<div class="actions">
{@render actions()}
</div>
{/if}
</div>
{#if verrou !== null}
<div class="verrou" data-verrou>
@ -176,8 +214,10 @@
gap: var(--espace-2);
max-height: 50%;
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
border-bottom: 1px solid var(--couleur-page-filet);
padding: var(--espace-1) var(--espace-3);
border-bottom: 1px solid var(--couleur-carte-filet);
background-color: var(--couleur-carte-fond-sourd);
container: barre / inline-size;
}
.ligne {
@ -187,6 +227,28 @@
gap: var(--espace-2) var(--espace-3);
}
/* Les outils et les actions, centrés sur la hauteur d'une cible
principale ; les actions calées à droite de leur ligne. */
.outils,
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
min-height: var(--hauteur-cible);
gap: var(--espace-2);
}
.actions {
margin-left: auto;
}
@container barre (max-width: 80rem) {
.outils {
order: 1;
min-height: 0;
}
}
.fixe {
display: flex;
flex-wrap: wrap;
@ -194,13 +256,40 @@
gap: var(--espace-2);
}
/* Le bouton de chaque commande de la barre — celui de la Commande, non
ceux du refus posé à côté — a la hauteur d'une cible principale. */
.fixe > :global(div > button),
.historique > :global(div > button) {
min-height: var(--hauteur-cible);
}
.porte-libelle {
display: flex;
align-items: center;
min-height: var(--hauteur-cible);
}
/* Le libellé de mode : une pastille du fond et du filet de la bande de
mode, d'encre neutre, en gras. */
.libelle {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
padding: 0.125rem var(--espace-3);
border: 1px solid var(--couleur-mode-lecture-filet);
border-radius: var(--rayon-pilule);
background-color: var(--couleur-mode-lecture-fond);
color: var(--couleur-page-texte);
font-weight: bold;
}
.libelle.plein {
border-color: var(--couleur-mode-ecriture-filet);
background-color: var(--couleur-mode-ecriture-fond);
}
.libelle.double {
border-color: var(--couleur-mode-bloque-filet);
background-color: var(--couleur-mode-bloque-fond);
}
.historique {
display: flex;
flex-wrap: wrap;
@ -228,7 +317,8 @@
}
/* La tête ne prend jamais plus de 35 % du cadre : au-delà, elle défile,
et la surface garde sa place. */
et la surface garde sa place. Elle n'a pas de retrait en bas : le trait
des onglets de la navigation se pose sur son filet. */
.tete {
display: flex;
flex: 0 0 auto;
@ -236,8 +326,9 @@
gap: var(--espace-2);
max-height: 35%;
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
border-bottom: 1px solid var(--couleur-page-filet);
padding: var(--espace-2) var(--espace-3) 0;
border-bottom: 1px solid var(--couleur-carte-filet);
background-color: var(--couleur-carte-fond);
}
/* Les annonces défilent dans leur zone, au-delà de sa hauteur. */
@ -277,7 +368,7 @@
.cote > .contenu {
flex: 1 1 0;
min-width: min(100%, 30rem);
padding-block: var(--espace-2);
padding-top: var(--espace-2);
}
.place {
@ -285,12 +376,15 @@
flex: 0 0 36%;
min-width: 16rem;
min-height: 3rem;
border-left: 1px solid var(--couleur-page-filet);
border-left: 1px solid var(--couleur-carte-filet);
}
/* À côté de la tête, la zone a un retrait vertical réduit : les deux
avertissements d'un journal abîmé paraissent dans sa hauteur. */
.place > .annonces {
position: absolute;
inset: 0;
padding-block: var(--espace-1);
}
/* Seules — sans tête, en plein écran, ou devant un support rompu —, les

View file

@ -2,41 +2,35 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'en-tête de l'événement ouvert (§ 9) : son nom, puis ses quatre états,
// dans l'ordre du § 9. Un état se pose en le cliquant, par la commande
// changerEtat de la séance — « Bloqué » est la commande « Bloquer », après
// laquelle la séance revient en lecture et rend le verrou. Ce que la séance
// refuse — la lecture, un plan bloqué, « Retenu » sans placement retenu,
// « Proposé » sans proposition — s'affiche à côté de l'état cliqué, avec
// son remède. L'état courant se distingue par sa forme : gras, et un trait
// sous lui. Le nom se coupe où il le faut, même sans espace : un nom long
// de glyphes larges passe à la ligne au lieu d'élargir la page. Chaque état
// est un bloc : la commande et son refus en sont.
//
// « Imprimer… » ouvre l'aperçu avant impression (§ 11.7) sur le plan, au
// tour montré : toute impression passe par lui. Vivante dans tout mode,
// elle n'écrit rien.
// L'en-tête de l'événement ouvert, en tête de la tête du cadre : son nom,
// puis, sur la même ligne tant qu'elle a la place, son résumé — la date
// quand il en a une, les participants, les tables et leurs sièges, les
// tours (resumeEvenement, modele.js) —, d'encre secondaire. Le nom se
// coupe où il le faut, même sans espace : un nom long de glyphes larges
// passe à la ligne au lieu d'élargir la page. Le résumé passe à la ligne
// entre ses éléments, jamais dans l'un d'eux.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import { ETATS_PLAN, cleEtat } from './modele.js';
import { resumeEvenement } from './modele.js';
const t = getContext('t');
const format = getContext('format');
const seance = getContext('seance');
const ui = getContext('ui');
const courant = $derived($seance.evenement?.etat ?? null);
const resume = $derived(resumeEvenement($seance));
</script>
<header class="entete">
<h1>{$seance.evenement?.nom}</h1>
<div class="etats" role="group" aria-label={t('evenement.etat.titre')}>
{#each ETATS_PLAN as etat (etat)}
<div class="etat" class:courant={courant === etat} data-etat={etat}>
<Commande cle={cleEtat(etat)} nom="etat" executer={() => seance.executer('changerEtat', { etat })} />
</div>
{/each}
</div>
<Commande cle="impression.commande.ouvrir" nom="apercu" executer={() => ui.ouvrirApercu({ vue: 'plan', tour: $ui.tour })} />
{#if resume !== null}
<p class="resume">
{#if resume.date !== null}
<span>{format.date(resume.date)}</span>
{/if}
<span>{t('evenement.resume.participants', { participants: resume.participants })}</span>
<span>{t('evenement.resume.tables', { tables: resume.tables, sieges: resume.sieges })}</span>
<span>{t('evenement.resume.tours', { tours: resume.tours })}</span>
</p>
{/if}
</header>
<style>
@ -44,31 +38,24 @@
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-4);
align-items: baseline;
gap: 0 var(--espace-4);
}
h1 {
min-width: 0;
margin: 0;
font-size: var(--taille-titre);
font-size: var(--taille-titre-grand);
line-height: 1.15;
overflow-wrap: anywhere;
}
.etats {
.resume {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
}
.etat {
padding-bottom: var(--espace-1);
border-bottom: 3px solid var(--couleur-page-fond);
}
.etat.courant {
border-bottom-color: var(--couleur-page-texte);
font-weight: bold;
gap: 0 var(--espace-4);
margin: 0;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
</style>

View file

@ -0,0 +1,476 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'enveloppe de l'événement ouvert dans un vrai moteur de rendu (§ 8.4,
// § 9, § 12.7, § 14.5) : la bande de mode sous le bandeau, la carte
// d'en-tête — le nom, le résumé chiffré, « Imprimer… » —, « Modifier » mis
// en valeur à sa place fixe, la suite des états du plan, la navigation
// groupée par phase, les avertissements en messages d'avis dont l'action
// n'est jamais rognée, le support rompu en message de refus. Chaque épreuve
// monte App par monterApp, au cadre de 1 366 × 700 sauf mention, et lit le
// rendu : la boîte, le style calculé, le texte, le nom accessible — jamais
// une classe. La couleur attendue d'un jeton se relit sur une sonde qui le
// peint ; un texte tient 4,5:1 et un signe 3:1 sur ce qui est peint sous
// lui, dans les deux thèmes.
//
// Les éléments s'atteignent par leurs désignations data-…, par leur rôle et
// leur nom accessible : la bande est la région d'état, enfant direct de
// l'écran ; la suite des états, le groupe nommé « État du plan » ; chaque
// phase, un groupe de la navigation nommé par son intertitre. Les noms
// d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerFormat } from '../../application/format.js';
import { creerTraduction } from '../../application/traduction.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { composantes, contraste, couleurTexte, fondPeintSous, porteursDeTexte, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { PHASES, SECTIONS } from './sections.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const THEMES = ['clair', 'sombre'];
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Merlette', 'Fauvelle'];
const EVENEMENT = { nom: 'Cercle des Linottes', date: null, siegesParDefaut: 3, tours: 2 };
let montes = [];
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// --- Les lectures du rendu ----------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)];
const ecran = (m) => dans(m, '[data-ecran="evenement"]');
const cadre = (m) => dans(m, '[data-mode]');
const bande = (m) => ecran(m).querySelector(':scope > [role="status"]');
const fixe = (m, nom) => dans(m, `[data-place-fixe] [data-commande="${nom}"]`);
const etats = (m) => dans(m, `[role="group"][aria-label="${t('evenement.etat.titre')}"]`);
const navigation = (m) => dans(m, `nav[aria-label="${t('coquille.navigation')}"]`);
const boite = (element) => element.getBoundingClientRect();
const style = (element) => getComputedStyle(element);
const fond = (element) => composantes(style(element).backgroundColor);
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
// La couleur du trait d'une icône des primitives.
const trait = (svg) => composantes(style(svg.querySelector('path')).stroke);
// Chaque texte visible sous racine tient 4,5:1 sur chaque couleur peinte
// sous lui.
function exigerTextesLisibles(racine, lieu) {
const porteurs = porteursDeTexte(racine).filter(({ element }) => visible(element) === true);
expect(porteurs.length, `${lieu} : aucun texte`).toBeGreaterThan(0);
for (const porteur of porteurs) {
const couleur = couleurTexte(porteur);
const fonds = fondPeintSous(porteur);
const nom = `${lieu} « ${porteur.element.textContent.trim().slice(0, 40)} »`;
expect(fonds.length, `${nom} : aucun fond opaque`).toBeGreaterThan(0);
for (const f of fonds) expect(contraste(couleur, f), `${nom} sur ${f}`).toBeGreaterThanOrEqual(4.5);
}
}
// Une icône tient 3:1 sur chaque couleur peinte sous elle (§ 14.5).
function exigerSigneVisible(svg, lieu) {
const couleur = trait(svg);
const fonds = fondPeintSous(svg);
expect(fonds.length, `${lieu} : aucun fond opaque sous le signe`).toBeGreaterThan(0);
for (const f of fonds) expect(contraste(couleur, f), `${lieu} : signe sur ${f}`).toBeGreaterThanOrEqual(3);
}
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// Un montage où l'événement neuf, aux champs donnés, est ouvert en lecture.
async function neuf(champs = {}, options = {}) {
const m = await monter(options);
await m.seance.creer({ ...EVENEMENT, ...champs });
flushSync();
return m;
}
// L'événement passé en écriture, une table de trois places créée, puis
// quatre personnes ajoutées : la quatrième annonce PLACES_MANQUANTES, dont
// le remède ouvre les tables.
async function enManque(m) {
await m.seance.passerEnEcriture();
await m.seance.executer('creerTable', { forme: 'ronde' });
for (const nom of NOMS.slice(0, 4)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
flushSync();
}
// --- Les épreuves -----------------------------------------------------------------
describe('la bande de mode (§ 8.4)', () => {
// 1 920 px dépasse --largeur-page : la colonne de la page n'y touche plus
// les bords de la fenêtre, la bande si.
for (const largeur of [1366, 1920]) {
test(`à ${largeur} px, sous le bandeau, de bord à bord de la fenêtre sans défilement en largeur, avant le cadre : l’icône, le libellé du cadre et son explication, aux couleurs de chaque mode, à la même hauteur`, async () => {
await fixerCadre(largeur, 700);
const m = await neuf();
const hauteurs = [];
const relever = async (mode, cle, explication) => {
flushSync();
const region = bande(m);
expect(region, mode).not.toBeNull();
const b = boite(region);
expect(Math.abs(b.top - boite(dans(m, '[data-bandeau]')).bottom), mode).toBeLessThanOrEqual(1);
expect([b.left, b.right], mode).toEqual([0, innerWidth]);
expect(document.documentElement.scrollWidth, mode).toBeLessThanOrEqual(innerWidth);
expect(b.bottom, mode).toBeLessThanOrEqual(boite(cadre(m)).top);
expect(region.textContent, mode).toContain(t(cle));
expect(region.textContent, mode).toContain(t(explication));
expect(region.textContent, mode).toContain(dans(m, '[data-mode-libelle="cadre"]').textContent);
expect(visible(region), mode).toBe(true);
hauteurs.push(b.height);
const icones = region.querySelectorAll('svg');
expect(icones.length, mode).toBe(1);
expect(icones[0].getAttribute('aria-hidden'), mode).toBe('true');
for (const theme of THEMES) {
await sousTheme(theme, () => {
const lieu = `${theme} ${mode}`;
expect(fond(region), lieu).toEqual(jeton(`--couleur-mode-${mode}-fond`));
expect(composantes(style(region).borderBottomColor), lieu).toEqual(jeton(`--couleur-mode-${mode}-filet`));
expect(trait(icones[0]), lieu).toEqual(jeton(`--couleur-cadre-${mode}`));
exigerSigneVisible(icones[0], lieu);
exigerTextesLisibles(region, lieu);
});
}
};
await relever('lecture', 'coquille.mode.lecture', 'evenement.bande.lecture');
await m.seance.passerEnEcriture();
await relever('ecriture', 'coquille.mode.ecriture', 'evenement.bande.ecriture');
await m.seance.executer('changerEtat', { etat: 'bloque' });
await relever('bloque', 'coquille.mode.bloque', 'evenement.bande.bloque');
expect(new Set(hauteurs).size).toBe(1);
// Le plein écran du plan garde la bande.
m.ui.allerA('plan');
m.ui.basculerPleinEcran();
flushSync();
expect(visible(bande(m))).toBe(true);
});
}
});
describe('la carte d’en-tête', () => {
test('la barre porte, après la place fixe, la suite des états puis « Imprimer… », principale, calée à droite ; la tête, le nom puis la date, les participants, les tables et leurs sièges, les tours ; le plein écran efface les uns et les autres', async () => {
const m = await neuf({ date: '2031-05-17', siegesParDefaut: 4, tours: 3 });
await m.seance.passerEnEcriture();
for (const nom of NOMS) await m.seance.executer('ajouterParticipant', { champs: { nom } });
for (let n = 0; n < 2; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
flushSync();
const titre = dans(m, '[data-ecran="evenement"] header h1');
expect(titre.textContent).toBe(EVENEMENT.nom);
const entete = titre.closest('header');
const attendus = [
format.date('2031-05-17'),
t('evenement.resume.participants', { participants: 6 }),
t('evenement.resume.tables', { tables: 2, sieges: 8 }),
t('evenement.resume.tours', { tours: 3 }),
];
const lus = [...entete.querySelectorAll('h1 ~ * > *')].map((element) => element.textContent.trim());
expect(lus).toEqual(attendus);
// Le nom et son résumé tiennent sur une ligne.
expect(boite(entete.querySelector('h1 ~ *')).top).toBeLessThan(boite(titre).bottom);
const imprimer = dans(m, '[data-ecran="evenement"] [data-commande="apercu"]');
expect(imprimer.textContent).toBe(t('impression.commande.ouvrir'));
expect(entete.contains(imprimer)).toBe(false);
const place = boite(fixe(m, 'lecture'));
const [i, e, c] = [boite(imprimer), boite(etats(m)), boite(cadre(m))];
expect(i.top).toBe(place.top);
expect(i.height).toBeGreaterThanOrEqual(44);
expect(c.right - i.right).toBeLessThanOrEqual(24);
expect(e.left).toBeGreaterThan(place.right);
expect(e.top).toBeGreaterThanOrEqual(place.top);
expect(e.bottom).toBeLessThanOrEqual(place.bottom);
expect(i.left).toBeGreaterThan(e.right);
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(imprimer), theme).toEqual(jeton('--couleur-action-fond'));
expect(fondPeintSous(titre), theme).toEqual([jeton('--couleur-carte-fond')]);
expect(fondPeintSous(etats(m)), theme).toEqual([jeton('--couleur-carte-fond-sourd')]);
exigerTextesLisibles(entete, theme);
});
}
m.ui.allerA('plan');
m.ui.basculerPleinEcran();
flushSync();
expect(dans(m, '[data-commande="apercu"]')).toBeNull();
expect(etats(m)).toBeNull();
expect(dans(m, '[data-ecran="evenement"] header')).toBeNull();
});
test('sans date, le résumé commence aux participants', async () => {
const m = await neuf();
const entete = dans(m, '[data-ecran="evenement"] header');
const lus = [...entete.querySelectorAll('h1 ~ * > *')].map((element) => element.textContent.trim());
expect(lus).toEqual([
t('evenement.resume.participants', { participants: 0 }),
t('evenement.resume.tables', { tables: 0, sieges: 0 }),
t('evenement.resume.tours', { tours: EVENEMENT.tours }),
]);
});
});
describe('« Modifier » mis en valeur à sa place fixe (§ 8.4)', () => {
test('en lecture, « Modifier » est l’action principale ; « Revenir en lecture » et « Débloquer » ne le sont pas ; la surface ne bouge pas d’un mode à l’autre', async () => {
const m = await neuf();
const surface = () => boite(dans(m, '[data-section]')).top;
const avant = surface();
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(fixe(m, 'modifier')), theme).toEqual(jeton('--couleur-action-fond'));
expect(composantes(style(fixe(m, 'modifier')).color), theme).toEqual(jeton('--couleur-action-texte'));
});
}
expect(boite(fixe(m, 'modifier')).height).toBeGreaterThanOrEqual(44);
await m.seance.passerEnEcriture();
flushSync();
expect(fond(fixe(m, 'lecture'))).not.toEqual(jeton('--couleur-action-fond'));
expect(surface()).toBe(avant);
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
expect(fond(fixe(m, 'debloquer'))).not.toEqual(jeton('--couleur-action-fond'));
expect(surface()).toBe(avant);
});
});
describe('la suite des états du plan (§ 9)', () => {
test('quatre étapes séparées de chevrons, sous l’intertitre « État du plan » ; l’état courant porte le fond de sa teinte, son icône et le gras ; les autres gardent leur nom et restent atteints', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
const groupe = etats(m);
expect(groupe).not.toBeNull();
const verifier = async (courant) => {
flushSync();
const etapes = [...groupe.querySelectorAll('[data-etat]')];
expect(etapes.map((etape) => etape.dataset.etat)).toEqual(['brouillon', 'propose', 'retenu', 'bloque']);
// Les chevrons séparent les étapes, hors d'elles, cachés à
// l'assistance.
const chevrons = [...groupe.querySelectorAll('svg')].filter((svg) => svg.closest('[data-etat]') === null);
expect(chevrons.length).toBe(3);
for (const chevron of chevrons) expect(chevron.closest('[aria-hidden="true"]')).not.toBeNull();
for (const etape of etapes) {
const { etat } = etape.dataset;
const bouton = etape.querySelector('[data-commande="etat"]');
expect(bouton.textContent, etat).toBe(t(`evenement.etat.${etat}`));
expect(visible(bouton), etat).toBe(true);
const icones = etape.querySelectorAll('svg');
expect(icones.length, etat).toBe(1);
// L'icône est dans la boîte du bouton : l'étape se lit d'un bloc.
const [b, i] = [boite(bouton), boite(icones[0])];
expect(i.left >= b.left && i.right <= b.right && i.top >= b.top && i.bottom <= b.bottom, etat).toBe(true);
for (const theme of THEMES) {
await sousTheme(theme, () => {
const lieu = `${theme} ${etat}`;
expect(trait(icones[0]), lieu).toEqual(jeton(`--couleur-etat-${etat}-marque`));
exigerSigneVisible(icones[0], lieu);
exigerTextesLisibles(etape, lieu);
if (etat === courant) {
expect(fond(bouton), lieu).toEqual(jeton(`--couleur-etat-${etat}-fond`));
expect(style(etape).fontWeight, lieu).toBe('700');
expect(style(bouton).fontWeight, lieu).toBe('700');
} else {
expect(fond(bouton), lieu).not.toEqual(jeton(`--couleur-etat-${etat}-fond`));
expect(style(bouton).fontWeight, lieu).toBe('400');
}
});
}
}
// L'intertitre est en petites capitales, d'une encre neutre.
const intertitre = [...groupe.querySelectorAll('*')].find(
(n) => n.textContent === t('evenement.etat.titre') && n.children.length === 0,
);
expect(intertitre).not.toBeUndefined();
expect(style(intertitre).textTransform).toBe('uppercase');
expect(composantes(style(intertitre).color)).toEqual(jeton('--couleur-page-texte-secondaire'));
};
await verifier('brouillon');
await m.seance.executer('changerEtat', { etat: 'bloque' });
await verifier('bloque');
});
});
describe('la navigation groupée par phase', () => {
test('trois groupes nommés par leur intertitre — préparer, placer, produire —, chacun ses sections dans l’ordre ; l’onglet courant souligné de l’action et gras', async () => {
const m = await neuf();
const nav = navigation(m);
expect(nav).not.toBeNull();
const groupes = [...nav.querySelectorAll('[role="group"]')];
expect(groupes.length).toBe(PHASES.length);
groupes.forEach((groupe, rang) => {
const phase = PHASES[rang];
const intertitre = document.getElementById(groupe.getAttribute('aria-labelledby'));
expect(intertitre, phase.nom).not.toBeNull();
expect(intertitre.textContent.trim(), phase.nom).toBe(t(phase.cle));
expect(style(intertitre).textTransform, phase.nom).toBe('uppercase');
expect(composantes(style(intertitre).color), phase.nom).toEqual(jeton('--couleur-page-texte-secondaire'));
const cibles = [...groupe.querySelectorAll('[data-commande="section"]')].map((bouton) => bouton.dataset.cible);
expect(cibles, phase.nom).toEqual(phase.sections);
});
// La marque colorée à côté de chaque intertitre : un élément muet, avant
// le nom, du fond de sa phase, qui tient 3:1 sur ce qui est peint sous
// l'intertitre dans les deux thèmes (§ 14.5).
const teintes = new Map([
['preparer', '--couleur-action-fond'],
['placer', '--couleur-etat-retenu-marque'],
['produire', '--couleur-etat-bloque-marque'],
]);
expect([...teintes.keys()]).toEqual(PHASES.map((phase) => phase.nom));
for (const [rang, groupe] of groupes.entries()) {
const phase = PHASES[rang];
const intertitre = document.getElementById(groupe.getAttribute('aria-labelledby'));
const marque = intertitre.firstChild;
expect(marque instanceof HTMLElement, phase.nom).toBe(true);
expect(marque.getAttribute('aria-hidden'), phase.nom).toBe('true');
expect(marque.textContent, phase.nom).toBe('');
expect(visible(marque), phase.nom).toBe(true);
const b = boite(marque);
expect(b.width, phase.nom).toBeGreaterThan(0);
expect(b.height, phase.nom).toBeGreaterThan(0);
for (const theme of THEMES) {
await sousTheme(theme, () => {
const lieu = `${theme} ${phase.nom}`;
expect(fond(marque), lieu).toEqual(jeton(teintes.get(phase.nom)));
const dessous = fondPeintSous(intertitre);
expect(dessous.length, `${lieu} : aucun fond opaque sous l\u{2019}intertitre`).toBeGreaterThan(0);
for (const f of dessous) expect(contraste(fond(marque), f), `${lieu} : marque sur ${f}`).toBeGreaterThanOrEqual(3);
});
}
}
for (const nom of SECTIONS) {
m.ui.allerA(nom);
flushSync();
for (const bouton of tous(m, '[data-commande="section"]')) {
const courant = bouton.dataset.cible === nom;
expect(bouton.getAttribute('aria-current'), nom).toBe(courant ? 'page' : null);
const s = style(bouton);
if (courant) {
expect(s.fontWeight, nom).toBe('700');
expect(Number.parseFloat(s.borderBottomWidth), nom).toBeGreaterThanOrEqual(2);
expect(composantes(s.borderBottomColor), nom).toEqual(jeton('--couleur-action-fond'));
} else {
expect(s.fontWeight, nom).toBe('400');
expect(composantes(s.borderBottomColor)[3] === 0 || Number.parseFloat(s.borderBottomWidth) === 0, nom).toBe(true);
}
}
}
for (const theme of THEMES) await sousTheme(theme, () => exigerTextesLisibles(nav, theme));
});
test('avec un avertissement à côté de la tête, les huit onglets tiennent sur une ligne', async () => {
const m = await neuf();
await enManque(m);
expect(dans(m, '[data-annonces] [data-avertissement]')).not.toBeNull();
const hauts = tous(m, '[data-commande="section"]').map((bouton) => boite(bouton).top);
expect(hauts.length).toBe(SECTIONS.length);
expect(new Set(hauts).size).toBe(1);
});
});
describe('les avertissements, en messages d’avis', () => {
test('« Ouvrir les tables » se voit en entier dans la zone des annonces, sans défiler ; le message porte le fond, le filet et le signe de l’avis', async () => {
const m = await neuf();
await enManque(m);
const message = await attendre(() => {
const element = dans(m, '[data-annonces] [data-avertissement="PLACES_MANQUANTES"]');
expect(element).not.toBeNull();
return element;
});
const zone = message.closest('[data-annonces]');
expect(zone.scrollTop).toBe(0);
const action = message.querySelector('[data-commande]');
expect(action).not.toBeNull();
const [a, z] = [boite(action), boite(zone)];
expect(a.top).toBeGreaterThanOrEqual(z.top);
expect(a.bottom).toBeLessThanOrEqual(z.bottom);
expect(a.left).toBeGreaterThanOrEqual(z.left);
expect(a.right).toBeLessThanOrEqual(z.right);
expect(visible(action)).toBe(true);
const icones = [...message.querySelectorAll('svg')].filter((svg) => !action.contains(svg));
expect(icones.length).toBe(1);
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(message), theme).toEqual(jeton('--couleur-avis-fond'));
expect(composantes(style(message).borderTopColor), theme).toEqual(jeton('--couleur-avis-filet'));
expect(trait(icones[0]), theme).toEqual(jeton('--couleur-avis-marque'));
exigerSigneVisible(icones[0], theme);
exigerTextesLisibles(message, theme);
});
}
});
});
describe('le support rompu, en message de refus (§ 8.6)', () => {
test('le fond, le filet et le signe du refus, ses issues lisibles', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
m.fs.pannes.ecriture('documents');
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' });
const message = await attendre(() => {
const element = dans(m, '[data-support-rompu]');
expect(element).not.toBeNull();
return element;
});
const icones = [...message.querySelectorAll('svg')].filter((svg) => svg.closest('button') === null);
expect(icones.length).toBe(1);
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(message), theme).toEqual(jeton('--couleur-refus-fond'));
expect(composantes(style(message).borderTopColor), theme).toEqual(jeton('--couleur-refus-filet'));
expect(trait(icones[0]), theme).toEqual(jeton('--couleur-refus-marque'));
exigerSigneVisible(icones[0], theme);
exigerTextesLisibles(message, theme);
});
}
});
});
describe('à 1 024 px de large', () => {
test('rien ne déborde : ni la page, ni la bande, ni la tête ; chaque onglet, chaque état et la place fixe se voient', async () => {
await fixerCadre(1024, 700);
const m = await neuf();
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
expect(boite(bande(m)).right).toBeLessThanOrEqual(innerWidth);
const tete = dans(m, '[data-ecran="evenement"] header').parentElement;
expect(tete.scrollWidth).toBeLessThanOrEqual(tete.clientWidth);
for (const bouton of tous(m, '[data-commande="section"], [data-commande="etat"]')) {
expect(visible(bouton), bouton.textContent).toBe(true);
}
expect(visible(fixe(m, 'modifier'))).toBe(true);
});
});

View file

@ -0,0 +1,163 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La suite des états du plan (§ 9), dans la barre du cadre : sous le nom
// « État du plan », que redit un intertitre en petites capitales tant que
// la barre a la place, les
// quatre états dans l'ordre du § 9 (etapesEtat, modele.js), séparés de
// chevrons cachés à l'assistance. Chaque étape est la Commande
// changerEtat de son état, restylée en pilule, avec l'icône de sa pastille
// d'état posée sur le bouton, à gauche de son nom, de la marque de sa
// teinte. L'état courant porte en plus le fond de sa teinte, un filet
// plein et le gras ; les autres, un filet tireté, et restent cliquables
// sous le même nom.
//
// Un état se pose en le cliquant : « Bloqué » est la commande « Bloquer »,
// après laquelle la séance revient en lecture et rend le verrou. Ce que la
// séance refuse — la lecture, un plan bloqué, « Retenu » sans placement
// retenu, « Proposé » sans proposition — s'affiche à côté de l'état
// cliqué, avec son remède : chaque état est un bloc, et la commande et son
// refus en sont.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
import { etapesEtat } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
const etapes = $derived(etapesEtat($seance));
</script>
<div class="etats" role="group" aria-label={t('evenement.etat.titre')}>
<span class="intertitre" aria-hidden="true">{t('evenement.etat.titre')}</span>
{#each etapes as etape, rang (etape.etat)}
{#if rang > 0}
<span class="chevron"><Icone nom="chevron" taille={14} /></span>
{/if}
<div
class="etat"
class:courant={etape.courant}
class:brouillon={etape.etat === 'brouillon'}
class:propose={etape.etat === 'propose'}
class:retenu={etape.etat === 'retenu'}
class:bloque={etape.etat === 'bloque'}
data-etat={etape.etat}
>
<span class="signe"><Icone nom={etape.icone} taille={14} /></span>
<Commande cle={etape.cle} nom="etat" executer={() => seance.executer('changerEtat', { etat: etape.etat })} />
</div>
{/each}
</div>
<style>
.etats {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-1);
}
/* L'intertitre et les chevrons sont centrés sur la hauteur d'une étape. */
.intertitre {
display: flex;
align-items: center;
min-height: 2rem;
margin-right: 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;
}
/* Dans une barre étroite (CadreMode.svelte), l'intertitre s'efface : le
groupe garde son nom, et les quatre étapes leur ligne. */
@container barre (max-width: 80rem) {
.intertitre {
display: none;
}
}
.chevron {
display: flex;
align-items: center;
min-height: 2rem;
color: var(--couleur-page-texte-secondaire);
}
/* L'étape : son bouton, une pilule de 2rem de haut, laisse à gauche la
place de l'icône, posée dessus sans recevoir le pointeur. */
.etat {
position: relative;
font-weight: normal;
}
.etat.courant {
font-weight: bold;
}
.signe {
position: absolute;
top: calc((2rem - 14px) / 2);
left: var(--espace-3);
display: flex;
pointer-events: none;
}
/* Le bouton de la Commande, non ceux du refus posé à côté. */
.etat > :global(div > button) {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0 var(--espace-3) 0 calc(var(--espace-3) + 14px + var(--espace-2));
border: 1px dashed var(--couleur-page-filet);
border-radius: var(--rayon-pilule);
background-color: transparent;
}
.etat > :global(div > button:hover) {
background-color: var(--couleur-carte-fond);
}
.etat.courant > :global(div > button) {
border-style: solid;
}
.brouillon .signe {
color: var(--couleur-etat-brouillon-marque);
}
.propose .signe {
color: var(--couleur-etat-propose-marque);
}
.retenu .signe {
color: var(--couleur-etat-retenu-marque);
}
.bloque .signe {
color: var(--couleur-etat-bloque-marque);
}
.courant.brouillon > :global(div > button) {
border-color: var(--couleur-etat-brouillon-marque);
background-color: var(--couleur-etat-brouillon-fond);
}
.courant.propose > :global(div > button) {
border-color: var(--couleur-etat-propose-marque);
background-color: var(--couleur-etat-propose-fond);
}
.courant.retenu > :global(div > button) {
border-color: var(--couleur-etat-retenu-marque);
background-color: var(--couleur-etat-retenu-fond);
}
.courant.bloque > :global(div > button) {
border-color: var(--couleur-etat-bloque-marque);
background-color: var(--couleur-etat-bloque-fond);
}
</style>

View file

@ -1113,6 +1113,8 @@ describe('le support rompu (§ 8.6, § 8.2)', () => {
await attendre(() => expect(dans(m, `[data-evenement="${nouvelle}"]`)).not.toBeNull());
const dossier = dans(m, '[data-dossier-travail]');
expect(dossier.textContent).toBe(DOCUMENTS);
// La liste défile : le dossier de travail s'amène dans la fenêtre.
dossier.scrollIntoView({ block: 'center' });
expect(visible(dossier)).toBe(true);
});
@ -1185,6 +1187,12 @@ describe('le format plus récent (§ 8.8)', () => {
const explication = dans(m, '[data-place-fixe] [data-refus="LECTURE_SEULE"]');
expect(explication.textContent).toContain(t('LECTURE_SEULE', { raison: 'FORMAT', remede: null }));
expect(visible(explication)).toBe(true);
// La bande sous le bandeau ne nomme aucune commande que la place fixe
// n'offre pas.
const bande = dans(m, '[data-ecran="evenement"] > [role="status"]');
expect(bande).not.toBeNull();
expect(bande.textContent).toContain(t('evenement.bande.lectureSeule'));
for (const cle of ['communs.commande.modifier', 'communs.commande.debloquer']) expect(bande.textContent).not.toContain(t(cle));
const avant = m.trace.length;
const boutons = tous(m, '[data-ecran="evenement"] button').filter((bouton) => bouton.dataset.commande !== 'section');
expect(boutons.length).toBeGreaterThan(0);

View file

@ -2,16 +2,32 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'événement ouvert (§ 2.1, § 8.4) : le cadre de mode, qui entoure la
// surface de travail de toute section, et porte sous sa barre — place
// fixe, libellé, Défaire et Refaire — la tête de l'événement : son en-tête
// — nom et états du plan — et la navigation des sections, dans l'ordre du
// § 2.1. Rien ne précède le cadre dans l'écran : ce que la tête montre ne
// déplace pas la place fixe. Aucune section n'en exige une autre : la
// navigation les offre toutes, et la séance refuse elle-même ce qui ne
// peut pas se faire. Le plein écran du plan efface la tête, jamais le
// L'événement ouvert (§ 2.1, § 8.4) : la bande de mode, sous le bandeau,
// puis le cadre de mode, qui entoure la surface de travail de toute
// section. Sa barre porte la place fixe, le libellé, Défaire et Refaire,
// puis les outils — la suite des états du plan — et les actions —
// « Imprimer… », l'action principale de l'en-tête. Sous la barre, la tête
// de l'événement : son en-tête — nom et résumé — et la navigation des
// sections, dans l'ordre du § 2.1. La barre et la tête forment la carte
// d'en-tête. Seule la bande précède le cadre dans l'écran, à hauteur
// constante : ce que la tête montre ne déplace pas la place fixe. Aucune
// section n'en exige une autre : la navigation les offre toutes, et la
// séance refuse elle-même ce qui ne peut pas se faire. Le plein écran du
// plan efface les outils, les actions et la tête, jamais la bande, le
// cadre ni sa place fixe.
//
// « Imprimer… » ouvre l'aperçu avant impression (§ 11.7) sur le plan, au
// tour montré : toute impression passe par lui. Vivante dans tout mode,
// elle n'écrit rien.
//
// La navigation groupe les sections par phase (PHASES, sections.js) —
// préparer, placer, produire — : chaque phase est un groupe nommé par son
// intertitre, en petites capitales d'encre secondaire, précédé d'une
// marque de couleur qui ne porte aucune information que le nom ne porte
// déjà. Les sections sont des onglets : la section ouverte, aria-current,
// se distingue par sa forme, en gras et soulignée d'un trait de la teinte
// de l'action.
//
// L'écran de l'événement tient dans la fenêtre, sous le bandeau : la page
// ne défile pas, la surface de travail défile dans le cadre, et le cadre,
// son libellé et sa place fixe restent où ils sont (§ 8.4). La règle
@ -34,15 +50,19 @@
import SectionPropositions from '../propositions/SectionPropositions.svelte';
import SectionTables from '../tables/SectionTables.svelte';
import Avertissements from './Avertissements.svelte';
import BandeMode from './BandeMode.svelte';
import Commande from '../primitives/Commande.svelte';
import CadreMode from './CadreMode.svelte';
import EnteteEvenement from './EnteteEvenement.svelte';
import EtatsPlan from './EtatsPlan.svelte';
import SupportRompu from './SupportRompu.svelte';
import { avertissementsOrdonnes } from './modele.js';
import { SECTIONS, cleDeSection } from './sections.js';
import { PHASES, cleDeSection } from './sections.js';
const t = getContext('t');
const ui = getContext('ui');
const seance = getContext('seance');
const uid = $props.id();
// Le composant de chaque section.
const COMPOSANTS = new Map([
@ -62,17 +82,45 @@
const annoncer = $derived($seance.supportRompu !== null || avertissementsOrdonnes($seance).length > 0);
</script>
{#snippet outils()}
<EtatsPlan />
{/snippet}
{#snippet actions()}
<Commande
cle="impression.commande.ouvrir"
nom="apercu"
variante="principale"
executer={() => ui.ouvrirApercu({ vue: 'plan', tour: $ui.tour })}
/>
{/snippet}
{#snippet tete()}
<EnteteEvenement />
<nav aria-label={t('coquille.navigation')}>
{#each SECTIONS as nom (nom)}
<button
type="button"
data-commande="section"
data-cible={nom}
aria-current={$ui.section === nom ? 'page' : undefined}
onclick={() => ui.allerA(nom)}>{t(cleDeSection(nom))}</button
>
{#each PHASES as phase (phase.nom)}
<div class="phase" role="group" aria-labelledby="{uid}-{phase.nom}">
<span class="intertitre" id="{uid}-{phase.nom}"
><span
class="marque"
class:preparer={phase.nom === 'preparer'}
class:placer={phase.nom === 'placer'}
class:produire={phase.nom === 'produire'}
aria-hidden="true"
></span>{t(phase.cle)}</span
>
<div class="onglets">
{#each phase.sections as nom (nom)}
<button
type="button"
data-commande="section"
data-cible={nom}
aria-current={$ui.section === nom ? 'page' : undefined}
onclick={() => ui.allerA(nom)}>{t(cleDeSection(nom))}</button
>
{/each}
</div>
</div>
{/each}
</nav>
{/snippet}
@ -85,14 +133,17 @@
{/snippet}
<div class="evenement" data-ecran="evenement">
<CadreMode tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
<BandeMode />
<CadreMode outils={$ui.pleinEcran ? null : outils} actions={$ui.pleinEcran ? null : actions} tete={$ui.pleinEcran ? null : tete} annonces={annoncer ? annonces : null}>
<Section />
</CadreMode>
</div>
<style>
/* L'ascendance de l'écran de l'événement prend la hauteur de la fenêtre :
le bandeau en haut, l'écran dessous, qui ne déborde pas. */
le bandeau en haut, l'écran dessous, qui ne déborde pas. Le retrait du
bas de l'écran se réduit à --espace-2 : la bande de mode prend le
retrait du haut, et le plan garde sa hauteur. */
:global(div:has(> main > [data-ecran='evenement'])) {
height: 100vh;
}
@ -103,6 +154,12 @@
min-height: 0;
}
/* La classe de l'écran (App.svelte) porte la spécificité de son retrait,
que cette règle doit dépasser. */
:global(main.ecran:has(> [data-ecran='evenement'])) {
padding-bottom: var(--espace-2);
}
.evenement {
display: flex;
flex: 1 1 auto;
@ -111,30 +168,79 @@
min-height: 0;
}
/* Les phases côte à côte, séparées d'un blanc : les huit onglets tiennent
sur une ligne à côté de la place des annonces dans une fenêtre de
1 366 px, et un avertissement ne retire donc aucune hauteur au plan
(CadreMode.svelte). */
nav {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
gap: 0 var(--espace-5);
}
/* Le retrait latéral des boutons de section vaut --espace-2 : les huit
sections tiennent sur une ligne à côté de la place des annonces dans
une fenêtre de 1 366 px, et un avertissement ne retire donc aucune
hauteur au plan (CadreMode.svelte). */
.phase {
display: flex;
flex-direction: column;
}
.intertitre {
display: flex;
align-items: center;
gap: var(--espace-1);
padding: 0 var(--espace-2);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
line-height: 1.2;
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
.marque {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: var(--rayon-pilule);
}
.marque.preparer {
background-color: var(--couleur-action-fond);
}
.marque.placer {
background-color: var(--couleur-etat-retenu-marque);
}
.marque.produire {
background-color: var(--couleur-etat-bloque-marque);
}
.onglets {
display: flex;
flex-wrap: wrap;
}
/* Un onglet : sans fond ni cadre, un trait transparent sous lui, que la
section ouverte remplit de la teinte de l'action. Son retrait latéral
vaut --espace-2 ; sa hauteur, trait compris, celle d'une commande
secondaire. */
button {
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: 0;
border-bottom: 3px solid transparent;
background-color: transparent;
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
/* La section ouverte se distingue par sa forme : texte souligné et gras. */
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
button[aria-current='page'] {
border-bottom-color: var(--couleur-action-fond);
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {

View file

@ -10,8 +10,13 @@
// demande le dossier à l'opérateur — un refus, NON_DISPONIBLE sous web,
// s'affiche à côté de la commande ; et « Fermer sans écrire ailleurs »,
// confirmé en place, qui abandonne l'événement à ce support.
//
// C'est un message de refus, comme la primitive Refus : le fond et le
// filet du refus, son trait à gauche, l'icône « interdit » de sa marque
// devant le texte, l'encre du refus ; les issues sous le texte.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
const t = getContext('t');
const seance = getContext('seance');
@ -26,22 +31,25 @@
{#if rompu !== null}
<section class="rompu" role="status" data-support-rompu>
<p>{t('SUPPORT_ROMPU', refus)}</p>
<div class="issues">
{#if rompu.ailleurs !== null}
<span class="signe"><Icone nom="interdit" taille={18} /></span>
<div class="corps">
<p>{t('SUPPORT_ROMPU', refus)}</p>
<div class="issues">
{#if rompu.ailleurs !== null}
<Commande
cle="communs.commande.ecrireDocuments"
nom="ecrireDocuments"
executer={() => seance.ecrireAilleurs(rompu.ailleurs)}
/>
{/if}
<Commande cle="communs.commande.choisirDossier" nom="choisirDossier" executer={() => seance.ecrireAilleurs(null)} />
<Commande
cle="communs.commande.ecrireDocuments"
nom="ecrireDocuments"
executer={() => seance.ecrireAilleurs(rompu.ailleurs)}
cle="evenement.supportRompu.abandonner"
nom="abandonner"
confirmer="evenement.supportRompu.confirmerAbandon"
executer={() => seance.fermer({ abandonner: true })}
/>
{/if}
<Commande cle="communs.commande.choisirDossier" nom="choisirDossier" executer={() => seance.ecrireAilleurs(null)} />
<Commande
cle="evenement.supportRompu.abandonner"
nom="abandonner"
confirmer="evenement.supportRompu.confirmerAbandon"
executer={() => seance.fermer({ abandonner: true })}
/>
</div>
</div>
</section>
{/if}
@ -49,13 +57,31 @@
<style>
.rompu {
display: flex;
align-items: flex-start;
gap: var(--espace-2) var(--espace-3);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-refus-filet);
border-left: 3px solid var(--couleur-refus);
border-radius: var(--rayon-2);
background-color: var(--couleur-refus-fond);
color: var(--couleur-refus);
font-size: var(--taille-texte);
}
.signe {
display: flex;
flex: none;
padding-top: 0.125rem;
color: var(--couleur-refus-marque);
}
.corps {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border-left: 3px solid var(--couleur-refus);
color: var(--couleur-refus);
font-size: var(--taille-texte);
min-width: 0;
}
p {

View file

@ -5,10 +5,14 @@
// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre
// de mode et sa forme, la commande de la place fixe, les états du plan et
// leurs clés, la reprise de verrou que l'écran offre, et les avertissements
// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé
// que la séance ne décide déjà : le refus courant, le mode et l'état du plan
// se lisent dans EtatSeance, jamais se recalculent.
// dans l'ordre de la séance, chacun avec son remède ; puis ce que montre
// l'enveloppe de l'événement : le résumé chiffré de son en-tête, les étapes
// de ses états, la bande de mode. Rien n'y est décidé que la séance ne
// décide déjà : le refus courant, le mode et l'état du plan se lisent dans
// EtatSeance, jamais se recalculent.
import { cleDeMode } from '../mode.js';
import { modelePastille } from '../primitives/pastille.js';
import { etatTable } from '../tables/modele.js';
// La forme du cadre pour chaque clé du libellé de mode : un trait différent
// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5).
@ -66,6 +70,67 @@ export function cleEtat(etat) {
return `evenement.etat.${etat}`;
}
/**
* Les étapes de la suite des états du plan (§ 9), dans l'ordre d'ETATS_PLAN :
* pour chacune, son état, la clé de son libellé (cleEtat), l'icône de sa
* pastille d'état (pastille.js) et courant, vrai pour l'état du plan ouvert
* seul. Sans événement, aucune n'est courante.
*
* @param {Object} etatSeance EtatSeance
* @returns {Array<{etat: string, cle: string, icone: string, courant: boolean}>}
*/
export function etapesEtat(etatSeance) {
const courant = etatSeance.evenement?.etat ?? null;
return ETATS_PLAN.map((etat) => ({ etat, cle: cleEtat(etat), icone: modelePastille('etat', etat, null).icone, courant: etat === courant }));
}
// Le mode de la bande, son explication et son icône, pour chaque forme du
// cadre : la bande dit ce que le cadre dit.
const BANDES = new Map([
['tirets', { mode: 'lecture', explication: 'evenement.bande.lecture', icone: 'oeil' }],
['plein', { mode: 'ecriture', explication: 'evenement.bande.ecriture', icone: 'stylo' }],
['double', { mode: 'bloque', explication: 'evenement.bande.bloque', icone: 'cadenas' }],
]);
/**
* La bande de mode, sous le bandeau (§ 8.4) : mode, 'lecture', 'ecriture' ou
* 'bloque' — un plan bloqué l'est quel que soit le mode — ; cle, le libellé
* du cadre (cadreDeMode) ; explication, la clé de la phrase qui le suit ;
* icone, celle des primitives qui le précède. La phrase nomme la commande
* de la place fixe (commandeFixe) ; devant un format plus récent, qui n'en
* offre aucune, plan bloqué compris, elle est evenement.bande.lectureSeule,
* qui n'en nomme aucune. null sans événement.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{mode: 'lecture'|'ecriture'|'bloque', cle: string, explication: string, icone: string}}
*/
export function bandeDeMode(etatSeance) {
const cadre = cadreDeMode(etatSeance);
if (cadre === null) return null;
const { mode, explication, icone } = BANDES.get(cadre.forme);
const phrase = commandeFixe(etatSeance) === 'format' ? 'evenement.bande.lectureSeule' : explication;
return { mode, cle: cadre.cle, explication: phrase, icone };
}
/**
* Le résumé chiffré de l'en-tête, tiré de la charge publiée : date, celle de
* l'événement ou null ; participants, tous ceux de la liste, exclus compris,
* comme la liste des événements les compte ; tables ; sieges, la somme de
* leurs capacités (etatTable, tables/modele.js), défaut ou surcharge ;
* tours. null sans charge.
*
* @param {Object} etatSeance EtatSeance
* @returns {null|{date: string|null, participants: number, tables: number, sieges: number, tours: number}}
*/
export function resumeEvenement(etatSeance) {
const { charge } = etatSeance;
if (charge === null || charge === undefined) return null;
const { date, siegesParDefaut, tours } = charge.evenement;
let sieges = 0;
for (const table of charge.tables) sieges += etatTable(table, siegesParDefaut).capacite;
return { date: date ?? null, participants: charge.participants.length, tables: charge.tables.length, sieges, tours };
}
/**
* Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a
* refusé le dernier passage en écriture quand sa vie ne se prouve pas

View file

@ -4,14 +4,26 @@
// Le modèle de l'événement ouvert (§ 8.4, § 8.5, § 8.8, § 8.9, § 9) : le cadre
// de mode et sa forme, la commande de la place fixe, les états du plan et
// leurs clés, la reprise de verrou offerte, et les avertissements ordonnés,
// chacun avec son remède. Chaque épreuve part d'un EtatSeance réduit à ce que
// la fonction lit ; les noms et identifiants sont inventés.
// chacun avec son remède ; puis l'enveloppe : le résumé chiffré de
// l'en-tête et ses textes, les étapes des états, la bande de mode. Chaque
// épreuve part d'un EtatSeance réduit à ce que la fonction lit ; les noms et
// identifiants sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { ETATS_PLAN, avertissementsOrdonnes, cadreDeMode, cleEtat, commandeFixe, repriseOfferte } from './modele.js';
import {
ETATS_PLAN,
avertissementsOrdonnes,
bandeDeMode,
cadreDeMode,
cleEtat,
commandeFixe,
etapesEtat,
repriseOfferte,
resumeEvenement,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -236,3 +248,119 @@ describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => {
}
});
});
// Une charge réduite à ce que lit le résumé : l'événement, ses
// participants et ses tables.
function charge({ date = null, tours = 3, defaut = 8, participants = 0, exclus = 0, sieges = [] } = {}) {
const personnes = Array.from({ length: participants }, (_, rang) => ({ id: rang + 1, nom: `Quillard ${rang + 1}`, exclu: rang < exclus }));
return {
evenement: { id: 'evt-merles', nom: 'Club des Merles', date, siegesParDefaut: defaut, tours, etat: 'brouillon' },
participants: personnes,
tables: sieges.map((nombre, rang) => ({ id: rang + 1, numero: rang + 1, sieges: nombre })),
};
}
describe('resumeEvenement : la ligne de méta de l’en-tête', () => {
test('null sans charge', () => {
assert.equal(resumeEvenement({ ...etat(), charge: null }), null);
assert.equal(resumeEvenement({ ...etat({ plan: null }), charge: null }), null);
});
test('la date de l’événement, ses participants exclus compris, ses tables, leurs sièges — le défaut ou la surcharge —, ses tours', () => {
const lu = resumeEvenement({ ...etat(), charge: charge({ date: '2031-05-17', tours: 4, defaut: 8, participants: 13, exclus: 2, sieges: [null, 6, null] }) });
assert.deepEqual(lu, { date: '2031-05-17', participants: 13, tables: 3, sieges: 22, tours: 4 });
});
test('sans date, la date est null ; sans table, zéro table et zéro siège', () => {
const lu = resumeEvenement({ ...etat(), charge: charge({ tours: 1 }) });
assert.deepEqual(lu, { date: null, participants: 0, tables: 0, sieges: 0, tours: 1 });
});
});
describe('les textes du résumé', () => {
test('le singulier jusqu’à un, le pluriel au-delà, les milliers groupés d’une espace fine', () => {
assert.equal(t('evenement.resume.participants', { participants: 0 }), '0 participant');
assert.equal(t('evenement.resume.participants', { participants: 1 }), '1 participant');
assert.equal(t('evenement.resume.participants', { participants: 13 }), '13 participants');
assert.equal(t('evenement.resume.participants', { participants: 1200 }), '1\u{202F}200 participants');
assert.equal(t('evenement.resume.tables', { tables: 1, sieges: 1 }), '1 table \u{00B7} 1 siège');
assert.equal(t('evenement.resume.tables', { tables: 2, sieges: 18 }), '2 tables \u{00B7} 18 sièges');
assert.equal(t('evenement.resume.tours', { tours: 1 }), '1 tour');
assert.equal(t('evenement.resume.tours', { tours: 3 }), '3 tours');
});
});
describe('etapesEtat : la suite des états du plan', () => {
test('les quatre états dans l’ordre du § 9, chacun sa clé, l’icône de sa pastille, un seul courant', () => {
const etapes = etapesEtat(etat({ plan: 'retenu' }));
assert.deepEqual(etapes, [
{ etat: 'brouillon', cle: 'evenement.etat.brouillon', icone: 'crayon', courant: false },
{ etat: 'propose', cle: 'evenement.etat.propose', icone: 'listePoints', courant: false },
{ etat: 'retenu', cle: 'evenement.etat.retenu', icone: 'coche', courant: true },
{ etat: 'bloque', cle: 'evenement.etat.bloque', icone: 'cadenas', courant: false },
]);
});
test('chaque état du plan est courant à son tour ; sans événement, aucun', () => {
for (const plan of ETATS_PLAN) {
const courants = etapesEtat(etat({ plan })).filter(({ courant }) => courant);
assert.deepEqual(courants.map(({ etat: e }) => e), [plan]);
}
assert.deepEqual(etapesEtat(etat({ plan: null })).filter(({ courant }) => courant), []);
assert.equal(etapesEtat(etat({ plan: null })).length, 4);
});
});
describe('bandeDeMode : la bande sous le bandeau', () => {
test('null sans événement', () => {
assert.equal(bandeDeMode(etat({ plan: null })), null);
});
test('lecture, écriture, plan bloqué — quel que soit le mode : le libellé du cadre, une explication, une icône', () => {
assert.deepEqual(bandeDeMode(etat({ mode: 'lecture' })), {
mode: 'lecture',
cle: 'coquille.mode.lecture',
explication: 'evenement.bande.lecture',
icone: 'oeil',
});
assert.deepEqual(bandeDeMode(etat({ mode: 'ecriture' })), {
mode: 'ecriture',
cle: 'coquille.mode.ecriture',
explication: 'evenement.bande.ecriture',
icone: 'stylo',
});
for (const mode of ['lecture', 'ecriture']) {
assert.deepEqual(bandeDeMode(etat({ mode, plan: 'bloque' })), {
mode: 'bloque',
cle: 'coquille.mode.bloque',
explication: 'evenement.bande.bloque',
icone: 'cadenas',
});
}
});
test('le libellé est celui du cadre ; trois explications traduites, distinctes, qui citent la commande qui en sort', () => {
const etats = [etat(), etat({ mode: 'ecriture' }), etat({ plan: 'bloque' })];
for (const e of etats) assert.equal(bandeDeMode(e).cle, cadreDeMode(e).cle);
const explications = etats.map((e) => t(bandeDeMode(e).explication));
for (const texte of explications) assert.notEqual(texte.trim(), '');
assert.equal(new Set(explications).size, 3);
const commandes = ['communs.commande.modifier', 'communs.commande.lecture', 'communs.commande.debloquer'];
explications.forEach((texte, rang) => assert.ok(texte.includes(`« ${t(commandes[rang])} »`), texte));
});
test('un format plus récent, plan bloqué ou non : le mode du cadre, et une explication qui ne cite aucune commande', () => {
const attendus = [
[etat({ refus: FORMAT_REFUS }), { mode: 'lecture', cle: 'coquille.mode.lecture', icone: 'oeil' }],
[etat({ plan: 'bloque', refus: FORMAT_REFUS }), { mode: 'bloque', cle: 'coquille.mode.bloque', icone: 'cadenas' }],
];
const commandes = ['communs.commande.modifier', 'communs.commande.debloquer', 'evenement.commande.defaire', 'communs.commande.lecture'];
for (const [e, attendu] of attendus) {
assert.equal(commandeFixe(e), 'format');
assert.deepEqual(bandeDeMode(e), { ...attendu, explication: 'evenement.bande.lectureSeule' });
const texte = t(bandeDeMode(e).explication);
assert.notEqual(texte.trim(), '');
for (const cle of commandes) assert.ok(!texte.includes(t(cle)), `${texte} cite ${t(cle)}`);
}
});
});

View file

@ -25,6 +25,22 @@ const CLES = new Map([
* clé coquille.section.<nom> de coquille.js. */
export const SECTIONS = Object.freeze([...CLES.keys()]);
// Les phases de la navigation, chacune avec ses sections, dans l'ordre du
// § 2.1 : préparer la liste et le mobilier, placer, produire la planche.
const PHASES_ET_SECTIONS = [
['preparer', ['import', 'participants', 'tables']],
['placer', ['plan', 'diagnostic', 'generation', 'propositions']],
['produire', ['badges']],
];
/** Les phases qui groupent les sections dans la navigation : { nom, cle,
* sections } — cle, evenement.phase.<nom> de evenement.js, l'intertitre du
* groupe ; sections, les siennes dans l'ordre de SECTIONS. Mises bout à
* bout, les phases rendent SECTIONS, chaque section une fois. Figées. */
export const PHASES = Object.freeze(
PHASES_ET_SECTIONS.map(([nom, sections]) => Object.freeze({ nom, cle: `evenement.phase.${nom}`, sections: Object.freeze([...sections]) })),
);
/** La section qu'un événement montre à son ouverture. */
export const SECTION_OUVERTURE = 'plan';

View file

@ -5,11 +5,13 @@
// l'ordre du parcours de l'opérateur — les badges, dernière étape, après les
// propositions (§ 2.1 étape 12) —, chacun nommé par sa clé
// coquille.section.<nom> de la table de la coquille ; la section d'ouverture
// en est une.
// en est une. La navigation les groupe en trois phases — préparer, placer,
// produire —, qui les couvrent chacune une fois, dans leur ordre.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLE as COQUILLE } from '../libelles/coquille.js';
import { SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
import { TABLE as EVENEMENT } from '../libelles/evenement.js';
import { PHASES, SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
describe('sections de l’événement (§ 2.1)', () => {
test('huit noms distincts, dans l’ordre du parcours, les badges après les propositions, figés', () => {
@ -39,3 +41,40 @@ describe('sections de l’événement (§ 2.1)', () => {
}
});
});
describe('les phases de la navigation', () => {
test('trois phases, préparer, placer, produire, qui couvrent les sections une fois chacune, dans l’ordre du parcours', () => {
assert.deepEqual(
PHASES.map(({ nom, sections }) => [nom, sections]),
[
['preparer', ['import', 'participants', 'tables']],
['placer', ['plan', 'diagnostic', 'generation', 'propositions']],
['produire', ['badges']],
],
);
assert.deepEqual(
PHASES.flatMap(({ sections }) => sections),
SECTIONS,
);
});
test('chaque phase a sa clé evenement.phase.<nom>, au texte non vide, et deux phases n’ont pas le même texte', () => {
const textes = PHASES.map(({ nom, cle }) => {
assert.equal(cle, `evenement.phase.${nom}`);
const texte = EVENEMENT.get(cle);
assert.equal(typeof texte, 'string', nom);
assert.notEqual(texte.trim(), '', nom);
return texte;
});
assert.equal(textes.length, 3);
assert.equal(new Set(textes).size, 3);
});
test('les phases, leurs listes et chacune d’elles sont figées', () => {
assert.ok(Object.isFrozen(PHASES));
for (const phase of PHASES) {
assert.ok(Object.isFrozen(phase), phase.nom);
assert.ok(Object.isFrozen(phase.sections), phase.nom);
}
});
});

View file

@ -1,11 +1,21 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de l'événement ouvert : son en-tête, ses états, la place fixe
// de son cadre de mode, la reprise d'un verrou, ses avertissements, le
// support rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par «
// evenement. ». Une donnée — un numéro, une séance, un instant — n'entre
// dans aucun de ces textes : l'écran la pose dans son propre nœud, à côté.
// Les libellés de l'événement ouvert : son en-tête et son résumé chiffré,
// ses états, la bande et la place fixe de son cadre de mode, les phases de
// sa navigation, la reprise d'un verrou, ses avertissements, le support
// rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par
// « evenement. ». Une donnée qui désigne — un numéro, une séance, un
// instant, une date — n'entre dans aucun de ces textes : l'écran la pose
// dans son propre nœud, à côté. Les comptes du résumé, eux, entrent dans le
// leur, qui s'accorde au nombre et l'écrit par creerFormat.
import { creerFormat } from '../../application/format.js';
import { cite } from '../../application/libelles/aides.js';
// Un compte en chiffres, par le format de la traduction, suivi de son nom au
// singulier jusqu'à un, au pluriel au-delà.
const compteEcrit = (n, singulier, pluriel, texte) => `${creerFormat(texte).nombre(n)} ${n <= 1 ? singulier : pluriel}`;
export const TABLE = new Map([
// --- L'en-tête et les états du plan (§ 9) -----------------------------------
['evenement.etat.titre', 'État du plan'],
@ -14,8 +24,37 @@ export const TABLE = new Map([
['evenement.etat.retenu', 'Retenu'],
['evenement.etat.bloque', 'Bloqué'],
// --- Le résumé chiffré de l'en-tête ------------------------------------------
['evenement.resume.participants', ({ participants }, texte) => compteEcrit(participants, 'participant', 'participants', texte)],
[
'evenement.resume.tables',
({ tables, sieges }, texte) =>
`${compteEcrit(tables, 'table', 'tables', texte)} \u{00B7} ${compteEcrit(sieges, 'siège', 'sièges', texte)}`,
],
['evenement.resume.tours', ({ tours }, texte) => compteEcrit(tours, 'tour', 'tours', texte)],
// --- La bande de mode, sous le bandeau (§ 8.4, § 8.5) ------------------------
// Chaque phrase cite la commande qui sort du mode par son propre libellé,
// sauf lectureSeule : devant un format plus récent, la place fixe n'offre
// aucune commande (§ 8.8), et la phrase n'en nomme aucune.
['evenement.bande.lecture', (_, texte) => `Rien ne change ; ${cite(texte('communs.commande.modifier'))} passe en écriture.`],
[
'evenement.bande.ecriture',
(_, texte) => `Chaque geste s\u{2019}enregistre aussitôt ; ${cite(texte('communs.commande.lecture'))} arrête l\u{2019}écriture.`,
],
[
'evenement.bande.bloque',
(_, texte) => `Rien ne change tant que le plan est bloqué ; ${cite(texte('communs.commande.debloquer'))} le rouvre.`,
],
['evenement.bande.lectureSeule', 'Rien ne change ; ce fichier ne s\u{2019}ouvre qu\u{2019}en lecture.'],
// --- Les phases de la navigation des sections ---------------------------------
['evenement.phase.preparer', 'Préparer'],
['evenement.phase.placer', 'Placer'],
['evenement.phase.produire', 'Produire'],
// --- Le cadre de mode et sa place fixe (§ 8.3, § 8.4, § 8.5) -------------
['evenement.cadre.commandes', 'Mode et historique'],
['evenement.cadre.commandes', 'Commandes de l\u{2019}événement'],
['evenement.commande.defaire', 'Défaire'],
['evenement.commande.refaire', 'Refaire'],