diff --git a/src/interface/evenement/Avertissements.svelte b/src/interface/evenement/Avertissements.svelte index 750dfb2..3ee4748 100644 --- a/src/interface/evenement/Avertissements.svelte +++ b/src/interface/evenement/Avertissements.svelte @@ -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 })}
-

{t(avertissement.code, avertissement.details)}

+ +

{t(avertissement.code, avertissement.details)}

+ {#if action !== null} +
+ +
+ {/if} {#if avertissement.fautives !== undefined && avertissement.fautives.length > 0} {/if} - {#if action !== null} - - {/if}
{/each} {/if} diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte index ad4cc36..a9479c8 100644 --- a/src/interface/evenement/CadreMode.svelte +++ b/src/interface/evenement/CadreMode.svelte @@ -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'} {:else if commande === 'modifier'} - seance.passerEnEcriture()} /> + seance.passerEnEcriture()} /> {:else if commande === 'debloquer'} {#if cadre !== null} - {t(cadre.cle)} + + {t(cadre.cle)} + {/if} {#if commande !== 'format'}
@@ -98,6 +126,16 @@ {/if}
{/if} + {#if outils !== null} +
+ {@render outils()} +
+ {/if} + {#if actions !== null} +
+ {@render actions()} +
+ {/if} {#if verrou !== null}
@@ -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 diff --git a/src/interface/evenement/EnteteEvenement.svelte b/src/interface/evenement/EnteteEvenement.svelte index c204d90..059de24 100644 --- a/src/interface/evenement/EnteteEvenement.svelte +++ b/src/interface/evenement/EnteteEvenement.svelte @@ -2,41 +2,35 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->

{$seance.evenement?.nom}

-
- {#each ETATS_PLAN as etat (etat)} -
- seance.executer('changerEtat', { etat })} /> -
- {/each} -
- ui.ouvrirApercu({ vue: 'plan', tour: $ui.tour })} /> + {#if resume !== null} +

+ {#if resume.date !== null} + {format.date(resume.date)} + {/if} + {t('evenement.resume.participants', { participants: resume.participants })} + {t('evenement.resume.tables', { tables: resume.tables, sieges: resume.sieges })} + {t('evenement.resume.tours', { tours: resume.tours })} +

+ {/if}
diff --git a/src/interface/evenement/Enveloppe.navigateur.test.js b/src/interface/evenement/Enveloppe.navigateur.test.js new file mode 100644 index 0000000..a76af76 --- /dev/null +++ b/src/interface/evenement/Enveloppe.navigateur.test.js @@ -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); + }); +}); diff --git a/src/interface/evenement/EtatsPlan.svelte b/src/interface/evenement/EtatsPlan.svelte new file mode 100644 index 0000000..85591fd --- /dev/null +++ b/src/interface/evenement/EtatsPlan.svelte @@ -0,0 +1,163 @@ + + + + +
+ + {#each etapes as etape, rang (etape.etat)} + {#if rang > 0} + + {/if} +
+ + seance.executer('changerEtat', { etat: etape.etat })} /> +
+ {/each} +
+ + diff --git a/src/interface/evenement/Evenement.navigateur.test.js b/src/interface/evenement/Evenement.navigateur.test.js index 6552896..5ed2ba3 100644 --- a/src/interface/evenement/Evenement.navigateur.test.js +++ b/src/interface/evenement/Evenement.navigateur.test.js @@ -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); diff --git a/src/interface/evenement/Evenement.svelte b/src/interface/evenement/Evenement.svelte index dc64e03..1cf62bd 100644 --- a/src/interface/evenement/Evenement.svelte +++ b/src/interface/evenement/Evenement.svelte @@ -2,16 +2,32 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> +{#snippet outils()} + +{/snippet} + +{#snippet actions()} + ui.ouvrirApercu({ vue: 'plan', tour: $ui.tour })} + /> +{/snippet} + {#snippet tete()} {/snippet} @@ -85,14 +133,17 @@ {/snippet}
- + +