[FIX] event header, proposals: unclipped warning, wider proposal column

The header warning sat in a zone that could not grow past the header
content, so a five-line warning lost its last line at 1366 x 700. The
zone now takes part in the layout: the header grows with it up to
7.25rem, then the zone scrolls; under a 70rem header it drops below at
full width. The proposal cell gets a 14rem minimum, so the kept row no
longer wraps word by word. Checked: node 4012, browser 868.

--- FR ---

[FIX] en-tête, propositions : avis jamais rogné, colonne plus large

L'avis de l'en-tête vivait dans une zone qui ne dépassait pas le contenu
de l'en-tête : un avis de cinq lignes perdait sa dernière à 1366 x 700.
La zone prend part à la mise en page : l'en-tête grandit avec elle
jusqu'à 7,25rem, puis elle défile ; sous un en-tête de 70rem, elle passe
dessous en pleine largeur. La cellule de proposition prend 14rem au
moins, la ligne retenue ne se replie plus mot à mot.
Vérifié : node 4012, navigateur 868.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 01:45:15 -04:00
parent 27f652c732
commit 6f7cd3511b
7 changed files with 162 additions and 42 deletions

View file

@ -12,13 +12,13 @@
// //
// Chacun est un message d'avis : le fond, le filet et l'encre de l'avis, // 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 à // 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 // droite, sur la première ligne du texte ; une liste trop étroite pour le
// des annonces, qui défile à la hauteur de la tête, l'action d'un // texte et l'action côte à côte passe l'action sous le texte, qui garde
// avertissement se voit dès que son début se voit, et n'est jamais // alors toute la largeur du message. Ce qu'il nomme — propositions
// rognée par le bas de la zone. Ce qu'il nomme — propositions écartées, // écartées, placements en dérive — vient dessous, sur toute la largeur du
// placements en dérive — vient dessous, sur toute la largeur du message : // message : l'action n'étroitise que la première phrase. La zone des
// l'action n'étroitise que la première phrase. Une zone trop étroite pour // annonces qui les porte prend la hauteur d'un avertissement ordinaire,
// le texte et l'action côte à côte passe l'action sous le texte. // action comprise, et défile au-delà (CadreMode.svelte).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte'; import Icone from '../primitives/Icone.svelte';
@ -91,10 +91,9 @@
{/if} {/if}
<style> <style>
/* Des messages serrés : dans la zone des annonces, à la hauteur de la /* Des messages serrés : la zone des annonces en montre davantage sous
tête, les deux avertissements d'un journal abîmé, de deux lignes son plafond. La liste est un conteneur de largeur, que lit la
chacun, se voient l'un et l'autre. La liste est un conteneur de disposition étroite ci-dessous. */
largeur, que lit la disposition étroite ci-dessous. */
.avertissements { .avertissements {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -144,8 +143,10 @@
} }
/* Une liste trop étroite pour le texte et l'action côte à côte — moins /* Une liste trop étroite pour le texte et l'action côte à côte — moins
de 26rem — passe l'action sous le texte. */ de 30rem, comme la place des annonces à côté de la tête d'une fenêtre
@container (max-width: 26rem) { de 1 366 px — passe l'action sous le texte : le texte y garde toute la
largeur du message, et tient en moins de lignes. */
@container (max-width: 30rem) {
p { p {
grid-column: 2 / 4; grid-column: 2 / 4;
} }

View file

@ -31,11 +31,15 @@
// devant un support rompu, dont les issues restent en vue. // devant un support rompu, dont les issues restent en vue.
// //
// Sous une tête, et hors d'un support rompu, les annonces se posent à // Sous une tête, et hors d'un support rompu, les annonces se posent à
// côté d'elle, dans la place que sa navigation laisse à droite : leur zone // côté d'elle, dans la place que sa navigation laisse à droite. Leur zone
// prend la hauteur de la tête et défile dedans, si bien qu'un // prend la hauteur de ce qu'elle porte, jusqu'à un plafond, et la tête
// avertissement ne retire aucune hauteur à la surface, ni au plan qu'elle // grandit avec elle : un avertissement s'y lit en entier, son action
// porte. Une fenêtre trop étroite pour les deux les passe à la ligne, et // comprise, jamais rogné par le bas de la zone ; au-delà du plafond, la
// la zone garde alors sa hauteur minimale. // zone défile. Un avertissement qui tient dans la hauteur de la tête ne
// retire donc aucune hauteur à la surface, ni au plan qu'elle porte, et
// des avertissements nombreux ne lui retirent que ce qui sépare le
// plafond de la hauteur de la tête. Une tête trop étroite pour les deux
// passe la place à la ligne, sous son contenu, sur toute sa largeur.
// //
// La tête se rend en un seul point du DOM, quelle que soit la place des // La tête se rend en un seul point du DOM, quelle que soit la place des
// annonces : seules les annonces passent de côté à dessous quand une // annonces : seules les annonces passent de côté à dessous quand une
@ -341,18 +345,19 @@
} }
/* À côté de la tête : la tête passe en ligne, son contenu à gauche, la /* À côté de la tête : la tête passe en ligne, son contenu à gauche, la
place des annonces à droite. La place ne donne à la ligne que sa place des annonces à droite. La ligne a la hauteur du plus haut des
hauteur minimale ; la zone des annonces la remplit, posée en absolu, deux : le contenu de la tête, ou la zone des annonces. La place prend
et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête. une part fixe de la largeur, et le contenu le reste :
La place prend une part fixe de la largeur, et le contenu le reste :
la zone ne bouge pas d'une section à l'autre, quand le libellé de la la zone ne bouge pas d'une section à l'autre, quand le libellé de la
section ouverte, en gras, change la largeur de la navigation. Sa part, section ouverte, en gras, change la largeur de la navigation. Sa part,
36 %, tient en deux lignes de Noto Sans chacun des deux avertissements 36 %, laisse au contenu les huit onglets sur une ligne dans une fenêtre
d'un journal tronqué dans une fenêtre de 1 366 px : le second paraît de 1 366 px. Une tête de moins de 70rem — celle d'une fenêtre de
dans la hauteur de la tête. Sous 30rem de contenu, la place passe à la 1 024 px — passe la place à la ligne, sous le contenu, sur toute sa
ligne. */ largeur, où un avertissement d'une phrase tient en deux lignes, son
action à côté. La tête est pour cela un conteneur de largeur. */
.tete.cote { .tete.cote {
flex-flow: row wrap; flex-flow: row wrap;
container: tete / inline-size;
align-items: stretch; align-items: stretch;
gap: 0 var(--espace-3); gap: 0 var(--espace-3);
padding-right: 0; padding-right: 0;
@ -372,19 +377,31 @@
} }
.place { .place {
position: relative;
flex: 0 0 36%; flex: 0 0 36%;
min-width: 16rem; min-width: 16rem;
min-height: 3rem; min-height: 3rem;
border-left: 1px solid var(--couleur-carte-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 /* Passée à la ligne, la place prend toute la largeur de la tête, séparée
avertissements d'un journal abîmé paraissent dans sa hauteur. */ du contenu par un filet au-dessus d'elle. */
@container tete (max-width: 70rem) {
.place {
flex-basis: 100%;
border-top: 1px solid var(--couleur-carte-filet);
border-left: none;
}
}
/* Dans la place, la zone a un retrait vertical de 2 px et un plafond,
retrait compris : 7,25rem tiennent entier, dans la place de 36 % d'une
fenêtre de 1 366 px, un avertissement de trois lignes de texte et son
action dessous (Avertissements.svelte). Au-delà, la zone défile, et la
tête, qui ne grandit pas plus, laisse le reste du cadre à la surface. */
.place > .annonces { .place > .annonces {
position: absolute; box-sizing: border-box;
inset: 0; max-height: 7.25rem;
padding-block: var(--espace-1); padding-block: 0.125rem;
} }
/* Seules — sans tête, en plein écran, ou devant un support rompu —, les /* Seules — sans tête, en plein écran, ou devant un support rompu —, les

View file

@ -433,6 +433,41 @@ describe('les avertissements, en messages d’avis', () => {
}); });
} }
}); });
// Le texte de l'avertissement et son action ne sont jamais rognés : ni
// le texte, ni le message, ni aucune boîte qui les porte jusqu'au cadre
// ne déborde de sa hauteur, et chacun tient dans la boîte visible de
// chaque ascendant qui coupe ce qui le dépasse.
for (const largeur of [1366, 1024]) {
test(`à ${largeur} × 700, le texte de PLACES_MANQUANTES et « Ouvrir les tables » se lisent en entier : aucune boîte ne les rogne jusqu’au cadre`, async () => {
await fixerCadre(largeur, 700);
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 texte = message.querySelector('p');
const action = message.querySelector('[data-commande]');
expect(texte.textContent).toBe(t('PLACES_MANQUANTES', { placesManquantes: 1 }));
expect(action).not.toBeNull();
const borne = cadre(m);
for (let element = texte; element !== borne.parentElement; element = element.parentElement) {
const nom = `${element.tagName.toLowerCase()}${element.hasAttribute('data-annonces') ? '[data-annonces]' : ''}`;
expect(element.scrollHeight, `${nom} : hauteur du contenu`).toBeLessThanOrEqual(element.clientHeight + 1);
if (element === texte || style(element).overflowY === 'visible') continue;
const b = boite(element);
const bas = b.top + element.clientTop + element.clientHeight;
for (const [lu, porte] of [['texte', texte], ['action', action]]) {
expect(boite(porte).top, `${lu} dans ${nom}`).toBeGreaterThanOrEqual(b.top + element.clientTop - 0.5);
expect(boite(porte).bottom, `${lu} dans ${nom}`).toBeLessThanOrEqual(bas + 0.5);
}
}
expect(visible(action)).toBe(true);
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
});
}
}); });
describe('le support rompu, en message de refus (§ 8.6)', () => { describe('le support rompu, en message de refus (§ 8.6)', () => {

View file

@ -756,7 +756,7 @@ describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', (
} }
}); });
test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan garde toute sa hauteur, et ses chaises au zoom ajusté', async () => { test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan ne cède que ce dont leur zone dépasse la tête, et garde ses chaises au zoom ajusté', async () => {
const m = await monter(); const m = await monter();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 }); await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
await m.seance.passerEnEcriture(); await m.seance.passerEnEcriture();
@ -775,6 +775,10 @@ describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', (
expect(dans(m, '[data-annonces]')).toBeNull(); expect(dans(m, '[data-annonces]')).toBeNull();
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height; const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
expect(hauteurSeule).toBeGreaterThan(0); expect(hauteurSeule).toBeGreaterThan(0);
// Le haut de la surface qui porte la section : la tête qui grandit le
// pousse d'autant.
const surface = () => dans(m, '[data-section]').parentElement.getBoundingClientRect();
const hautSeul = surface().top;
// Trois gestes admis sur le retenu, chacun une dérive annoncée. // Trois gestes admis sur le retenu, chacun une dérive annoncée.
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } }); await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
await m.seance.executer('creerTable', { forme: 'ronde' }); await m.seance.executer('creerTable', { forme: 'ronde' });
@ -790,7 +794,13 @@ describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', (
expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top); expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top);
expect(premier.top).toBeLessThan(boiteZone.bottom - 16); expect(premier.top).toBeLessThan(boiteZone.bottom - 16);
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight); expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - 1); // La zone, à son plafond, défile ; la tête ne grandit que de ce dont la
// zone la dépasse, et le plan ne cède que cela.
expect(zone.scrollHeight).toBeGreaterThan(zone.clientHeight);
const pousse = surface().top - hautSeul;
expect(pousse).toBeGreaterThanOrEqual(0);
expect(pousse).toBeLessThan(boiteZone.height);
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - pousse - 1);
const tables = tous(m, '[data-plan] [data-table]'); const tables = tous(m, '[data-plan] [data-table]');
expect(tables.length).toBe(4); expect(tables.length).toBe(4);
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8); expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);

View file

@ -36,10 +36,11 @@
// écran, et à lui seul. // écran, et à lui seul.
// //
// Le support rompu et les avertissements se posent dans la zone des // Le support rompu et les avertissements se posent dans la zone des
// annonces du cadre, qui défile seule, et non au-dessus de la section : la // annonces du cadre, qui défile seule au-delà de son plafond, et non
// section garde la hauteur de la surface, plan compris, quel que soit le // au-dessus de la section : la section ne cède à la zone que ce dont elle
// nombre des avertissements, et le support rompu, en tête de la zone, // dépasse la tête, quel que soit le nombre des avertissements, et le
// garde ses issues en vue (CadreMode.svelte). // support rompu, en tête de la zone, garde ses issues en vue
// (CadreMode.svelte).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import SectionBadges from '../badges/SectionBadges.svelte'; import SectionBadges from '../badges/SectionBadges.svelte';
import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte';
@ -170,8 +171,7 @@
/* Les phases côte à côte, séparées d'un blanc : les huit onglets tiennent /* 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 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 1 366 px (CadreMode.svelte). */
(CadreMode.svelte). */
nav { nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -402,6 +402,56 @@ describe('retenir, voir sur le plan', () => {
expect(ids(m)).toEqual(vue.lignes.map(({ id }) => id)); expect(ids(m)).toEqual(vue.lignes.map(({ id }) => id));
}); });
// Les lignes du texte d'un élément : les boîtes de ses fragments de
// texte, rangées de haut en bas, dont chacune ouvre une ligne quand elle
// commence sous le milieu de la ligne courante.
function lignesDuTexte(element) {
const boites = [];
const parcours = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let noeud = parcours.nextNode(); noeud !== null; noeud = parcours.nextNode()) {
const plage = document.createRange();
plage.selectNodeContents(noeud);
boites.push(...[...plage.getClientRects()].filter((r) => r.width > 0 && r.height > 0));
}
boites.sort((a, b) => a.top - b.top);
let lignes = 0;
let milieu = -Infinity;
for (const r of boites) {
if (r.top < milieu) continue;
lignes += 1;
milieu = (r.top + r.bottom) / 2;
}
return lignes;
}
test('à 1 366 px, la cellule de la ligne du retenu tient son numéro et « Tel que la proposition n° 3. » en trois lignes au plus', async () => {
const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }));
const cellule = await attendre(() => {
const trouvee = ligneRetenu(m)?.querySelector('td[data-colonne="proposition"]');
expect(trouvee ?? null).not.toBeNull();
return trouvee;
});
const vue = tableau(evaluation(charge(m)).table);
const libelle = t(vue.retenu.libelle.cle, vue.retenu.libelle.details);
expect(libelle).toBe('Tel que la proposition n° 3.');
expect(texteDe(cellule)).toBe(`${format.nombre(3)} ${libelle}`);
expect(lignesDuTexte(cellule)).toBeLessThanOrEqual(3);
});
test('à 1 366 px, la cellule du retenu ajusté tient son numéro et « Ajusté à la main depuis la proposition n° 2. » en trois lignes au plus', async () => {
const m = await ouvrir(chargeSix());
commande(ligne(m, 2), 'retenir').click();
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
await m.seance.executer('deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
const libelle = t('propositions.retenu.ajuste', { proposition: 2 });
const cellule = await attendre(() => {
const trouvee = ligneRetenu(m).querySelector('td[data-colonne="proposition"]');
expect(texteDe(trouvee)).toBe(`${format.nombre(2)} ${libelle}`);
return trouvee;
});
expect(lignesDuTexte(cellule)).toBeLessThanOrEqual(3);
});
test('« Voir » de la ligne du retenu montre le retenu sur le plan, après une proposition demandée, sans écriture', async () => { test('« Voir » de la ligne du retenu montre le retenu sur le plan, après une proposition demandée, sans écriture', async () => {
const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 })); const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }));
m.ui.montrerProposition(1); m.ui.montrerProposition(1);

View file

@ -323,8 +323,15 @@
border-left: 3px solid var(--couleur-etat-retenu-marque); border-left: 3px solid var(--couleur-etat-retenu-marque);
} }
td[data-colonne='proposition'] { /* Les colonnes de phrases ont la largeur d'une phrase courte : la
min-width: 7rem; cellule des propositions tient son numéro et « Tel que la proposition
n° 3. » en deux lignes, « Ajusté à la main depuis… » en trois, et celle
des retours imposés chacune de ses deux phrases en deux lignes au plus.
Plus étroites, leurs phrases passeraient à la ligne mot à mot et
allongeraient la ligne entière ; le tableau défile déjà en largeur. */
td[data-colonne='proposition'],
td[data-colonne='imposes'] {
min-width: 14rem;
} }
.identifiant { .identifiant {