Fusion du lot U2 : les mineurs d'interface des relectures de M7 et M6c (cibles de 44 px sur téléphone, les trois lectures entières, « Proposer cet ordre » dit pourquoi, une information à un seul endroit dans le vote déplié, l'en-tête compte ce que montre la liste, le changement d'inertie d'un module à sa ligne, settings.reading, l'anglais, le commentaire du moteur) ; C38, M2
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 11:39:56 +01:00
co-authored by Claude Opus 5.5
24 changed files with 520 additions and 79 deletions
+2 -2
View File
@@ -96,7 +96,7 @@ web/src/
# · g/[group]/decisions · g/[group]/mandats · # · g/[group]/decisions · g/[group]/mandats ·
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ; # g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
# /groupes, /decisions… redirigent # /groupes, /decisions… redirigent
web/e2e/ # Playwright C1–C37 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et web/e2e/ # Playwright C1–C38 + M1, M2 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts # chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
docker/ # app.Dockerfile · app-compose.yml (sonic) docker/ # app.Dockerfile · app-compose.yml (sonic)
.woodpecker/ # app.yml · maquette.yml .woodpecker/ # app.yml · maquette.yml
@@ -118,7 +118,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
```bash ```bash
export PATH=$HOME/.cargo/bin:$PATH export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 38 contrôles (40 tests, C25 et C35 en deux) cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 40 contrôles (42 tests, C25 et C35 en deux)
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
cd web && npm run dev # :3002 à chaud cd web && npm run dev # :3002 à chaud
+28 -3
View File
@@ -6,9 +6,12 @@
//! ``` //! ```
//! //!
//! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`. //! R is the minimum number of *for* votes; a version passes when `for ≥ ⌈R⌉`.
//! W is the size of the electorate, T the votes cast (for + against). At low //! W is the size of the electorate, T the votes cast (for + against). The share of
//! participation the formula asks for near-unanimity, at full participation the //! *for* asked is judged on T' voters, never fewer than the quorum: it is at its
//! simple majority M suffices. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02: //! highest at the quorum (the share of the level at T' = Q), then falls as turnout
//! rises above it, down to the simple majority M when the whole electorate votes.
//! Below the quorum it does not rise any further: the voters missing count as
//! against. Q is the **quorum** (ADR 0013, Yvv, 2026-11-02:
//! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir //! « ce n'est pas un nombre de vote pour a priori, c'est plutôt un quorum, à partir
//! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and //! de ce nombre, alors il faut la bonne proportion attendue par l'indice ») — and
//! below it the voters missing count as against (`judged_voters`). It replaces the //! below it the voters missing count as against (`judged_voters`). It replaces the
@@ -234,6 +237,28 @@ mod tests {
assert_eq!(e.missing, 0); assert_eq!(e.missing, 0);
} }
#[test]
fn the_share_is_highest_at_the_quorum_then_falls_to_the_majority() {
// What the head of this module says (lot U2, review of M6c): judged on T' voters, the
// share of *for* is at its highest at the quorum, falls as turnout rises, and is M when
// the whole electorate votes; below the quorum it stays that of the quorum.
let (w, q) = (5745, 12);
let p = standard(q);
let share = |cast: u32| required_ratio(w, judged_voters(cast, q), &p);
for cast in 0..q {
assert!((share(cast) - share(q)).abs() < 1e-12, "below the quorum, at {cast}");
}
let mut last = share(q);
for cast in (q + 1..w).step_by(7).chain([w]) {
let now = share(cast);
assert!(now < last, "falls at {cast}: {now} after {last}");
last = now;
}
assert!((share(w) - 0.5).abs() < 1e-12, "M at full turnout");
// Never « near-unanimity »: at this level, 85 % of the quorum.
assert!(share(q) > 0.8 && share(q) < 0.9, "at the quorum: {}", share(q));
}
#[test] #[test]
fn parity_with_the_typescript_reference() { fn parity_with_the_typescript_reference() {
// Values computed by maquette/frontend/app/engine/threshold.ts (C = 0). // Values computed by maquette/frontend/app/engine/threshold.ts (C = 0).
+10 -7
View File
@@ -4,7 +4,7 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara `npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée. sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31, C35, C37) se font dans le **groupe Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31, C35, C37, C38, M2) se font dans le **groupe
d'essai** (caché, quorum 1 à chaque cran — une voix seule y passe, ADR 0013 —, jamais publié) — jamais sur le texte de la communauté. Au départ, rien d'essai** (caché, quorum 1 à chaque cran — une voix seule y passe, ADR 0013 —, jamais publié) — jamais sur le texte de la communauté. Au départ, rien
n'y est en vigueur (ADR 0011) : les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24, n'y est en vigueur (ADR 0011) : les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24,
C28 à C31) y font d'abord entrer Raison d'être, Unicité du compte et Réciprocité par le vote (une voix C28 à C31) y font d'abord entrer Raison d'être, Unicité du compte et Réciprocité par le vote (une voix
@@ -28,20 +28,21 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille | | C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille |
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil sous la voûte tient à 412 et à 360 de large (les trois points dans l'ouverture, rien ne déborde) et montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les gestes visibles sans survol, « Le vote » d'au moins 44 px ; le vote de la Raison d'être (en tête) se déplie sous elle : « 9 oui sur 12 votants pour passer » ; « Réglages de la formule » depuis le vote : le panneau en feuille, nommé « Réglages de la formule » en tête, le tableau des cinq crans tient dans la feuille ; « Voter en lot » : le panier tient dans la feuille, son bouton « Signer et envoyer » reste en vue | | M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil sous la voûte tient à 412 et à 360 de large (les trois points dans l'ouverture, rien ne déborde) et montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les gestes visibles sans survol, « Le vote » d'au moins 44 px ; le vote de la Raison d'être (en tête) se déplie sous elle : « 9 oui sur 12 votants pour passer » ; « Réglages de la formule » depuis le vote : le panneau en feuille, nommé « Réglages de la formule » en tête, le tableau des cinq crans tient dans la feuille ; « Voter en lot » : le panier tient dans la feuille, son bouton « Signer et envoyer » reste en vue |
| M2 | Téléphone : les gestes du lecteur, les trois lectures (lot U2) | téléphone (Pixel 7), groupe d'essai : une version concurrente de la Raison d'être et un article à soi (l'automate les propose) ; lire la rangée « Lire » ; « Tout déplier » ; la Raison d'être → « Le vote » → « Autres versions (1) », « La formule du seuil » ; puis le vote de son article → « Proposer un changement d'inertie » | « En vigueur », « Si les bien cotées passent », « Si tout passe » se lisent en entier : la rangée passe à la ligne, rien ne défile ni ne se coupe ; « Tout déplier », « Autres versions (1) », « Fixer ce quorum » et **tous** les gestes en vue du vote déplié (Pour, Contre, les dépliants, « Réglages de la formule », « Coquilles… », « Soumettre au vote ») font au moins 44 px de haut |
| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis | | C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis |
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | | C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | | C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant | | C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant |
| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur | | C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur |
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | | C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Tout déplier » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version déplie son vote sous elle (cran 3, quorum 50 : « 39 oui sur 50 votants pour passer ») ; un nouveau clic sur la ligne la replie | | C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Tout déplier » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version déplie son vote sous elle (cran 3, quorum 50 : « 39 oui sur 50 votants pour passer »), sans « Autres versions » : l'autre version est juste là, dans la liste (lot U2) ; un nouveau clic sur la ligne la replie |
| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis, en tête du texte, Unicité du compte (Proposition seed) → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité (entrée en vigueur par le vote) → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois 50, le quorum de référence : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; quorum 12, 12, 50, 50, 50 (ADR 0013) ; voix pour à 100 votants 60, 67, 75, 83, 90, « Voix pour : à 100 votants, comme d'habitude. », « Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude. », « Quorum : en dessous, les votants qui manquent comptent comme « non ». … » ; depuis le vote déplié : « Inertie · 2 / 5 · basse », la ligne du cran 2 marquée « cet article », à 0 votant 8, 9, 39, 43, 46 voix, « Voix pour : à 0 votant, la participation de cette version. » ; le sélecteur montre les cinq crans, 2 « actuelle », atteignable au clavier mais jamais choisi ; un seul arrêt de tabulation ; les flèches passent d'un cran à l'autre et le choisissent, en boucle ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 2 / 5 · basse » | | C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis, en tête du texte, Unicité du compte (Proposition seed) → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité (entrée en vigueur par le vote) → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois 50, le quorum de référence : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; quorum 12, 12, 50, 50, 50 (ADR 0013) ; voix pour à 100 votants 60, 67, 75, 83, 90, « Voix pour : à 100 votants, comme d'habitude. », « Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude. », « Quorum : en dessous, les votants qui manquent comptent comme « non ». … » ; depuis le vote déplié : « Inertie · 2 / 5 · basse », la ligne du cran 2 marquée « cet article », à 0 votant 8, 9, 39, 43, 46 voix, « Voix pour : à 0 votant, la participation de cette version. » ; le sélecteur montre les cinq crans, 2 « actuelle », atteignable au clavier mais jamais choisi ; un seul arrêt de tabulation ; les flèches passent d'un cran à l'autre et le choisissent, en boucle ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 2 / 5 · basse » |
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « N oui sur Q votants pour passer » (le quorum en infobulle), « Il manque … », les crans de l'article, « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 | | C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « N oui sur Q votants pour passer » (le quorum en infobulle), « Il manque … », les crans de l'article, « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour | | C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité (Propositions seed), « Signer et envoyer (2) » ; puis, en tête du texte, sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : la Raison d'être → « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous la proposition « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre | | C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité (Propositions seed), « Signer et envoyer (2) » ; puis, en tête du texte, sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : la Raison d'être → « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous la proposition « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première Proposition seed : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et « 9 oui sur 12 votants pour passer » restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là | | C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première Proposition seed : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et « 9 oui sur 12 votants pour passer » restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là |
| C25 | L'ordre des articles | groupe d'essai : « Tout déplier » ; en fin de « Conclusion », la ligne « Ordre de la section » → « Réordonner » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (quorum 1 dans le groupe d'essai : ADR 0013) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par **une ligne** « Ordre de la section » (7, aucune pour l'Ordonnancement, un seul article) : la poignée, les crans « basse », « seed a minima · 0 vote », **jamais la liste de ses articles** ; à la fin du texte quand la section s'y lit, sinon à la fin de sa section dans « Propositions en cours » ; le vote d'un ordre en vigueur déplié sous sa ligne montre la liste entière ; « Réordonner » : les articles de la section, sur place, sans panneau ; l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie sous la ligne, l'ordre proposé en clair, « Ordre en vigueur » parmi ses « Autres versions », « 1 ordre proposé » sur la ligne ; aucune ligne d'ordre parmi les propositions ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, les gestes du titre sans survol (réordonner sur un téléphone : C35) | | C25 | L'ordre des articles | groupe d'essai : « Tout déplier » ; en fin de « Conclusion », la ligne « Ordre de la section » → « Réordonner » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (quorum 1 dans le groupe d'essai : ADR 0013) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par **une ligne** « Ordre de la section » (7, aucune pour l'Ordonnancement, un seul article) : la poignée, les crans « basse », « seed a minima · 0 vote », **jamais la liste de ses articles** ; à la fin du texte quand la section s'y lit, sinon à la fin de sa section dans « Propositions en cours » ; le vote d'un ordre en vigueur déplié sous sa ligne montre la liste entière, et son inertie une fois, « Inertie · 2 / 5 · basse » (lot U2) ; « Réordonner » : les articles de la section, sur place, sans panneau ; l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur, et il le dit à côté (« C'est l'ordre en vigueur : déplace au moins un article. ») ; le vote de l'ordre proposé se déplie sous la ligne, l'ordre proposé en clair, « Ordre en vigueur » parmi ses « Autres versions », « 1 ordre proposé » sur la ligne ; aucune ligne d'ordre parmi les propositions ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, les gestes du titre sans survol (réordonner sur un téléphone : C35) |
| C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre, une ligne : « Titre du document · seed a minima · 0 vote », les crans « inertie 4 / 5 · haute », deux gestes ; le panneau nommé « Proposer un autre titre » ; le titre proposé se vote **sous le titre** (« 1 titre proposé »), jamais parmi les propositions ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » | | C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre, une ligne : « Titre du document · seed a minima · 0 vote », les crans « inertie 4 / 5 · haute », deux gestes ; le panneau nommé « Proposer un autre titre » ; le titre proposé se vote **sous le titre** (« 1 titre proposé »), jamais parmi les propositions ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre, son inertie dite une fois (« Inertie · 4 / 5 · haute ») ; en anglais, l'ancien titre traduit, marqué « translation needs review » |
| C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude. » | | C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude. » |
| C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris | | C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris |
| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste | | C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste |
@@ -50,9 +51,10 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page | | C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé | | C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
| C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « Voter en lot », « Contre » encore, « Voter (1) » ; puis « Tes bulletins » | « Voter en lot » est là, marqué « vote simulé » ; aucune fenêtre de portefeuille ; « Vote simulé. », **jamais** « Vote enregistré. » ; « Ton vote : contre · simulé » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; le panier : « 1 vote simulé. », jamais « enregistré » ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » tant que les votes simulés ne sont pas effacés », sans bloc (l'ouverture de la chaîne n'efface rien : c'est un acte humain, sans date promise) | | C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « Voter en lot », « Contre » encore, « Voter (1) » ; puis « Tes bulletins » | « Voter en lot » est là, marqué « vote simulé » ; aucune fenêtre de portefeuille ; « Vote simulé. », **jamais** « Vote enregistré. » ; « Ton vote : contre · simulé » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; le panier : « 1 vote simulé. », jamais « enregistré » ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » tant que les votes simulés ne sont pas effacés », sans bloc (l'ouverture de la chaîne n'efface rien : c'est un acte humain, sans date promise) |
| C35 | Réordonner une section, par poignées, puis voter (lot U) | groupe d'essai : à la fin des « Engagements fondamentaux », la ligne « Ordre de la section » → « Réordonner » ; glisser la poignée de Réciprocité sur Unicité du compte ; au clavier, flèche haut puis bas ; « Descendre « Réciprocité » » ; cliquer Réciprocité puis la place 2 ; « Annuler », puis « Réordonner » et « Monter « Réciprocité » » ; « Proposer cet ordre » ; « Pour » (une deuxième voix si la sienne ne suffit pas) ; puis, sur un téléphone, dans Monnaie libre : « Tout déplier », « Ordre des articles · Engagements techniques » → « Réordonner » | la ligne ne répète aucun article ; « Réordonner » : les articles de la section, en vigueur ou seulement proposés (« Engagements fondamentaux » marqué « proposé »), sur place (pas de panneau), une poignée devant chacun, les articles en vigueur de la section repliés dans la liste (pas deux fois) ; chaque geste annonce la place (« « Réciprocité » en place 2 sur 3. ») ; « Proposer cet ordre » éteint sur l'ordre en vigueur ; « Annuler » rend la section telle qu'elle se lisait ; « Ordre proposé au vote. », son vote déplié aussitôt sous la ligne, l'ordre proposé en clair, « 1 ordre proposé » ; « Vote enregistré. » ; en vigueur, la section se lit dans le nouvel ordre, « adopté le … » ; sur un téléphone : 11 articles, rien ne déborde de la page, l'article, sa poignée et ses flèches font au moins 44 px | | C35 | Réordonner une section, par poignées, puis voter (lot U) | groupe d'essai : à la fin des « Engagements fondamentaux », la ligne « Ordre de la section » → « Réordonner » ; glisser la poignée de Réciprocité sur Unicité du compte ; au clavier, flèche haut puis bas ; « Descendre « Réciprocité » » ; cliquer Réciprocité puis la place 2 ; « Annuler », puis « Réordonner » et « Monter « Réciprocité » » ; « Proposer cet ordre » ; « Pour » (une deuxième voix si la sienne ne suffit pas) ; puis, sur un téléphone, dans Monnaie libre : « Tout déplier », « Ordre des articles · Engagements techniques » → « Réordonner » | la ligne ne répète aucun article ; « Réordonner » : les articles de la section, en vigueur ou seulement proposés (« Engagements fondamentaux » marqué « proposé »), sur place (pas de panneau), une poignée devant chacun, les articles en vigueur de la section repliés dans la liste (pas deux fois) ; chaque geste annonce la place (« « Réciprocité » en place 2 sur 3. ») ; « Proposer cet ordre » éteint sur l'ordre en vigueur, et il le dit à côté, lu aussi comme sa description (« C'est l'ordre en vigueur : déplace au moins un article. ») : la phrase s'en va dès qu'un article bouge et revient quand l'ordre redevient celui en vigueur (lot U2) ; « Annuler » rend la section telle qu'elle se lisait ; « Ordre proposé au vote. », son vote déplié aussitôt sous la ligne, l'ordre proposé en clair, « 1 ordre proposé » ; « Vote enregistré. » ; en vigueur, la section se lit dans le nouvel ordre, « adopté le … » ; sur un téléphone : 11 articles, rien ne déborde de la page, l'article, sa poignée et ses flèches font au moins 44 px |
| C36 | Combien de oui pour passer, combien de non pour bloquer (ADR 0013 ; Yvv, 02/11) | Monnaie libre : la Raison d'être (en tête) → « Le vote » ; « Tout déplier » ; puis, l'automate donnant à une proposition du groupe d'essai les totaux 9 pour · 4 contre (cran 2, quorum 12), puis 3 pour · 0 contre | avant tout vote, sous l'article : « 9 oui sur 12 votants pour passer », au survol « Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ». » ; dans la liste, en bref : « 39 oui sur 50 » (cran 3) ; à 9 pour · 4 contre : « 11 oui pour passer · 4 non pour bloquer » sous l'article, « passe à 11 oui · bloquée à 4 non » dans la liste, « Il manque 2 voix pour. » ; à 3 pour · 0 contre : « 9 oui pour passer · le quorum la bloque », « passe à 9 oui » | | C36 | Combien de oui pour passer, combien de non pour bloquer (ADR 0013 ; Yvv, 02/11) | Monnaie libre : la Raison d'être (en tête) → « Le vote » ; « Tout déplier » ; puis, l'automate donnant à une proposition du groupe d'essai les totaux 9 pour · 4 contre (cran 2, quorum 12), puis 3 pour · 0 contre | avant tout vote, sous l'article : « 9 oui sur 12 votants pour passer », au survol « Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ». » ; la ligne de la Proposition seed, juste au-dessus, ne redit plus les totaux ni les oui (lot U2) ; dans la liste, en bref : « 39 oui sur 50 » (cran 3) ; à 9 pour · 4 contre : « 11 oui pour passer · 4 non pour bloquer » sous l'article, « passe à 11 oui · bloquée à 4 non » dans la liste, « Il manque 2 voix pour. » ; à 3 pour · 0 contre : « 9 oui pour passer · le quorum la bloque », « passe à 9 oui » |
| C37 | Le quorum d'un article, par son autrice, avant le premier vote (ADR 0013) | groupe d'essai : proposer un article, puis son vote → « Quorum » : 3 → « Fixer ce quorum » ; « Revenir au défaut (1) » ; 100000 → « Fixer ce quorum » ; une autre personne ouvre le même vote, puis vote « contre » ; l'autrice tente 2 | le réglage n'apparaît qu'à l'autrice (`canSetQuorum`) : « Quorum · 1 votant », « Par défaut, celui de son cran : 1 votant. », le champ « Quorum de l'article » ; « Quorum fixé : 3 votants. », la ligne dit « … oui sur 3 votants pour passer » ; « Quorum revenu au défaut : 1 votant. » ; hors bornes (400) : « Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus … » ; l'autre personne ne voit pas le réglage ; après son vote (409) : « Quelqu'un a déjà voté sur cet article : son quorum ne change plus. », puis le réglage disparaît | | C37 | Le quorum d'un article, par son autrice, avant le premier vote (ADR 0013) | groupe d'essai : proposer un article, puis son vote → « Quorum » : 3 → « Fixer ce quorum » ; « Revenir au défaut (1) » ; 100000 → « Fixer ce quorum » ; une autre personne ouvre le même vote, puis vote « contre » ; l'autrice tente 2 | le réglage n'apparaît qu'à l'autrice (`canSetQuorum`) : « Quorum · 1 votant », « Par défaut, celui de son cran : 1 votant. », le champ « Quorum de l'article » ; « Quorum fixé : 3 votants. », la ligne dit « … oui sur 3 votants pour passer » ; « Quorum revenu au défaut : 1 votant. » ; hors bornes (400) : « Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus … » ; l'autre personne ne voit pas le réglage ; après son vote (409) : « Quelqu'un a déjà voté sur cet article : son quorum ne change pas. » (lot T2), puis le réglage disparaît |
| C38 | Ce que compte l'en-tête ; un changement d'inertie d'un ordre ou du titre (lot U2) | Monnaie libre : lire la ligne de faits sous le résumé ; « Tout déplier », compter les versions de la liste ; puis, dans le groupe d'essai, proposer un changement d'inertie de l'ordre de « Conclusion » et du titre (l'automate passe par `POST /api/units/{id}/inertia`, puis les retire) | « 0 article en vigueur · 3 Propositions seed · N propositions en cours », N le nombre de versions que montre la liste : les Propositions seed lues en tête sont dites à part, les ordres et titres proposés ne sont pas comptés (ils se votent à la ligne de leur module) ; sous le titre, la pastille « 1 changement d'inertie proposé », qui déplie son vote sous le titre ; à la fin de « Conclusion », la même pastille sur la ligne d'ordre, qui déplie son vote là ; aucun « inertie vers … » dans « Propositions en cours », et l'en-tête compte toujours ce que la liste montre |
## Au-delà de la liste ## Au-delà de la liste
@@ -93,3 +95,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-11-03 | automate | local, lot S (Propositions seed, ADR 0011 ; le quorum par cran, ADR 0013 : C3, C4, C5, C9, C18 à C20, C23, C24, C27, M1 repris) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-11-03 | automate | local, lot S (Propositions seed, ADR 0011 ; le quorum par cran, ADR 0013 : C3, C4, C5, C9, C18 à C20, C23, C24, C27, M1 repris) | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-11-03 | automate | local, lot U sur `6fb8238` (le lecteur refait : l'ordre en une ligne au bout de sa section, poignées sur place et vote ; oui pour passer · non pour bloquer ; quorum de l'auteur ; panneau sans onglets ; C3, C4, C6, C19 à C21, C23 à C26, C34, M1 repris) | C1–C37, M1 | 38 / 38 verts (40 tests) | | 2026-11-03 | automate | local, lot U sur `6fb8238` (le lecteur refait : l'ordre en une ligne au bout de sa section, poignées sur place et vote ; oui pour passer · non pour bloquer ; quorum de l'auteur ; panneau sans onglets ; C3, C4, C6, C19 à C21, C23 à C26, C34, M1 repris) | C1–C37, M1 | 38 / 38 verts (40 tests) |
| 2026-11-03 | automate | local, lot T2 sur `f27ee08` (les trois phrases de F1.1, F1.2 et N1.1 ; l'espace perdu du vote déplié ; C34 lit la ligne entière, C37 « ne change pas ») | C1–C37, M1 | 38 / 38 verts (40 tests), du premier coup | | 2026-11-03 | automate | local, lot T2 sur `f27ee08` (les trois phrases de F1.1, F1.2 et N1.1 ; l'espace perdu du vote déplié ; C34 lit la ligne entière, C37 « ne change pas ») | C1–C37, M1 | 38 / 38 verts (40 tests), du premier coup |
| 2026-11-03 | automate | local, lot U2 sur `bb4e7b7` (les mineurs de la relecture de M7 : cibles de 44 px et trois lectures entières sur téléphone, M2 ; « Proposer cet ordre » dit pourquoi, C35 ; une information à un seul endroit, C19, C36 ; l'en-tête et les changements d'inertie d'un module, C38 ; C25 et C26 lisent l'inertie dans « Inertie ») | C1–C38, M1, M2 | 40 / 40 verts (42 tests), du premier coup |
@@ -169,3 +169,27 @@ refus du serveur sont dits dans les mots de la personne : hors bornes (400), un
- La ligne d'ordre, ses gestes discrets comme ceux d'un article (au survol sur bureau, toujours - La ligne d'ordre, ses gestes discrets comme ceux d'un article (au survol sur bureau, toujours
visibles sur téléphone) : elle ne crie plus, mais « Réordonner » se trouve au survol. visibles sur téléphone) : elle ne crie plus, mais « Réordonner » se trouve au survol.
- Le mot court dans la liste : « passe à 11 oui · bloquée à 4 non ». - Le mot court dans la liste : « passe à 11 oui · bloquée à 4 non ».
## Après la relecture de M7 (lot U2, 03/11)
Les mineurs de la relecture, tranchés ainsi (captures `/tmp/ld-u2-shots/`, avant et après) :
- **Une information à un seul endroit** dans le vote déplié : l'inertie se dit une fois, dans
« Inertie », ses crans avec elle (« Inertie · ▮▮▯▯▯ 2 / 5 · basse ») ; seul le vote d'un
changement d'inertie dit sous ses totaux le cran où il se vote. La ligne d'état d'une
Proposition seed (totaux, oui, crans, autres versions) s'efface tant que son vote est déplié
juste dessous ; les gestes gardent leur place (à droite sur bureau, à gauche sur téléphone).
« Autres versions » ne nomme pas les versions déjà listées à côté (un article à deux versions,
dans la liste) ; il nomme encore le texte en vigueur, lu plus haut.
- **La ligne de faits compte ce que la page montre** : « N articles en vigueur · N Propositions
seed · N propositions en cours ». Les Propositions seed n'y sont dites que lorsqu'elles sont lues
en tête (rien en vigueur) ; « propositions en cours » compte les versions que montre la liste
du bas, jamais les ordres, titres ni changements d'inertie d'un module, qui se votent à la ligne
de leur module. La carte du document, sur la page du groupe, garde le total du serveur.
- **Un changement d'inertie d'un ordre ou du titre** a sa pastille sur la ligne de son module,
à côté de « N ordres proposés » ou « N titres proposés » : « 1 changement d'inertie proposé »,
qui déplie son vote là.
- **« Proposer cet ordre » éteint le dit**, à sa gauche (sur téléphone, au-dessus) : « C'est
l'ordre en vigueur : déplace au moins un article. », lu aussi comme description du bouton.
- **Sur téléphone** : les trois lectures passent à la ligne plutôt que de cacher « Si tout
passe » ; tous les gestes du vote déplié, « Tout déplier » compris, font au moins 44 px.
+66 -1
View File
@@ -1,5 +1,5 @@
import { expect, test, type Locator } from '@playwright/test'; import { expect, test, type Locator } from '@playwright/test';
import { DOC, expectWelcome, shot, signIn } from './helpers'; import { DOC, expectWelcome, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc } from './helpers';
/** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */ /** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */
function contrast(a: string, b: string): number { function contrast(a: string, b: string): number {
@@ -112,3 +112,68 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti
await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click(); await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
}); });
/** The height of a target, in CSS pixels. */
async function height(target: Locator): Promise<number> {
return (await target.boundingBox())?.height ?? 0;
}
/** Every gesture in sight under `root` — buttons, links, summaries — under 44 px high. */
async function small(root: Locator): Promise<string[]> {
return root.evaluate((el) =>
[...el.querySelectorAll('button, a[href], summary, [role="radio"]')]
.filter((b) => b.checkVisibility({ visibilityProperty: true }) && b.getClientRects().length > 0)
.map((b) => ({ what: (b.textContent || b.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(), h: b.getBoundingClientRect().height }))
.filter((x) => x.h < 44)
.map((x) => `${x.what} (${Math.round(x.h)} px)`),
);
}
test('M2 — téléphone : les gestes du lecteur font au moins 44 px, les trois lectures se lisent en entier', async ({ page, browser }) => {
await seedsInForce(browser);
await signIn(page, 'Chiara');
// In the test group: a rival of the Raison d'être (in force), so that its vote names its
// « Autres versions », and an article of hers, whose quorum she sets before any vote.
const doc = await testDoc(page);
const i1 = doc.units.find((u) => u.code === 'I1');
const r = await page.request.post(`/api/docs/${doc.id}/proposals`, {
data: { kind: 'text', unitId: i1?.id, title: "Raison d'être", body: 'Une clé, une personne vivante : la certification ne garantit rien d’autre.', submit: true },
headers: { 'x-requested-with': 'libredecision' },
});
expect(r.ok()).toBe(true);
const mine = await propose(page, 'Cibles au doigt', 'Je propose cet article pour régler son quorum au doigt.');
await page.goto(TEST_DOC);
// The three readings, each whole on the screen: « Si tout passe » is never cut (lot U2).
const views = page.getByRole('radiogroup', { name: 'Lire' });
const frame = await views.boundingBox();
expect.soft(await views.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
for (const reading of await views.getByRole('radio').all()) {
const b = await reading.boundingBox();
expect.soft(b && frame && b.x >= frame.x - 1 && b.x + b.width <= frame.x + frame.width + 1, await reading.textContent()).toBe(true);
}
await expect.soft(views.getByRole('radio', { name: 'Si tout passe' })).toBeInViewport({ ratio: 1 });
await shot(page, 'm2-1-lectures');
// « Tout déplier », then the vote of the Raison d'être: every gesture a finger's target.
expect.soft(await height(page.getByRole('button', { name: 'Tout déplier' }))).toBeGreaterThanOrEqual(44);
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
const others = fold.getByRole('button', { name: 'Autres versions (1)' });
expect.soft(await height(others)).toBeGreaterThanOrEqual(44);
await others.click();
await fold.getByText('La formule du seuil').click();
expect.soft(await small(fold)).toEqual([]);
await shot(page, 'm2-2-vote-deplie');
// Her own article: « Fixer ce quorum » and the rest of its vote.
await page.goto(`${TEST_DOC}?vote=${mine}`);
const hers = page.getByRole('region', { name: 'Le vote de « Cibles au doigt »' });
const set = hers.getByRole('button', { name: 'Fixer ce quorum' });
expect.soft(await height(set)).toBeGreaterThanOrEqual(44);
await hers.getByText("Proposer un changement d'inertie").click();
expect.soft(await small(hers)).toEqual([]);
await set.scrollIntoViewIfNeeded();
await shot(page, 'm2-3-quorum');
});
+3 -2
View File
@@ -60,7 +60,8 @@ test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée
await fond.hover(); await fond.hover();
await fond.getByRole('button', { name: 'Le vote' }).click(); await fond.getByRole('button', { name: 'Le vote' }).click();
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }); const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible(); // Its inertia, said once, in its own place (lot U2).
await expect(fondVote.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']); await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
await fond.getByRole('button', { name: 'Le vote' }).click(); await fond.getByRole('button', { name: 'Le vote' }).click();
@@ -171,7 +172,7 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
// In force now: its vote stays unfolded, under the title. // In force now: its vote stays unfolded, under the title.
const vote = bar.locator('section.fold'); const vote = bar.locator('section.fold');
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true'); await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible(); await expect(vote.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible(); await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible();
await shot(page, 'c26-2-titre-vote'); await shot(page, 'c26-2-titre-vote');
await bar.getByRole('button', { name: 'Le vote' }).click(); await bar.getByRole('button', { name: 'Le vote' }).click();
+54
View File
@@ -48,6 +48,10 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss
await expect(list.locator('.grip')).toHaveCount(3); await expect(list.locator('.grip')).toHaveCount(3);
const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); const submit = page.getByRole('button', { name: 'Proposer cet ordre' });
await expect(submit).toBeDisabled(); await expect(submit).toBeDisabled();
// Off, and it says why (UX-lecteur.md: « le bouton reste éteint et le dit » — lot U2).
const why = "C'est l'ordre en vigueur : déplace au moins un article.";
await expect(page.getByText(why)).toBeVisible();
await expect(submit).toHaveAccessibleDescription(why);
await shot(page, 'c35-2-poignees'); await shot(page, 'c35-2-poignees');
// By dragging its handle: Réciprocité onto the place of Unicité du compte. // By dragging its handle: Réciprocité onto the place of Unicité du compte.
@@ -55,6 +59,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss
await expect(names).toHaveText([EF, E2, E1]); await expect(names).toHaveText([EF, E2, E1]);
await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached();
await expect(submit).toBeEnabled(); await expect(submit).toBeEnabled();
await expect(page.getByText(why)).toHaveCount(0);
// With the keyboard: the article keeps the focus, the arrows move it. // With the keyboard: the article keeps the focus, the arrows move it.
const items = list.locator('button.item'); const items = list.locator('button.item');
await items.nth(1).focus(); await items.nth(1).focus();
@@ -67,6 +72,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss
await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); await list.getByRole('button', { name: `Descendre « ${E2} »` }).click();
await expect(names).toHaveText([EF, E1, E2]); await expect(names).toHaveText([EF, E1, E2]);
await expect(submit).toBeDisabled(); await expect(submit).toBeDisabled();
await expect(page.getByText(why)).toBeVisible();
await items.nth(2).click(); await items.nth(2).click();
await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true');
await items.nth(1).click(); await items.nth(1).click();
@@ -145,6 +151,9 @@ test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l
await expect(need).toBeVisible(); await expect(need).toBeVisible();
// The quorum in a tooltip, never as a title. // The quorum in a tooltip, never as a title.
await expect(need).toHaveAttribute('title', 'Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».'); await expect(need).toHaveAttribute('title', 'Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».');
// Said once: the line of the seed proposal, right above its vote, does not say it again (lot U2).
await expect(i1.locator('footer')).not.toContainText(/oui (sur|pour)/);
await expect(i1.locator('footer')).not.toContainText(/\d+ pour · \d+ contre/);
// In the list, short: at level 3, quorum 50. // In the list, short: at level 3, quorum 50.
await unfold(page); await unfold(page);
await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50'); await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50');
@@ -237,3 +246,48 @@ test('C37 — le quorum d’un article : son auteur le fixe avant le premier vot
await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0);
await yvv.context().close(); await yvv.context().close();
}); });
test('C38 — l’en-tête compte ce que montre la liste ; un changement d’inertie d’un ordre ou du titre se trouve à sa ligne', async ({ page }) => {
await signIn(page, 'Chiara');
/** The versions « Propositions en cours » shows: one per line, or those inside a line of several. */
const listed = () =>
page.locator('ul.deep li.article').evaluateAll((lis) => lis.reduce((n, li) => n + Math.max(1, li.querySelectorAll('ul.versions > li').length), 0));
// Monnaie libre, nothing in force: the seed proposals at the head, said apart; the count of the
// proposals in progress is what the list below shows (lot U2: the orders are not counted there).
await page.goto(DOC);
await unfold(page);
const facts = page.locator('.facts');
await expect.soft(facts).toContainText('0 article en vigueur · 3 Propositions seed · ');
await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`));
// A change of inertia on the order of a section and on the title (test group): each is found at
// the line of its module, where its vote unfolds — never a line of « Propositions en cours ».
const g = await (await page.request.get('/api/groups/essai')).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
const modules: { id: string; inertia: number }[] = [d.modules.orders.find((o: { section: string }) => o.section === 'conclusion'), d.modules.title];
const made: string[] = [];
for (const unit of modules) {
const r = await page.request.post(`/api/units/${unit.id}/inertia`, { data: { target: unit.inertia === 5 ? 4 : unit.inertia + 1 }, headers: CSRF });
expect(r.ok()).toBe(true);
made.push((await r.json()).id);
}
await page.goto(TEST_DOC);
const title = page.getByRole('group', { name: 'Titre du document' });
const titleChange = title.getByRole('button', { name: "1 changement d'inertie proposé" });
await expect(titleChange).toBeVisible();
await titleChange.click();
await expect(title.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible();
await shot(page, 'c38-1-titre-inertie');
await unfold(page);
await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`));
await expect(page.locator('ul.deep').getByText(/inertie vers/)).toHaveCount(0);
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
const orderChange = order.getByRole('button', { name: "1 changement d'inertie proposé" });
await expect(orderChange).toBeVisible();
await orderChange.click();
await expect(order.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible();
await order.scrollIntoViewIfNeeded();
await shot(page, 'c38-2-ordre-inertie');
// Withdrawn: the group is left as found.
for (const id of made) expect((await page.request.post(`/api/proposals/${id}/withdraw`, { headers: CSRF })).ok()).toBe(true);
});
+2
View File
@@ -298,6 +298,8 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
// Level 3, the quorum 50 (ADR 0013): 39 « oui » pass it, the voters missing counted as « non » // Level 3, the quorum 50 (ADR 0013): 39 « oui » pass it, the voters missing counted as « non »
// (« sur 50 votants » before any vote; a « contre » of another control may be there already). // (« sur 50 votants » before any vote; a « contre » of another control may be there already).
await expect(page.locator('section.fold .need')).toHaveText(/^39 oui (sur 50 votants pour passer|pour passer · le quorum la bloque)$/); await expect(page.locator('section.fold .need')).toHaveText(/^39 oui (sur 50 votants pour passer|pour passer · le quorum la bloque)$/);
// Its other version is right there in the list: the vote does not list it again (lot U2).
await expect(page.locator('section.fold').getByRole('button', { name: /^Autres versions/ })).toHaveCount(0);
await expect(page).toHaveURL(/vote=/); await expect(page).toHaveURL(/vote=/);
await e3.click(); await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(e3).toHaveAttribute('aria-expanded', 'false');
+7 -4
View File
@@ -3,12 +3,13 @@
// (ADR 0007, « cinq crans »). The water level of a vote stands upright; the // (ADR 0007, « cinq crans »). The water level of a vote stands upright; the
// weight of a text lies flat — two shapes one does not confuse. The words stay // weight of a text lies flat — two shapes one does not confuse. The words stay
// beside it: « inertie 3 / 5 · moyenne », or only « moyenne » when `short` // beside it: « inertie 3 / 5 · moyenne », or only « moyenne » when `short`
// (a screen reader still hears the whole). // (a screen reader still hears the whole), or « 3 / 5 · moyenne » when `bare`,
// after a heading that already says « Inertie ».
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { inertiaBadge } from '$lib/labels'; import { inertiaBadge, inertiaLevel } from '$lib/labels';
import type { Inertia } from '$lib/types'; import type { Inertia } from '$lib/types';
let { level, short = false }: { level: Inertia; short?: boolean } = $props(); let { level, short = false, bare = false }: { level: Inertia; short?: boolean; bare?: boolean } = $props();
const full = $derived(inertiaBadge(level)); const full = $derived(inertiaBadge(level));
</script> </script>
@@ -16,7 +17,9 @@
<span class="notches" aria-hidden="true"> <span class="notches" aria-hidden="true">
{#each [1, 2, 3, 4, 5] as k (k)}<span class="notch" class:on={k <= level}></span>{/each} {#each [1, 2, 3, 4, 5] as k (k)}<span class="notch" class:on={k <= level}></span>{/each}
</span> </span>
{#if short}<span class="visually-hidden">{full}</span><span aria-hidden="true">{t(`inertia.name.${level}`)}</span>{:else}<span>{full}</span>{/if} {#if short}<span class="visually-hidden">{full}</span><span aria-hidden="true">{t(`inertia.name.${level}`)}</span>
{:else if bare}<span>{inertiaLevel(level)}</span>
{:else}<span>{full}</span>{/if}
</span> </span>
<style> <style>
+6 -4
View File
@@ -36,7 +36,8 @@
/** Every section open (a filter is on). */ /** Every section open (a filter is on). */
all?: boolean; all?: boolean;
ontoggle: (unit: Unit, p: Proposal) => void; ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>; /** The vote of a version, and the versions already listed beside it (not named again inside). */
fold: Snippet<[Unit, Proposal, string[]]>;
/** The order line of a section, at its end. */ /** The order line of a section, at its end. */
order: Snippet<[Unit]>; order: Snippet<[Unit]>;
/** Opens the basket on a section; absent when this session does not vote here. */ /** Opens the basket on a section; absent when this session does not vote here. */
@@ -153,7 +154,7 @@
</span> </span>
</span> </span>
</button> </button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if} {#if vote === best.id}<div class="fold">{@render fold(unit, best, [])}</div>{/if}
</li> </li>
{:else if best} {:else if best}
{@const vopen = isVersionsOpen(a)} {@const vopen = isVersionsOpen(a)}
@@ -194,7 +195,7 @@
</span> </span>
</span> </span>
</button> </button>
{#if vote === p.id}{@render fold(unit, p)}{/if} {#if vote === p.id}{@render fold(unit, p, a.versions.map((v) => v.id))}{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -431,7 +432,8 @@
} }
} }
@media (max-width: 899px) { @media (max-width: 899px) {
.vote-section { .vote-section,
.linkish {
min-height: 2.75rem; min-height: 2.75rem;
} }
} }
+10 -3
View File
@@ -70,15 +70,17 @@
min-width: 0; min-width: 0;
border-radius: var(--r-input); border-radius: var(--r-input);
background: var(--surface-2); background: var(--surface-2);
padding: 0.7rem 0.9rem; padding: 0 0.9rem;
} }
/* The whole height of the closed box is the summary: a target a finger finds (lot U2). */
summary { summary {
cursor: pointer; cursor: pointer;
font-weight: 750; font-weight: 750;
color: var(--ink-soft); color: var(--ink-soft);
padding-block: 0.7rem;
} }
.formula[open] summary { .formula[open] {
margin-bottom: 0.7rem; padding-bottom: 0.7rem;
} }
.formula > :not(summary) + :not(summary) { .formula > :not(summary) + :not(summary) {
margin-top: 0.6rem; margin-top: 0.6rem;
@@ -112,4 +114,9 @@
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
@media (max-width: 899px) {
.settings-link {
min-height: 2.75rem;
}
}
</style> </style>
+18 -4
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
// The inertia of an article: its level out of five, its past, the changes // The inertia of an article: its level out of five — drawn, said once in the vote (lot
// under vote, and the choice of a new level. A change of inertia is voted at // U2) —, its past, the changes under vote, and the choice of a new level. A change of
// the level of the article itself (ADR 0007). // inertia is voted at the level of the article itself (ADR 0007).
import { api, ApiError } from '$lib/api'; import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte'; import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { inertiaLevel } from '$lib/labels'; import { inertiaLevel } from '$lib/labels';
import { INERTIA_LEVELS, type Inertia, type Unit } from '$lib/types'; import { INERTIA_LEVELS, type Inertia, type Unit } from '$lib/types';
import Crans from './Crans.svelte';
let { doc, unit, onopen }: { doc: DocState; unit: Unit; onopen: (proposalId: string) => void } = $props(); let { doc, unit, onopen }: { doc: DocState; unit: Unit; onopen: (proposalId: string) => void } = $props();
@@ -59,7 +60,7 @@
</script> </script>
<section class="inertia" aria-labelledby="inertia-{unit.id}"> <section class="inertia" aria-labelledby="inertia-{unit.id}">
<h4 id="inertia-{unit.id}">{t('inertia.title')} · <span class="muted">{inertiaLevel(unit.inertia)}</span></h4> <h4 id="inertia-{unit.id}">{t('inertia.title')} · <span class="muted"><Crans level={unit.inertia} bare /></span></h4>
<p class="faint small">{t('inertia.explain')}</p> <p class="faint small">{t('inertia.explain')}</p>
{#each unit.inertiaChanges as c (c.at + c.to)} {#each unit.inertiaChanges as c (c.at + c.to)}
<p class="faint small">{t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}</p> <p class="faint small">{t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}</p>
@@ -195,6 +196,19 @@
font-weight: 700; font-weight: 700;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* On a phone: every gesture big enough for a finger (lot U2). */
@media (max-width: 899px) {
details > summary {
padding-block: 0.75rem;
}
details[open] > summary {
margin-bottom: 0;
}
.btn,
.link {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.levels button { .levels button {
transition: none; transition: none;
+7 -1
View File
@@ -2,7 +2,8 @@
// The order of a section, at its end (ADR 0009; Yvv, 2026-11-02: « la répétition de // The order of a section, at its end (ADR 0009; Yvv, 2026-11-02: « la répétition de
// "ordonnancement" est nulle. Il faudrait que ce soit en fin de section »): one line, never // "ordonnancement" est nulle. Il faudrait que ce soit en fin de section »): one line, never
// the list of its articles again — the handle that marks an order, « Ordre de la section », // the list of its articles again — the handle that marks an order, « Ordre de la section »,
// its weight in notches, its state, the orders proposed. « Le vote » unfolds here the vote // its weight in notches, its state, the orders proposed and the changes of its inertia
// proposed (lot U2: found here, where their vote unfolds). « Le vote » unfolds here the vote
// of any of its versions (the list whole); « Réordonner » puts handles on the articles of // of any of its versions (the list whole); « Réordonner » puts handles on the articles of
// the section, in place. // the section, in place.
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -37,6 +38,7 @@
const p = $derived(unit.inForce); const p = $derived(unit.inForce);
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order')); const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order'));
const inertia = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'inertia'));
// Any version of the module unfolds here: its order in force, an order proposed, a change of its inertia. // Any version of the module unfolds here: its order in force, an order proposed, a change of its inertia.
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null); const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0)); const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
@@ -53,6 +55,10 @@
{@const first = proposed[0]} {@const first = proposed[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('order.proposed', proposed.length)}</button> <button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('order.proposed', proposed.length)}</button>
{/if} {/if}
{#if inertia.length && !reordering}
{@const first = inertia[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('module.inertia.proposed', inertia.length)}</button>
{/if}
{#if !reordering} {#if !reordering}
<span class="links"> <span class="links">
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if} {#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
@@ -110,4 +110,10 @@
font-weight: 700; font-weight: 700;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* On a phone: « Fixer ce quorum » and « Revenir au défaut » as tall as the field (lot U2). */
@media (max-width: 899px) {
.btn {
min-height: 2.75rem;
}
}
</style> </style>
+27 -6
View File
@@ -6,7 +6,8 @@
// or uses its arrows; with the keyboard, the up and down arrows move the article that has // or uses its arrows; with the keyboard, the up and down arrows move the article that has
// the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the // the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the
// vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order // vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order
// already proposed is voted, never proposed twice. // already proposed is voted, never proposed twice. While the order is the one in force,
// the button stays off and says why, beside it (lot U2, UX-lecteur.md).
import { tick, untrack } from 'svelte'; import { tick, untrack } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { api } from '$lib/api'; import { api } from '$lib/api';
@@ -141,10 +142,7 @@
} }
async function submit() { async function submit() {
if (unchanged) { if (unchanged) return;
error = t('order.edit.unchanged');
return;
}
if (existing) { if (existing) {
ondone(existing.id); ondone(existing.id);
return; return;
@@ -192,8 +190,15 @@
{#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if} {#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if} {#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions"> <div class="actions">
{#if unchanged}<p class="why" id="order-why-{unit.id}">{t('order.edit.unchanged')}</p>{/if}
<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button> <button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button> <button
class="btn primary small"
type="button"
disabled={busy || unchanged}
aria-describedby={unchanged ? `order-why-${unit.id}` : undefined}
onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button
>
</div> </div>
</div> </div>
@@ -338,15 +343,31 @@
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
justify-content: flex-end; justify-content: flex-end;
} }
/* Why the button is off, on its left: read before one reaches for it. */
.why {
flex: 1 1 14rem;
margin: 0;
font-size: 0.84rem;
font-weight: 650;
color: var(--ink-soft);
}
/* On a phone: every target is big enough for a finger. */ /* On a phone: every target is big enough for a finger. */
@media (max-width: 899px) { @media (max-width: 899px) {
.arrow { .arrow {
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
} }
.actions .btn {
min-height: 2.75rem;
}
/* Its own line, above the two buttons kept together. */
.why {
flex-basis: 100%;
}
} }
@keyframes unfold { @keyframes unfold {
from { from {
+7 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
// The title of the document is a module (ADR 0009, inertia 4): one line under the title, // The title of the document is a module (ADR 0009, inertia 4): one line under the title,
// of the same voice as the order of a section — its weight in notches, its state, the // of the same voice as the order of a section — its weight in notches, its state, the
// titles proposed. « Le vote » unfolds here the vote of any of its versions; another title // titles proposed and the changes of its inertia proposed (lot U2). « Le vote » unfolds here
// is written in the panel. // the vote of any of its versions; another title is written in the panel.
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import { moduleName } from '$lib/labels'; import { moduleName } from '$lib/labels';
@@ -33,6 +33,7 @@
const p = $derived(unit.inForce); const p = $derived(unit.inForce);
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0)); const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title')); const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title'));
const inertia = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'inertia'));
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null); const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
</script> </script>
@@ -49,6 +50,10 @@
{@const first = proposed[0]} {@const first = proposed[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('title.proposed', proposed.length)}</button> <button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('title.proposed', proposed.length)}</button>
{/if} {/if}
{#if inertia.length}
{@const first = inertia[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('module.inertia.proposed', inertia.length)}</button>
{/if}
<span class="links"> <span class="links">
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if} {#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
<button type="button" onclick={onpropose}>{t('title.propose')}</button> <button type="button" onclick={onpropose}>{t('title.propose')}</button>
+13 -2
View File
@@ -67,6 +67,9 @@
<Prose text={text.body} /> <Prose text={text.body} />
{#if actions} {#if actions}
<footer class="actions"> <footer class="actions">
<!-- Its vote unfolded says the totals, the « oui » to pass, the inertia and the other
versions: the line does not say them again (lot U2). -->
{#if !(seed && voteOpen)}
<span class="status faint"> <span class="status faint">
{#if seed} {#if seed}
{@const need = needOf(seed)} {@const need = needOf(seed)}
@@ -74,8 +77,9 @@
<span class="crans"><Crans level={seed.votedAtInertia} short /></span> <span class="crans"><Crans level={seed.votedAtInertia} short /></span>
{:else if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span> {:else if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if} {:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
{#if rivals > 0} · {i18n.plural('doc.others', rivals)}{/if} {#if rivals > 0 && !voteOpen}{' · '}{i18n.plural('doc.others', rivals)}{/if}
</span> </span>
{/if}
<span class="links"> <span class="links">
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button> <button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button> <button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
@@ -129,11 +133,13 @@
.crans { .crans {
margin-left: 0.6rem; margin-left: 0.6rem;
} }
/* The gestures keep their place whether the line of state is there or not (it steps aside
while the vote is unfolded, lot U2): on the right on a desktop, on the left on a phone. */
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 0.4rem 1rem; gap: 0.4rem 1rem;
margin-top: 0.7rem; margin-top: 0.7rem;
min-height: 2.25rem; min-height: 2.25rem;
@@ -170,6 +176,11 @@
opacity: 1; opacity: 1;
} }
} }
@media (min-width: 900px) {
.links {
margin-left: auto;
}
}
@media (max-width: 899px) { @media (max-width: 899px) {
.links button { .links button {
min-height: 2.75rem; min-height: 2.75rem;
+22 -5
View File
@@ -9,7 +9,8 @@
// many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches, // many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
// its other versions — each opens its own vote where it reads —, and the quorum its // its other versions — each opens its own vote where it reads —, and the quorum its
// author may set before the first vote. The rest of a version (typos, translations, // author may set before the first vote. The rest of a version (typos, translations,
// objections, history) lives in the panel, « Détails ». // objections, history) lives in the panel, « Détails ». Lot U2: each thing said once —
// the inertia in its own place, the other versions only those not already in sight.
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte'; import type { DocState } from '$lib/doc.svelte';
@@ -31,6 +32,7 @@
doc, doc,
proposal: p, proposal: p,
unit, unit,
besides = [],
onopen, onopen,
onsettings, onsettings,
ondetails, ondetails,
@@ -40,6 +42,8 @@
doc: DocState; doc: DocState;
proposal: Proposal; proposal: Proposal;
unit: Unit; unit: Unit;
/** The versions already in sight beside this vote (the versions of its article, listed): not named again. */
besides?: string[];
/** Unfolds the vote of another version, where it reads (another text, a change of inertia). */ /** Unfolds the vote of another version, where it reads (another text, a change of inertia). */
onopen: (proposalId: string) => void; onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void; onsettings: (proposalId: string) => void;
@@ -80,12 +84,17 @@
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source); const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
/** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */ /** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */
const need = $derived(needOf(p)); const need = $derived(needOf(p));
/** The other live versions of the module (its changes of inertia live with its inertia). */ /**
* The other live versions of the module (its changes of inertia live with its inertia),
* less those already in sight beside the vote.
*/
const others = $derived( const others = $derived(
[unit.inForce, ...unit.proposals.filter((x) => x.status === 'open' && x.kind !== 'inertia')].filter( [unit.inForce, ...unit.proposals.filter((x) => x.status === 'open' && x.kind !== 'inertia')].filter(
(x): x is Proposal => !!x && x.id !== p.id, (x): x is Proposal => !!x && x.id !== p.id && !besides.includes(x.id),
), ),
); );
/** The inertia is said by « Inertie », below; a change of inertia has none: its level is said here. */
const ownInertia = $derived(p.kind === 'inertia');
/** What tells another version apart, in a few words. */ /** What tells another version apart, in a few words. */
function otherLabel(v: Proposal): string { function otherLabel(v: Proposal): string {
@@ -165,7 +174,7 @@
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if} {#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p> <p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if} {/if}
<p class="faint small"><Crans level={p.votedAtInertia} /></p> {#if ownInertia}<p class="faint small"><Crans level={p.votedAtInertia} /></p>{/if}
</div> </div>
</div> </div>
{#if p.blocked} {#if p.blocked}
@@ -277,7 +286,7 @@
</ul> </ul>
</section> </section>
{/if} {/if}
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if} {#if !ownInertia}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if} {#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if}
{#if detail && view} {#if detail && view}
<Formula <Formula
@@ -504,6 +513,14 @@
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
/* On a phone: every gesture of the vote big enough for a finger (lot U2). */
@media (max-width: 899px) {
.choice .btn,
.disclose,
.linkish {
min-height: 2.75rem;
}
}
@keyframes unfold { @keyframes unfold {
from { from {
opacity: 0; opacity: 0;
+20 -16
View File
@@ -103,6 +103,8 @@ export const en: Messages = {
'docs.inForce.other': '{n} articles in force', 'docs.inForce.other': '{n} articles in force',
'docs.open.one': '{n} open proposal', 'docs.open.one': '{n} open proposal',
'docs.open.other': '{n} open proposals', 'docs.open.other': '{n} open proposals',
'docs.seeds.one': '{n} seed proposal',
'docs.seeds.other': '{n} seed proposals',
'doc.view.label': 'Read', 'doc.view.label': 'Read',
'doc.view.inforce': 'In force', 'doc.view.inforce': 'In force',
@@ -207,10 +209,12 @@ export const en: Messages = {
'title.submitted': 'Title submitted to vote.', 'title.submitted': 'Title submitted to vote.',
'title.proposed.one': '{n} title proposed', 'title.proposed.one': '{n} title proposed',
'title.proposed.other': '{n} titles proposed', 'title.proposed.other': '{n} titles proposed',
'module.inertia.proposed.one': '{n} inertia change proposed',
'module.inertia.proposed.other': '{n} inertia changes proposed',
'tally.short': '{for} for · {against} against', 'tally.short': '{for} for · {against} against',
'tally.missing.one': '{n} more vote in favor needed.', 'tally.missing.one': '{n} more vote in favour needed.',
'tally.missing.other': '{n} more votes in favor needed.', 'tally.missing.other': '{n} more votes in favour needed.',
'tally.reached': 'Threshold reached.', 'tally.reached': 'Threshold reached.',
'tally.level': 'Level: {pct}% of the threshold', 'tally.level': 'Level: {pct}% of the threshold',
'inertia.name.1': 'very low', 'inertia.name.1': 'very low',
@@ -242,15 +246,15 @@ export const en: Messages = {
'vote.simulated.mark': 'simulated vote', 'vote.simulated.mark': 'simulated vote',
'vote.others': 'Other versions', 'vote.others': 'Other versions',
'vote.others.count': 'Other versions ({n})', 'vote.others.count': 'Other versions ({n})',
'vote.need': '{yes} yes to pass · {no} no to block', 'vote.need': '{yes} for to pass · {no} against to block',
'vote.need.quorum': '{yes} yes to pass · the quorum blocks it', 'vote.need.quorum': '{yes} for to pass · the quorum blocks it',
'vote.need.first.one': '{yes} yes out of {quorum} voter to pass', 'vote.need.first.one': '{yes} for out of {quorum} voter to pass',
'vote.need.first.other': '{yes} yes out of {quorum} voters to pass', 'vote.need.first.other': '{yes} for out of {quorum} voters to pass',
'vote.need.short': 'passes at {yes} yes · blocked at {no} no', 'vote.need.short': 'passes at {yes} for · blocked at {no} against',
'vote.need.short.quorum': 'passes at {yes} yes', 'vote.need.short.quorum': 'passes at {yes} for',
'vote.need.short.first': '{yes} yes out of {quorum}', 'vote.need.short.first': '{yes} for out of {quorum}',
'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as “no”.', 'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as against.',
'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as “no”.', 'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as against.',
'vote.retracted': 'Vote withdrawn.', 'vote.retracted': 'Vote withdrawn.',
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
'vote.reconfirm': 'Reconfirm: {choice}', 'vote.reconfirm': 'Reconfirm: {choice}',
@@ -295,7 +299,7 @@ export const en: Messages = {
'quorum.heading.other': 'Quorum · {n} voters', 'quorum.heading.other': 'Quorum · {n} voters',
'quorum.default.one': 'By default, that of its level: {n} voter.', 'quorum.default.one': 'By default, that of its level: {n} voter.',
'quorum.default.other': 'By default, that of its level: {n} voters.', 'quorum.default.other': 'By default, that of its level: {n} voters.',
'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as “no”.', 'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as against.',
'quorum.field': 'Quorum of the article', 'quorum.field': 'Quorum of the article',
'quorum.set': 'Set this quorum', 'quorum.set': 'Set this quorum',
'quorum.reset': 'Back to the default ({n})', 'quorum.reset': 'Back to the default ({n})',
@@ -400,7 +404,7 @@ export const en: Messages = {
'ballots.verify.unreadable': 'This is not a readable vote receipt.', 'ballots.verify.unreadable': 'This is not a readable vote receipt.',
'inertia.title': 'Inertia', 'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text. Below the quorum, the missing voters count as “no”; beyond it, the share of “yes” asked for falls as turnout rises.', 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text. Below the quorum, the missing voters count as against; beyond it, the share of votes in favour asked for falls as turnout rises.',
'inertia.change': 'Propose an inertia change', 'inertia.change': 'Propose an inertia change',
'inertia.change.to': 'New inertia', 'inertia.change.to': 'New inertia',
'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.", 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.",
@@ -411,7 +415,7 @@ export const en: Messages = {
'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.', 'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.',
'formula.title': 'The threshold formula', 'formula.title': 'The threshold formula',
'formula.intro': 'The number of votes in favor required, R, depends on turnout:', 'formula.intro': 'The number of votes in favour required, R, depends on turnout:',
'formula.W': 'W — electorate: {n} ({source})', 'formula.W': 'W — electorate: {n} ({source})',
'formula.W.wot': 'members of the Ğ1 web of trust', 'formula.W.wot': 'members of the Ğ1 web of trust',
'formula.W.group': 'group members', 'formula.W.group': 'group members',
@@ -420,7 +424,7 @@ export const en: Messages = {
'formula.G': 'G — gradient: {n}', 'formula.G': 'G — gradient: {n}',
'formula.B': 'B — base: {n}', 'formula.B': 'B — base: {n}',
'formula.Q': 'Q — quorum: {n}', 'formula.Q': 'Q — quorum: {n}',
'formula.R': 'R — current threshold: {n} votes in favor', 'formula.R': 'R — current threshold: {n} votes in favour',
'formula.reading': 'Below the quorum Q, the missing voters count as against. Beyond it, the share of votes in favour asked for, highest at the quorum, falls as turnout rises; when everyone votes, majority M is enough.', 'formula.reading': 'Below the quorum Q, the missing voters count as against. Beyond it, the share of votes in favour asked for, highest at the quorum, falls as turnout rises; when everyone votes, majority M is enough.',
'common.pct': '{n}%', 'common.pct': '{n}%',
'settings.title': 'Formula settings', 'settings.title': 'Formula settings',
@@ -447,7 +451,7 @@ export const en: Messages = {
'settings.required.version.other': "Votes for: at {n} voters, this version's turnout.", 'settings.required.version.other': "Votes for: at {n} voters, this version's turnout.",
'settings.required.usual.one': 'Votes for: at {n} voter, as usual.', 'settings.required.usual.one': 'Votes for: at {n} voter, as usual.',
'settings.required.usual.other': 'Votes for: at {n} voters, as usual.', 'settings.required.usual.other': 'Votes for: at {n} voters, as usual.',
'settings.reading': 'At each level, the group asks for its share of votes in favour when it votes as usual; the more voters, the closer it comes to M. Below the quorum of the level, the missing voters count as against.', 'settings.reading': 'At each level, the group asks for its share of votes in favour when it votes as usual; beyond the quorum, the more voters, the closer it comes to M. Below the quorum of the level, the missing voters count as against.',
'history.title': 'Corrections', 'history.title': 'Corrections',
'history.initial': 'initial text', 'history.initial': 'initial text',
+5 -1
View File
@@ -103,6 +103,8 @@ export const es: Messages = {
'docs.inForce.other': '{n} artículos en vigor', 'docs.inForce.other': '{n} artículos en vigor',
'docs.open.one': '{n} propuesta en curso', 'docs.open.one': '{n} propuesta en curso',
'docs.open.other': '{n} propuestas en curso', 'docs.open.other': '{n} propuestas en curso',
'docs.seeds.one': '{n} propuesta semilla',
'docs.seeds.other': '{n} propuestas semilla',
'doc.view.label': 'Leer', 'doc.view.label': 'Leer',
'doc.view.inforce': 'En vigor', 'doc.view.inforce': 'En vigor',
@@ -207,6 +209,8 @@ export const es: Messages = {
'title.submitted': 'Título sometido a votación.', 'title.submitted': 'Título sometido a votación.',
'title.proposed.one': '{n} título propuesto', 'title.proposed.one': '{n} título propuesto',
'title.proposed.other': '{n} títulos propuestos', 'title.proposed.other': '{n} títulos propuestos',
'module.inertia.proposed.one': '{n} cambio de inercia propuesto',
'module.inertia.proposed.other': '{n} cambios de inercia propuestos',
'tally.short': '{for} a favor · {against} en contra', 'tally.short': '{for} a favor · {against} en contra',
'tally.missing.one': 'Falta {n} voto a favor.', 'tally.missing.one': 'Falta {n} voto a favor.',
@@ -447,7 +451,7 @@ export const es: Messages = {
'settings.required.version.other': 'Votos a favor: con {n} votantes, la participación de esta versión.', 'settings.required.version.other': 'Votos a favor: con {n} votantes, la participación de esta versión.',
'settings.required.usual.one': 'Votos a favor: con {n} votante, como de costumbre.', 'settings.required.usual.one': 'Votos a favor: con {n} votante, como de costumbre.',
'settings.required.usual.other': 'Votos a favor: con {n} votantes, como de costumbre.', 'settings.required.usual.other': 'Votos a favor: con {n} votantes, como de costumbre.',
'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Por debajo del quórum del nivel, los votantes que faltan cuentan como «no».', 'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor cuando vota como de costumbre; más allá del quórum, cuantos más votantes, más se acerca a M. Por debajo del quórum del nivel, los votantes que faltan cuentan como «no».',
'history.title': 'Correcciones', 'history.title': 'Correcciones',
'history.initial': 'texto inicial', 'history.initial': 'texto inicial',
+5 -1
View File
@@ -107,6 +107,8 @@ export const fr = {
'docs.inForce.other': '{n} articles en vigueur', 'docs.inForce.other': '{n} articles en vigueur',
'docs.open.one': '{n} proposition en cours', 'docs.open.one': '{n} proposition en cours',
'docs.open.other': '{n} propositions en cours', 'docs.open.other': '{n} propositions en cours',
'docs.seeds.one': '{n} Proposition seed',
'docs.seeds.other': '{n} Propositions seed',
'doc.view.label': 'Lire', 'doc.view.label': 'Lire',
'doc.view.inforce': 'En vigueur', 'doc.view.inforce': 'En vigueur',
@@ -211,6 +213,8 @@ export const fr = {
'title.submitted': 'Titre proposé au vote.', 'title.submitted': 'Titre proposé au vote.',
'title.proposed.one': '{n} titre proposé', 'title.proposed.one': '{n} titre proposé',
'title.proposed.other': '{n} titres proposés', 'title.proposed.other': '{n} titres proposés',
'module.inertia.proposed.one': "{n} changement d'inertie proposé",
'module.inertia.proposed.other': "{n} changements d'inertie proposés",
'tally.short': '{for} pour · {against} contre', 'tally.short': '{for} pour · {against} contre',
'tally.missing.one': 'Il manque {n} voix pour.', 'tally.missing.one': 'Il manque {n} voix pour.',
@@ -451,7 +455,7 @@ export const fr = {
'settings.required.version.other': 'Voix pour : à {n} votants, la participation de cette version.', 'settings.required.version.other': 'Voix pour : à {n} votants, la participation de cette version.',
'settings.required.usual.one': "Voix pour : à {n} votant, comme d'habitude.", 'settings.required.usual.one': "Voix pour : à {n} votant, comme d'habitude.",
'settings.required.usual.other': "Voix pour : à {n} votants, comme d'habitude.", 'settings.required.usual.other': "Voix pour : à {n} votants, comme d'habitude.",
'settings.reading': "À chaque cran, le groupe demande sa part de « pour » quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Sous le quorum du cran, les votants qui manquent comptent comme « non ».", 'settings.reading': "À chaque cran, le groupe demande sa part de « pour » quand il vote comme d'habitude ; au-delà du quorum, plus il y a de votants, plus elle se rapproche de M. Sous le quorum du cran, les votants qui manquent comptent comme « non ».",
'history.title': 'Corrections', 'history.title': 'Corrections',
'history.initial': 'texte initial', 'history.initial': 'texte initial',
@@ -153,6 +153,11 @@
const sectionKeys = $derived([...new Set((view?.units ?? []).map((u) => u.section))]); const sectionKeys = $derived([...new Set((view?.units ?? []).map((u) => u.section))]);
const deepSecs = $derived(deepSections(deep, deepOrders, sectionKeys)); const deepSecs = $derived(deepSections(deep, deepOrders, sectionKeys));
const canVote = $derived(!!view?.vote.open); const canVote = $derived(!!view?.vote.open);
// The facts of the head say what the page shows (lot U2): the seed proposals read at the head
// while nothing is in force, apart; then the proposals of « Propositions en cours » — the
// articles' versions, never the orders and titles proposed, voted at their module's line.
const headSeeds = $derived(doc.seedHead.length);
const openArticles = $derived(doc.open.filter((x) => !x.unit.kind).length - headSeeds);
/** The other versions of an article open to the vote, besides the one read. */ /** The other versions of an article open to the vote, besides the one read. */
const othersOf = (e: Entry) => e.unit.proposals.filter((p) => p.status === 'open' && p.kind !== 'inertia' && p.id !== e.proposal.id).length; const othersOf = (e: Entry) => e.unit.proposals.filter((p) => p.status === 'open' && p.kind !== 'inertia' && p.id !== e.proposal.id).length;
@@ -164,11 +169,12 @@
} }
</script> </script>
{#snippet foldOf(unit: Unit, p: Proposal)} {#snippet foldOf(unit: Unit, p: Proposal, besides: string[] = [])}
<VoteFold <VoteFold
{doc} {doc}
proposal={p} proposal={p}
{unit} {unit}
{besides}
onopen={(id) => go(panel, id)} onopen={(id) => go(panel, id)}
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })} onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })} ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
@@ -223,7 +229,7 @@
{/if} {/if}
<p class="summary">{summary}</p> <p class="summary">{summary}</p>
<p class="facts faint"> <p class="facts faint">
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)} {i18n.plural('docs.inForce', doc.inForce.length)} · {#if headSeeds}{i18n.plural('docs.seeds', headSeeds)}{' · '}{/if}{i18n.plural('docs.open', openArticles)}
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if} {#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button> · <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p> </p>
@@ -517,13 +523,15 @@
.new { .new {
margin-top: 1.4rem; margin-top: 1.4rem;
} }
/* On a phone: the three readings on one line that scrolls, the targets big enough for a finger. */ /* On a phone: the three readings go to the line rather than hide « Si tout passe » off the
edge (lot U2); each row fills the width, the targets big enough for a finger. */
@media (max-width: 899px) { @media (max-width: 899px) {
.seg.views { .seg.views {
flex-wrap: nowrap; display: flex;
max-width: 100%; gap: 2px;
overflow-x: auto; }
scrollbar-width: none; .seg.views button {
flex: 1 1 auto;
} }
.seg button { .seg button {
min-height: 2.75rem; min-height: 2.75rem;
+24
View File
@@ -55,3 +55,27 @@ describe('anti-lexicon', () => {
} }
}); });
}); });
// Lot U2 (reviews of M6c and M7): the same words for the same things.
describe('the reading of the formula', () => {
it('the settings say, like N1.1 and the formula, that the share falls beyond the quorum', () => {
expect(fr['formula.reading']).toContain('Au-delà');
expect(fr['settings.reading']).toContain('au-delà du quorum');
expect(en['settings.reading']).toContain('beyond the quorum');
expect(es['settings.reading']).toContain('más allá del quórum');
});
});
describe('English: one vocabulary', () => {
it('« Pour » and « Contre » are for and against, never yes and no', () => {
for (const [k, v] of Object.entries(en)) {
// What is read, the placeholders ({yes}, {no}: the numbers) left out.
const shown = v.replace(/\{\w+\}/g, '#');
expect(shown, k).not.toMatch(/\byes\b/i);
expect(shown, k).not.toMatch(/“no”|# no\b/);
}
});
it('one spelling, the one that dominates: favour', () => {
for (const [k, v] of Object.entries(en)) expect(v, k).not.toMatch(/\bfavor\b/i);
});
});
+131
View File
@@ -0,0 +1,131 @@
// Lot U2 (review of M7): in the vote unfolded, one piece of information in one place. Rendered as
// the server would (svelte/server), and read as a person reads it: the tags left out, and what a
// closed <details> holds left out too (only its summary shows).
import { render } from 'svelte/server';
import { describe, expect, it } from 'vitest';
import UnitBlock from '../../src/lib/components/UnitBlock.svelte';
import VoteFold from '../../src/lib/components/VoteFold.svelte';
import type { DocState } from '../../src/lib/doc.svelte';
import type { Inertia, Proposal, Unit } from '../../src/lib/types';
function version(id: string, over: Partial<Proposal> = {}): Proposal {
return {
id,
unitId: 'u1',
kind: 'text',
inertiaTarget: null,
title: 'Connaissance suffisante',
body: 'Je connais suffisamment la personne.',
status: 'open',
origin: 'seed',
label: null,
author: null,
mine: false,
revision: 1,
createdAt: '2026-11-03T00:00:00Z',
updatedAt: '2026-11-03T00:00:00Z',
submittedAt: null,
enteredAt: null,
confirmedAt: null,
seedMinimal: false,
tally: { for: 0, against: 0 },
tallyPending: { for: 0, against: 0 },
tallySimulated: { for: 0, against: 0 },
evaluation: { cast: 0, required: 9, reached: false, missing: 9, requiredRatio: 0.75, wellRated: false, quorum: 12, yesNeeded: 9, noNeeded: 0 },
votedAtInertia: 2,
blocked: false,
votable: true,
objections: 0,
myVote: null,
myVoteState: null,
myVoteStale: false,
myVoteChain: false,
myBallot: null,
translations: {},
...over,
};
}
function unitOf(proposals: Proposal[], inertia: Inertia = 2): Unit {
return { id: 'u1', code: 'E3', section: 'technique', position: 1, inertia, inertiaChanges: [], inForce: null, proposals };
}
/** The text a person reads: no tags, no server markers, nothing inside a closed <details> but its summary. */
function read(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/(<details(?![^>]*\sopen)[^>]*>[\s\S]*?<\/summary>)[\s\S]*?<\/details>/g, '$1')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ');
}
const noop = () => {};
const doc = { view: null, original: false, articleTitle: () => '', unit: () => undefined } as unknown as DocState;
function fold(p: Proposal, unit: Unit, besides?: string[]): string {
const { body } = render(VoteFold, {
props: { doc, proposal: p, unit, besides, onopen: noop, onsettings: noop, ondetails: noop, onanswer: noop, onsign: noop },
});
return read(body);
}
const count = (text: string, part: string) => text.split(part).length - 1;
describe('the vote unfolded says each thing once', () => {
it('the inertia of the article, once (its notches with it)', () => {
const p = version('p1');
const text = fold(p, unitOf([p]));
expect(count(text, '2 / 5 · basse'), text).toBe(1);
});
it('the inertia a change of inertia is voted at, once', () => {
const p = version('p2', { kind: 'inertia', inertiaTarget: 4, title: 'inertia:4' });
const text = fold(p, unitOf([p]));
expect(count(text, '2 / 5 · basse'), text).toBe(1);
});
it('the other versions already listed beside the vote are not listed again', () => {
const a = version('p1', { label: '1.0.0' });
const b = version('p2', { label: '0.4.0-draft' });
const unit = unitOf([a, b]);
// Alone (in the text, say), it names its other version …
expect(fold(a, unit)).toContain('Autres versions (1)');
// … but in the list of the versions of its article, the other one is right there.
expect(fold(a, unit, ['p1', 'p2'])).not.toContain('Autres versions');
});
it('an other version not in sight is still named (the text in force, above)', () => {
const inForce = version('p0', { status: 'in_force', title: 'Connaissance suffisante' });
const a = version('p1', { label: '1.0.0' });
const b = version('p2', { label: '0.4.0-draft' });
const unit = { ...unitOf([a, b]), inForce };
expect(fold(a, unit, ['p1', 'p2'])).toContain('Autres versions (1)');
});
});
function block(seed: Proposal, voteOpen: boolean, rivals = 0): string {
const { body } = render(UnitBlock, {
props: {
text: { title: seed.title, body: seed.body, translated: false, stale: false, missing: false },
seed,
rivals,
voteFor: seed.id,
voteOpen,
onaction: noop,
},
});
return read(body);
}
describe('a seed proposal at the head, its vote unfolded', () => {
const seed = version('p1', { seedProposal: true, title: "Raison d'être" });
it('folded: its line says how far it is from passing', () => {
const text = block(seed, false, 1);
expect(text).toContain('0 pour · 0 contre · 9 oui sur 12');
expect(text).toContain('1 autre version');
});
it('unfolded: the vote below says it, the line does not say it again', () => {
const text = block(seed, true, 1);
expect(text).not.toContain('0 pour · 0 contre');
expect(text).not.toContain('9 oui sur 12');
expect(text).not.toContain('basse');
expect(text).not.toContain('autre version');
});
});