[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,
// 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.
// droite, sur la première ligne du texte ; une liste trop étroite pour le
// texte et l'action côte à côte passe l'action sous le texte, qui garde
// alors toute la largeur du message. 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. La zone des
// annonces qui les porte prend la hauteur d'un avertissement ordinaire,
// action comprise, et défile au-delà (CadreMode.svelte).
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
@ -91,10 +91,9 @@
{/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. */
/* Des messages serrés : la zone des annonces en montre davantage sous
son plafond. La liste est un conteneur de largeur, que lit la
disposition étroite ci-dessous. */
.avertissements {
display: flex;
flex-direction: column;
@ -144,8 +143,10 @@
}
/* 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) {
de 30rem, comme la place des annonces à côté de la tête d'une fenêtre
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 {
grid-column: 2 / 4;
}

View file

@ -31,11 +31,15 @@
// devant un support rompu, dont les issues restent en vue.
//
// 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
// prend la hauteur de la tête et défile dedans, si bien qu'un
// avertissement ne retire aucune hauteur à la surface, ni au plan qu'elle
// porte. Une fenêtre trop étroite pour les deux les passe à la ligne, et
// la zone garde alors sa hauteur minimale.
// côté d'elle, dans la place que sa navigation laisse à droite. Leur zone
// prend la hauteur de ce qu'elle porte, jusqu'à un plafond, et la tête
// grandit avec elle : un avertissement s'y lit en entier, son action
// comprise, jamais rogné par le bas de la zone ; au-delà du plafond, la
// 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
// 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
place des annonces à droite. La place ne donne à la ligne que sa
hauteur minimale ; la zone des annonces la remplit, posée en absolu,
et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête.
La place prend une part fixe de la largeur, et le contenu le reste :
place des annonces à droite. La ligne a la hauteur du plus haut des
deux : le contenu de la tête, ou la zone des annonces. 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
section ouverte, en gras, change la largeur de la navigation. Sa part,
36 %, tient en deux lignes de Noto Sans chacun des deux avertissements
d'un journal tronqué dans une fenêtre de 1 366 px : le second paraît
dans la hauteur de la tête. Sous 30rem de contenu, la place passe à la
ligne. */
36 %, laisse au contenu les huit onglets sur une ligne dans une fenêtre
de 1 366 px. Une tête de moins de 70rem — celle d'une fenêtre de
1 024 px — passe la place à la ligne, sous le contenu, sur toute sa
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 {
flex-flow: row wrap;
container: tete / inline-size;
align-items: stretch;
gap: 0 var(--espace-3);
padding-right: 0;
@ -372,19 +377,31 @@
}
.place {
position: relative;
flex: 0 0 36%;
min-width: 16rem;
min-height: 3rem;
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. */
/* Passée à la ligne, la place prend toute la largeur de la tête, séparée
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 {
position: absolute;
inset: 0;
padding-block: var(--espace-1);
box-sizing: border-box;
max-height: 7.25rem;
padding-block: 0.125rem;
}
/* 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)', () => {

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();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
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();
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
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.
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
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).toBeLessThan(boiteZone.bottom - 16);
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]');
expect(tables.length).toBe(4);
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);

View file

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

View file

@ -402,6 +402,56 @@ describe('retenir, voir sur le plan', () => {
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 () => {
const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }));
m.ui.montrerProposition(1);

View file

@ -323,8 +323,15 @@
border-left: 3px solid var(--couleur-etat-retenu-marque);
}
td[data-colonne='proposition'] {
min-width: 7rem;
/* Les colonnes de phrases ont la largeur d'une phrase courte : la
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 {