[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:
parent
229a2d4067
commit
8b69b63a32
14 changed files with 1434 additions and 139 deletions
|
|
@ -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;
|
||||
|
|
|
|||
85
src/interface/evenement/BandeMode.svelte
Normal file
85
src/interface/evenement/BandeMode.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
476
src/interface/evenement/Enveloppe.navigateur.test.js
Normal file
476
src/interface/evenement/Enveloppe.navigateur.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
163
src/interface/evenement/EtatsPlan.svelte
Normal file
163
src/interface/evenement/EtatsPlan.svelte
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue