diff --git a/CLAUDE.md b/CLAUDE.md index 388636c..771e658 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,8 @@ web/src/ lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier), # ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList, - # OrderModule / OrderEditor (l'ordre d'une section, la liste où l'on clique), + # OrderLine / Reorder (l'ordre d'une section : une ligne en fin de section, les poignées + # sur ses articles, sur place), QuorumSetting (le quorum de l'auteur), Crans (l'inertie dessinée), # TitleBar / TitlePropose (le titre du document), Seal… lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence, versions retenues, modules @@ -88,11 +89,12 @@ web/src/ lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) · # g/[group]/docs · g/[group]/docs/[doc] (lecteur, - # vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages) + # vote déplié ?vote=, panneau sans onglets ?onglet=panier|proposer|details|reglages ; + # l'ancien onglet=propositions déplie le vote ; note : docs/chantiers/acte-certification/UX-lecteur.md) # · g/[group]/decisions · g/[group]/mandats · # g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ; # /groupes, /decisions… redirigent -web/e2e/ # Playwright C1–C34 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et +web/e2e/ # Playwright C1–C37 + M1 ; 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 docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml @@ -114,7 +116,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH 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 : 35 contrôles (36 tests, C25 en deux) +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 38 contrôles (40 tests, C25 et C35 en deux) LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 950cb49..901f5e5 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -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 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) se font dans le **groupe +Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31, C35, C37) 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 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 @@ -17,31 +17,31 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. |---|---|---|---| | C1 | L'accueil, sans session | ouvrir `/` sans être connectée | la clé de voûte en grand, jamais inclinée ; sous l'arche, centrés et dans cet ordre : « boîte à outils », « contexte et cadrage », « processus et modalités » ; puis le titre centré sur deux lignes, « Rendre possible » / « la décision collective » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | -| C3 | Le texte au départ : les Propositions seed | Monnaie libre → Docs de référence → Acte d'engagement Certification | aucun article en vigueur (ADR 0011) : « Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil. », sous le titre du document, puis à la fin de chaque section ses modules (C25, C26) ; en dessous, « Propositions en cours » (33 lignes, une par article), dont Raison d'être, Unicité du compte et Réciprocité marquées « Proposition seed » (au survol : « Texte de départ mis en avant pour lancer le vote… »), « 0 pour · 0 contre », « inertie 2 / 5 · basse » | -| C4 | Gestes contextuels | groupe d'essai, où la Raison d'être est entrée en vigueur par le vote : la survoler → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : le seuil et les voix qui manquent ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | +| C3 | Le texte au départ : les Propositions seed | Monnaie libre → Docs de référence → Acte d'engagement Certification | aucun article en vigueur (ADR 0011) : « Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition. » ; puis, **en tête du texte**, en entier, Raison d'être (sous « Introduction »), Unicité du compte et Réciprocité (sous « Engagements fondamentaux »), chacune marquée « Proposition seed » (au survol : « Texte de départ mis en avant pour lancer le vote… »), sur un lavis d'eau, avec « 0 pour · 0 contre · 9 oui sur 12 » et « basse » (au lecteur d'écran : « inertie 2 / 5 · basse ») ; chaque section lue finit par une ligne d'ordre (C25) ; en dessous, « Propositions en cours » **repliées par section** (8 lignes : le nom, « 11 articles », un petit niveau d'eau par article) ; « Tout déplier » : 30 lignes, une par article (les trois Propositions seed ne s'y répètent pas) | +| C4 | Gestes contextuels | groupe d'essai, où la Raison d'être est entrée en vigueur par le vote : la survoler → « Le vote » | deux gestes sous l'article, « Le vote » et « Proposer une modification » (ses autres versions se lisent dans le vote) ; le vote se **déplie sous l'article**, sans fenêtre : « N oui sur Q votants pour passer » et les voix qui manquent ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie | | C5 | Entrée en vigueur | groupe d'essai (Raison d'être, Unicité du compte et Réciprocité entrées en vigueur par le vote) : déplier le vote de « Les deux garanties réciproques », « Pour » ; une deuxième personne si cette voix ne suffit pas | à la voix qui suffit, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (autant de « pour » que de voix, 0 contre) | -| C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) | +| C6 | Coquille sans vote | groupe d'essai, **en administratrice** (un texte du seed n'a pas d'auteur) : Unicité du compte → « Le vote » → « Coquilles, traductions, objections, historique » (le panneau « Détails », sans onglets) → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » (sans être administratrice, la correction attend : C31) | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | | C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis aucun (rien en vigueur dans Monnaie libre, rien de bien coté sans « pour ») | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | 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 | -| 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 trois gestes visibles sans survol ; le vote se déplie sous l'article ; « Réglages de la formule » depuis le vote : le panneau en feuille, l'onglet « Réglages » visible, 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 | | 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 | | 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 | | 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 » | -| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « 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 ; 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, dans « Propositions en cours », Unicité du compte → son 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 », « Seuil : … voix pour », « Il manque … », « 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 | +| 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 | +| 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 | | 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, dans « Propositions en cours », sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « 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://?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 : « 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 le seuil 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 : en fin de « Conclusion », « Proposer un autre ordre » ; 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 (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; 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 « Ordre des articles ·
» (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : 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 dans la liste des propositions, l'ordre proposé en clair ; 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, rien ne déborde, chaque ligne fait au moins 44 px | -| 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 : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à 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 » | +| 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://?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à | +| 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) | +| 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 » | | 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 | | 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 | @@ -49,7 +49,10 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv. | C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » | | 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--.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 « Tes bulletins » | « Voter en lot » est là ; aucune fenêtre de portefeuille ; « Ton vote : contre » aussitôt, et un « contre » de plus dans les totaux ; dans l'API, `myVoteState` « simulated » et `tallySimulated` ; dans « Tes bulletins » : « Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1 », sans bloc | +| 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 | +| 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 » | +| 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 | ## Au-delà de la liste @@ -88,3 +91,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-11-02 | automate | local, lot G sur `5d1f865` (décision d'Yvv du 01/11 : sur une version retenue, « contre » et retrait, jamais « pour » ; C28 ajusté : « Contre » et le retrait sous l'article, le panier) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-11-02 | automate | local, lot G fusionné (`0919b46`) et les articles des règles réécrits (amendement de seed sur une proposition ouverte) | 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) | diff --git a/docs/chantiers/acte-certification/UX-lecteur.md b/docs/chantiers/acte-certification/UX-lecteur.md new file mode 100644 index 0000000..0681a71 --- /dev/null +++ b/docs/chantiers/acte-certification/UX-lecteur.md @@ -0,0 +1,171 @@ +# Le lecteur d'un document — note de conception (lot U) + +> Demande d'Yvv du 2026-11-02 : « la page est trop longue, trop fournie et on ne comprend pas tout +> de suite » ; l'ordonnancement répété est « nul » ; distinguer les natures des modules (les +> textes, l'inertie, l'ordonnancement) ; pas de redondance entre les consoles sous l'article et +> celles du panneau ; le choix entre déplier sur place et ouvrir à droite doit être logique. +> Écrite avant le code. Elle tranche ; ce qu'Yvv devrait regarder est en fin de note. + +## Ce que montraient les captures d'avant + +`/tmp/ld-u-shots/avant/` (base neuve, Monnaie libre et groupe d'essai, bureau et téléphone) : + +- **6 300 px de haut sur bureau.** Trois articles en vigueur, puis **sept blocs « Ordre des + articles · … »** dans le texte en vigueur, dont cinq pour des sections qui n'y ont aucun article : + chaque bloc redit la liste des articles de sa section. Puis trente lignes de propositions. +- **Sur téléphone, le premier écran ne montre aucun article** : fil d'Ariane, titre, barre du + module titre, résumé, faits, aperçus sur deux lignes, langues. +- **Les mêmes mots pour des choses différentes** : « Propositions » est un geste sous l'article + (qui ouvre le panneau), un onglet du panneau, et le titre de la liste du bas. « Proposer » est un + onglet, « Proposer une modification » un geste, « Proposer un autre ordre » un troisième. +- **Le panneau a des onglets qui ne sont pas frères** : le panier (tout le document), les versions + d'un article, un formulaire, les détails d'une version, les réglages du groupe. +- Les trois natures ne se distinguaient que par le fond du bloc d'ordre (le lavis d'eau) et par + la ligne « inertie 3 / 5 · moyenne » écrite partout en toutes lettres. + +## La règle : sur place, ou à droite + +**Sur place** (on déplie, sous ce qu'on regarde) : **ce qui se lit et se décide sur ce qu'on a +sous les yeux**. Le vote d'une version, ses autres versions, son inertie, et l'ordre d'une section +(les poignées apparaissent sur les articles mêmes). + +**À droite** (un panneau, une feuille sur téléphone) : **ce qui s'écrit, ou travaille sur +plusieurs modules, ou se consulte à côté du texte**. Proposer (un article, un titre), le panier, +les détails d'une version (coquilles, traductions, objections, historique), les réglages de la +formule. + +Pourquoi : déplier garde l'œil sur le texte qu'on juge et ne cache rien du reste ; le panneau +donne la place d'écrire et de comparer sans perdre la page. Un geste qui agit sur un seul module +et tient en quelques lignes ne mérite pas une fenêtre ; un formulaire ou une vue d'ensemble ne +tient pas dans un accordéon. + +**Le vote d'une version se déplie là où la version se lit** : un texte en vigueur sous lui ; une +proposition d'article dans « Propositions en cours » ; une version du titre sous le titre ; un +ordre à la fin de sa section. Un seul vote déplié à la fois, toujours dans l'URL (`?vote=`). + +## Les consoles : avant, après + +| Fonction | Avant | Après (un nom, un endroit) | +|---|---|---| +| Voter une version | « Le vote » sous l'article (déplié) | **« Le vote »**, déplié sous la version | +| Les autres versions d'un article | « Propositions » sous l'article → onglet « Propositions » du panneau | **dans « Le vote »**, « Autres versions » : chacune ouvre son propre vote, là où elle se lit | +| Proposer un autre texte | « Proposer une modification » → onglet « Proposer » | **« Proposer une modification »** → panneau « Proposer » | +| Proposer un nouvel article | bouton en bas → onglet « Proposer » | inchangé | +| Proposer un autre titre | « Proposer un autre titre » → onglet « Proposer » | inchangé (panneau : on écrit) | +| Proposer un autre ordre | bloc d'ordre → « Proposer un autre ordre » → onglet « Proposer » (une liste répétée) | **« Réordonner »**, à la fin de la section : les poignées sur les articles mêmes, puis on vote | +| Voir les ordres proposés | une ligne parmi les articles, dans la liste du bas | **dans « Le vote » de l'ordre**, à la fin de la section | +| Changer l'inertie | dans « Le vote » | inchangé | +| Voter en lot | « Voter en lot » (en-tête), « Voter cette section » (liste) → onglet « Panier » | inchangé : deux portes, un seul panier | +| Coquilles, traductions, objections, historique | lien dans « Le vote » → onglet « Détails » | inchangé → panneau « Détails » | +| Réglages de la formule | lien en-tête et lien dans « Le vote » → onglet « Réglages » | inchangé → panneau « Réglages » | +| La liste du bas | « Propositions en cours » | inchangé, le seul endroit qui porte ce nom | + +Le panneau perd ses onglets : il montre **une chose à la fois**, nommée en tête (« Panier », +« Proposer », « Détails », « Réglages »), avec sa croix. Une ancienne adresse +`?onglet=propositions` déplie le vote de l'article, sans panneau. + +## Les natures des modules + +Un document se lit en **sections** ; une section contient des **articles** ; chaque module porte +une **inertie** ; chaque section a un **ordre** ; le document a un **titre**. Chaque nature a sa +voix, discrète, prise dans le design existant (sans bordure, arrondis, quatre ambiances) : + +- **Texte** (l'article, son titre et son corps) : la lecture. Titre en petites capitales d'eau, + corps en Literata. C'est le seul gros caractère de la page. +- **Section** : son nom en Literata italique, couleur d'encre douce. On sait où l'on est, sans + numéro. +- **Inertie** : **cinq crans** dessinés, à l'horizontale (▮▮▮▯▯), à côté de « inertie 3 / 5 · + moyenne ». Le niveau d'eau (le vote) est vertical ; les crans (le poids du texte) horizontaux : + deux formes qu'on ne confond pas. Les crans suivent l'inertie partout : sous l'article, sous le + titre, sur la ligne d'ordre, dans le vote, dans la liste. +- **Ordonnancement** : la **poignée** (six points). Elle marque la ligne d'ordre qui ferme une + section, puis apparaît sur chaque article quand on réordonne. Le mot est « Ordre de la + section » là où la section est sous les yeux ; « Ordre des articles ·
» ailleurs + (panier, messages), où il faut la nommer. +- **Titre du document** : une ligne sous le titre, de la même voix que la ligne d'ordre (petite, + Nunito, crans, état), avec « Le vote » et « Proposer un autre titre ». +- **Proposition seed** (lot S, `seedProposal`) : proposée au départ pour amorcer le vote, pas en + vigueur. Marque « proposition seed » en lavis d'eau, niveau d'eau visible, jamais le corps nu + d'un texte en vigueur. **Quand aucun article n'est en vigueur**, le texte en vigueur, vide, laisse + sa place aux Propositions seed, en entier, chacune avec son vote : on sait par où commencer. Elles + ne sont pas répétées dans la liste du bas. +- **Vote simulé** (lot V, `vote.simulated`) : les boutons de vote apparaissent partout comme si le + vote était ouvert, avec la marque « vote simulé » à côté. Ton vote s'affiche aussitôt, avec son + état (`myVoteState` : enregistré, en attente, simulé), et les voix en attente à côté des totaux + (`tallyPending`). + +## Une page courte, lisible d'un coup d'œil + +Ce qu'on voit d'abord, dans l'ordre : le titre du document et sa ligne de module ; le résumé ; une +ligne de faits et d'outils (articles en vigueur, propositions en cours, « Voter en lot », +« Réglages de la formule ») ; la lecture (en vigueur, aperçus, langue). Puis le texte. + +Ce qui est replié par défaut : + +- **Le vote** de chaque version, et dedans la formule et le changement d'inertie (comme avant). +- **L'ordre d'une section** : une seule ligne à sa fin, jamais la liste. Il n'apparaît que pour une + section d'au moins deux articles, **à la fin de la section là où elle se lit** : dans le texte en + vigueur si elle y a un article, sinon à la fin de sa section dans « Propositions en cours ». La + liste des articles ne se lit qu'en réordonnant, ou dans le vote d'un ordre proposé (ses + déplacements marqués). +- **Les sections de « Propositions en cours »** : une ligne par section, son nom, le nombre + d'articles proposés et, en petit, le niveau d'eau de chacun (on voit où le soutien monte sans + rien ouvrir). On déplie une section d'un geste ; « Tout déplier » les ouvre toutes ; un filtre + (bien cotées, les miennes) ou un vote déplié par l'URL ouvre la section qui le contient. + +## Réordonner une section + +À la fin de la section, « Réordonner » : les articles de la section (ceux en vigueur et ceux +seulement proposés, marqués « proposé ») se replient sur leur titre, une poignée devant chacun. + +- **Au glisser** : on prend la poignée, on dépose à la nouvelle place. +- **Au clavier** : chaque article est un bouton ; les flèches haut et bas le déplacent, le focus + le suit, la nouvelle place est annoncée (« « … » en place 2 sur 3 »). +- **Sans glisser** : un clic sur un article le choisit, un clic sur un autre le met à sa place ; ou + ses flèches monter / descendre (cibles de 44 px sur téléphone). + +Puis **« Proposer cet ordre »** : l'ordre part au vote et son vote se déplie aussitôt, au même +endroit, avec « Pour » et « Contre ». Si l'ordre formé est l'ordre en vigueur, le bouton reste +éteint et le dit ; s'il est déjà proposé, on vote celui-là au lieu d'en créer un double. +« Annuler » rend la section telle qu'elle se lisait. + +Routes inchangées (ADR 0009) : `POST /api/units/{id}/order` pour proposer, le vote d'une version +pour voter. + +## Combien de oui pour passer, combien de non pour bloquer + +Yvv, 2026-11-02 : « combien il faut de oui pour passer et combien de non pour maintenir +l'opposition ». Le serveur le dit (ADR 0013 : `yesNeeded`, `noNeeded`, `quorum`) ; l'écran le +répète sans rien calculer (`needOf`, `web/src/lib/labels.ts`), le quorum en infobulle, jamais en +titre : + +| Cas | Sous l'article déplié | En bref, dans la liste | +|---|---|---| +| aucun vote | « 9 oui sur 12 votants pour passer » | « 9 oui sur 12 » | +| des votes, des « non » la tiennent | « 11 oui pour passer · 4 non pour bloquer » | « passe à 11 oui · bloquée à 4 non » | +| sous le quorum, les absents la tiennent (`noNeeded` = 0) | « 9 oui pour passer · le quorum la bloque » | « passe à 9 oui » | + +Cette ligne remplace « Seuil : R voix pour » sous l'article (R à la participation du moment +induisait en erreur dès qu'un « non » arrivait) ; R reste dans « La formule du seuil ». « Il +manque N voix pour. » reste : c'est `yesNeeded − pour`. + +## Le quorum d'un article + +Visible seulement quand le serveur le permet (`canSetQuorum` : l'auteur de la première +version, avant le premier vote ; un administrateur pour un texte du seed), dans le vote déplié, +sous l'inertie : un champ nombre, le défaut du cran rappelé, « Revenir au défaut (n) ». Les +refus du serveur sont dits dans les mots de la personne : hors bornes (400), un vote déjà là +(409), pas l'auteur (403). Les bornes restent au serveur : l'écran ne les recalcule pas. + +## Ce qu'Yvv devrait regarder + +- Les sections de « Propositions en cours » **repliées par défaut** : la page tient en un écran + et demi, mais une proposition demande un geste de plus pour être vue. +- Le panneau **sans onglets** : on ne passe plus des détails au panier d'un clic dans le panneau ; + on ferme, puis on ouvre l'autre. +- Les ordres et les titres proposés **ne sont plus des lignes** de « Propositions en cours » : ils + se lisent et se votent à leur module (le panier les garde). +- Quand rien n'est en vigueur, les Propositions seed **en tête, en entier**. +- 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. +- Le mot court dans la liste : « passe à 11 oui · bloquée à 4 non ». diff --git a/web/e2e/bulletins.spec.ts b/web/e2e/bulletins.spec.ts index 8067954..a9660bd 100644 --- a/web/e2e/bulletins.spec.ts +++ b/web/e2e/bulletins.spec.ts @@ -12,8 +12,11 @@ import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } fr type Entry = { id: string; status: string; comment: string }; const mine = async (page: Page): Promise => (await (await page.request.get('/api/ballots')).json()).ballots; -/** Unfolds the vote of an open proposal, from « Propositions en cours ». */ +/** Unfolds the vote of an open proposal, from « Propositions en cours » (its sections folded: lot U). */ async function unfold(page: Page, title: string) { + await page.locator('ul.deep li.section').first().waitFor(); + const all = page.getByRole('button', { name: 'Tout déplier' }); + if (await all.isVisible()) await all.click(); // By its title only: the excerpt of another proposal may name it. await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click(); return page.getByRole('region', { name: `Le vote de « ${title} »` }); diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index d793b5c..908967f 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -80,6 +80,17 @@ export function deepRow(page: Page, title: string) { return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }); } +/** + * « Propositions en cours » shows its sections folded (lot U): « Tout déplier » opens them + * all, so that a line can be clicked. + */ +export async function unfold(page: Page) { + const all = page.getByRole('button', { name: 'Tout déplier' }); + await expect(all).toBeVisible(); + await all.click(); + await expect(page.getByRole('button', { name: 'Tout replier' })).toBeVisible(); +} + /** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */ export async function shot(page: Page, name: string) { const dir = process.env.LD_SHOTS; diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 0d914fd..cb7e0de 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -80,14 +80,19 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti await drawer.getByRole('button', { name: 'Fermer' }).first().click(); // The vote unfolds under the article, on a phone too (a seed proposal, ADR 0011: nothing is - // in force at the start, its vote unfolds in the list of proposals). - await page.locator('ul.deep button.row').first().click(); - const fold = page.locator('ul.deep section.fold'); - await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible(); + // in force at the start, the seed proposals open the text — lot U). + const first = page.locator('section.text article.unit').first(); + const toggle = first.getByRole('button', { name: 'Le vote' }); + // A finger's target, and nothing to hover with: the gestures are in sight. + expect((await toggle.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); + await toggle.click(); + const fold = page.locator('section.text section.fold'); + // Level 2, the quorum 12: « sur 12 votants » before any vote; the desktop controls may have voted. + await expect(fold.locator('.need')).toHaveText(/^9 oui (sur 12 votants pour passer|pour passer · le quorum la bloque)$/); // The settings of the formula read on a phone: the table of the five levels holds in the sheet. await fold.getByRole('button', { name: 'Réglages de la formule' }).click(); const panel = page.locator('aside.panel'); - await expect(panel.getByRole('tab', { name: 'Réglages' })).toBeInViewport(); + await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeInViewport(); await expect(panel.locator('table tbody tr')).toHaveCount(5); const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); expect(fits).toBe(true); diff --git a/web/e2e/modules.spec.ts b/web/e2e/modules.spec.ts index cab972a..4c10d14 100644 --- a/web/e2e/modules.spec.ts +++ b/web/e2e/modules.spec.ts @@ -1,22 +1,18 @@ import { devices, expect, test, type Browser, type Page } from '@playwright/test'; -import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers'; +import { ALICE, DOC, person, shot, signIn, TEST_DOC, unfold, wallet } from './helpers'; // Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the -// order of its articles (inertia 2), proposed with a list one clicks; at the head of -// the document, its title (inertia 4). Both voted like an article: unfolded, in the -// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs). -// The server is shared with the specs that follow: the test group keeps its articles -// and their votes; its conclusion is reordered and its title changes. +// order of its articles (inertia 2); at the head of the document, its title (inertia 4). +// Both voted like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre +// (simulated here: e2e/chain-mock.mjs). Lot U (Yvv, 2026-11-02): the order is one line at +// the end of its section, never the list of its articles again; « Réordonner » puts handles +// on them in place (C35); every version of a module is voted at its place, never among the +// proposals. The server is shared with the specs that follow: the test group keeps its +// articles and their votes; its conclusion is reordered and its title changes. const K1 = 'Règles abrégées de la toile de confiance'; const K2 = 'Monnaie G1'; -/** A vote unfolded in the list of proposals: « Pour », then the news. */ -async function voteFor(page: Page, row: string) { - await page.locator('ul.deep button.row', { hasText: row }).click(); - await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); -} - /** Whether a version of a module of the test group is in force now. */ async function inForce(page: Page, proposalId: string): Promise { const g = await (await page.request.get('/api/groups/essai')).json(); @@ -27,37 +23,39 @@ async function inForce(page: Page, proposalId: string): Promise { /** * The voices that bring a module version of the test group into force: hers, then a * second one if hers was not enough. Every dev sign-in joins the test group: from 16 - * members B^W vanishes and, C being 1, one voice may suffice (ADR 0007). Returns the - * number of voices for it. + * members B^W vanishes and, the quorum being 1, one voice may suffice (ADR 0013). Returns + * the number of voices for it. The second one votes at the module, by its address. */ -async function secondVoice(page: Page, browser: Browser, proposalId: string, row: string, news: string): Promise { +async function secondVoice(page: Page, browser: Browser, proposalId: string, fold: string, news: string): Promise { if (await inForce(page, proposalId)) { await expect(page.getByText(news)).toBeVisible(); return 1; } const yvv = await person(browser, 'Yvv'); - await yvv.goto(TEST_DOC); - await voteFor(yvv, row); + await yvv.goto(`${TEST_DOC}?vote=${proposalId}`); + await yvv.getByRole('region', { name: fold }).getByRole('button', { name: 'Pour', exact: true }).click(); await expect(yvv.getByText(news)).toBeVisible(); return 2; } -test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => { +test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée sur place, votée là, en vigueur', async ({ page, browser }) => { await signIn(page, 'Chiara'); await page.goto(TEST_DOC); - // Each section ends with its order; a section of one article has nothing to decide. - const modules = page.locator('section.text [role="group"]'); + await unfold(page); + // Each section ends with its order, in one line; a section of one article has nothing to decide. + const modules = page.getByRole('group', { name: /^Ordre des articles · / }); await expect(modules).toHaveCount(7); await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0); const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' }); - await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K1}`, `2 ${K2}`]); - await expect(order).toContainText('2 articles · inertie 2 / 5 · basse'); + await expect(order).toContainText('Ordre de la section'); + await expect(order).toContainText('inertie 2 / 5 · basse'); await expect(order.getByText('seed a minima · 0 vote')).toBeVisible(); + // Never the list of its articles again: it is read in its vote, or while reordering. + await expect(order.getByText(K1)).toHaveCount(0); await order.scrollIntoViewIfNeeded(); await shot(page, 'c25-1-ordre-lecture'); - // Its vote unfolds under it, like an article's, the list whole (the mark's real number of - // votes is seen in Monnaie libre, below: here one voice may confirm a seed). + // Its vote unfolds under its line, like an article's, the list whole. const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' }); await fond.hover(); await fond.getByRole('button', { name: 'Le vote' }).click(); @@ -66,22 +64,21 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, 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(); - // Another order: the article, then its new place. + // Another order: « Réordonner », the article, then its new place — in place, no panel. await order.hover(); - await order.getByRole('button', { name: 'Proposer un autre ordre' }).click(); - await expect(page).toHaveURL(/onglet=proposer/); - const panel = page.locator('aside.panel'); - const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' }); + await order.getByRole('button', { name: 'Réordonner' }).click(); + await expect(page.locator('aside.panel')).toHaveCount(0); + const list = order.getByRole('list', { name: 'Nouvel ordre des articles' }); const items = list.locator('button.item'); const names = list.locator('button.item .name'); await expect(names).toHaveText([K1, K2]); - const submit = panel.getByRole('button', { name: 'Proposer cet ordre' }); + const submit = order.getByRole('button', { name: 'Proposer cet ordre' }); await expect(submit).toBeDisabled(); await items.nth(1).click(); await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true'); await items.nth(0).click(); await expect(names).toHaveText([K2, K1]); - await expect(panel.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached(); + await expect(order.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached(); await expect(submit).toBeEnabled(); // With the keyboard: the moved article keeps the focus, the arrows move it. await expect(items.nth(0)).toBeFocused(); @@ -91,38 +88,43 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, await page.keyboard.press('ArrowUp'); await expect(names).toHaveText([K2, K1]); // Or its arrows. - await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click(); - await panel.getByRole('button', { name: `Monter « ${K2} »` }).click(); + await order.getByRole('button', { name: `Descendre « ${K2} »` }).click(); + await order.getByRole('button', { name: `Monter « ${K2} »` }).click(); await expect(names).toHaveText([K2, K1]); await shot(page, 'c25-2-ordre-propose'); await submit.click(); await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); - // Its vote unfolds in the list of proposals, the list it proposes in sight. - const fold = page.locator('ul.deep section.fold'); + // Its vote unfolds at the line, the list it proposes in sight; never a line among the proposals. + const fold = order.locator('section.fold'); await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »'); - await expect(fold.locator('ol.order li')).toHaveText([K2, K1]); - await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible(); - await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`); + await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]); + await expect(order).toContainText('1 ordre proposé'); + await expect(page.locator('ul.deep button.row .title', { hasText: /^Ordre des articles · / })).toHaveCount(0); + // The order in force is read from it, among its other versions (folded until asked). + await fold.getByRole('button', { name: 'Autres versions (1)' }).click(); + await expect(fold.getByRole('region', { name: 'Autres versions' })).toContainText('Ordre en vigueur'); const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, ''); await fold.getByRole('button', { name: 'Pour', exact: true }).click(); await expect(page.getByText('Vote enregistré.')).toBeVisible(); await shot(page, 'c25-3-ordre-vote'); // Into force (her voice, or a second one): the section reads in its new order. - const voices = await secondVoice(page, browser, pid, 'Ordre des articles · Conclusion', 'Le nouvel ordre de « Conclusion » est entré en vigueur.'); + const voices = await secondVoice(page, browser, pid, 'Le vote de « Ordre des articles · Conclusion »', 'Le nouvel ordre de « Conclusion » est entré en vigueur.'); // In the basket too: at the end of its section. await page.getByRole('button', { name: /^Voter en lot/ }).click(); + const panel = page.locator('aside.panel'); const basket = panel.getByRole('region', { name: 'Conclusion' }); await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion'); await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); await page.reload(); - await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]); - // In force now: its vote stays unfolded, under the module, the list whole. + // In force now: its vote stays unfolded under its line, the list whole, in the new order. await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`); + await expect(order.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText([K2, K1]); await order.scrollIntoViewIfNeeded(); await shot(page, 'c25-3b-ordre-en-vigueur'); await expect(order).toContainText(/adopté le/); + await unfold(page); const rows = await page.locator('ul.deep button.row .title').allTextContents(); expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1)); }); @@ -131,12 +133,16 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions await signIn(page, 'Chiara'); await page.goto(TEST_DOC); const head = page.locator('header.head'); + // A module, named for what it is (in French here; the language changes below). + await expect(head.getByRole('group', { name: 'Titre du document' })).toBeVisible(); const bar = head.locator('.title-unit'); - await expect(bar).toContainText('Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote'); + await expect(bar).toContainText('Titre du document · seed a minima · 0 vote'); + await expect(bar).toContainText('inertie 4 / 5 · haute'); await head.hover(); await bar.getByRole('button', { name: 'Proposer un autre titre' }).click(); await expect(page).toHaveURL(/onglet=proposer/); const panel = page.locator('aside.panel'); + await expect(panel.getByRole('heading', { name: 'Proposer un autre titre' })).toBeVisible(); const input = panel.getByRole('textbox', { name: 'Nouveau titre' }); await expect(input).toHaveValue("Acte d'engagement Certification"); const submit = panel.getByRole('button', { name: 'Proposer un autre titre' }); @@ -147,13 +153,15 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions await shot(page, 'c26-1-titre-propose'); await submit.click(); await expect(page.getByText('Titre proposé au vote.')).toBeVisible(); - const fold = page.locator('ul.deep section.fold'); + // Its vote unfolds under the title, where it is read; never a line among the proposals. + const fold = bar.locator('section.fold'); await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`); - await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document'); + await expect(bar).toContainText('1 titre proposé'); + await expect(page.locator('ul.deep').getByText(next)).toHaveCount(0); const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, ''); await fold.getByRole('button', { name: 'Pour', exact: true }).click(); await expect(page.getByText('Vote enregistré.')).toBeVisible(); - const voices = await secondVoice(page, browser, pid, next, `Le titre « ${next} » est entré en vigueur.`); + const voices = await secondVoice(page, browser, pid, `Le vote de « ${next} »`, `Le titre « ${next} » est entré en vigueur.`); // The document changes title; its translations stay editorial, to review. await page.reload(); @@ -194,7 +202,7 @@ test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que await shot(page, 'c27-participation-bornee'); }); -test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, la liste tient', async ({ browser }) => { +test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, puis se lit à sa ligne', async ({ browser }) => { // The chain is waited for: the server reads the indexer every 10 seconds. test.setTimeout(90_000); const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' }); @@ -219,23 +227,10 @@ test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans ); await fond.scrollIntoViewIfNeeded(); await shot(page, 'c25-4-ordre-mobile'); - // The title on a phone: its gestures in sight, without a pointer to hover with. + // The title on a phone: its gestures in sight, without a pointer to hover with. Reordering on a + // phone: C35. await page.goto(TEST_DOC); await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1'); await shot(page, 'c26-4-titre-mobile'); - // The list one clicks, on a phone: nothing overflows, the buttons are big enough to touch. - const tech = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' }); - await tech.getByRole('button', { name: 'Proposer un autre ordre' }).click(); - const items = panel.getByRole('list', { name: 'Nouvel ordre des articles' }).locator('button.item'); - await expect(items).toHaveCount(11); - await items.nth(3).click(); - await items.nth(0).click(); - await expect(panel.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled(); - const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); - expect(holds).toBe(true); - const box = await items.nth(0).boundingBox(); - expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); - await shot(page, 'c25-5-ordre-propose-mobile'); - await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded(); await ctx.close(); }); diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index 45dd711..30aff62 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -28,7 +28,8 @@ async function openBox(page: Page, unread: number) { async function details(page: Page, id: string) { await page.goto(`${TEST_DOC}?onglet=details&version=${id}`); const panel = page.locator('aside.panel'); - await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); + // One thing at a time in the panel (lot U), named at its head. + await expect(panel.getByRole('heading', { name: 'Détails', exact: true })).toBeVisible(); return panel; } diff --git a/web/e2e/ordre-quorum.spec.ts b/web/e2e/ordre-quorum.spec.ts new file mode 100644 index 0000000..f30c60b --- /dev/null +++ b/web/e2e/ordre-quorum.spec.ts @@ -0,0 +1,238 @@ +import { devices, expect, test, type Page } from '@playwright/test'; +import { deepRow, DOC, person, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc, unfold } from './helpers'; + +// Lot U — the reader (Yvv, 2026-11-02: « la répétition de "ordonnancement" est nulle. Il +// faudrait que ce soit en fin de section, qui ajoute des "poignées" aux modules de la section. +// Et permette de voter. » ; « combien il faut de oui pour passer et combien de non pour +// maintenir l'opposition »). Run after modules.spec (C25 reads the order of the fundamental +// commitments as the seed gives it): here it is reordered, voted, and may enter into force. +test.beforeAll(async ({ browser }) => seedsInForce(browser)); + +const EF = 'Engagements fondamentaux'; +const E1 = 'Unicité du compte'; +const E2 = 'Réciprocité'; +const ORDER = `Ordre des articles · ${EF}`; +const CSRF = { 'x-requested-with': 'libredecision' }; + +/** Whether a version of a module of the test group is in force now. */ +async function moduleInForce(page: Page, proposalId: string): Promise { + 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(); + return [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId); +} + +test('C35 — réordonner une section : les poignées sur ses articles, au glisser, au clavier, sans glisser, puis le vote', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + await page.goto(TEST_DOC); + // The order closes its section in one line: its name, its weight, its state — never the list again. + const line = page.getByRole('group', { name: ORDER }); + await expect(line).toBeVisible(); + await expect(line).toContainText('Ordre de la section'); + await expect(line).toContainText('inertie 2 / 5 · basse'); + await expect(line.getByText(E1)).toHaveCount(0); + // Nowhere a block that repeats the articles of a section, and no order among the proposals. + await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0); + await expect(page.locator('ul.deep').getByText(/^Ordre des articles · /)).toHaveCount(0); + await line.scrollIntoViewIfNeeded(); + await shot(page, 'c35-1-ligne-ordre'); + + // « Réordonner »: the articles of the section fold to their titles, a handle before each, in place. + await line.getByRole('button', { name: 'Réordonner' }).click(); + await expect(page.locator('aside.panel')).toHaveCount(0); + const list = page.locator('section.text').getByRole('list', { name: 'Nouvel ordre des articles' }); + const names = list.locator('button.item .name'); + await expect(names).toHaveText([EF, E1, E2]); + // The articles in force of the section are in the list now, not twice; the one only proposed says so. + await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(0); + await expect(list.locator('li', { hasText: EF })).toContainText('proposé'); + await expect(list.locator('.grip')).toHaveCount(3); + const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); + await expect(submit).toBeDisabled(); + await shot(page, 'c35-2-poignees'); + + // By dragging its handle: Réciprocité onto the place of Unicité du compte. + await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: E1 })); + await expect(names).toHaveText([EF, E2, E1]); + await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); + await expect(submit).toBeEnabled(); + // With the keyboard: the article keeps the focus, the arrows move it. + const items = list.locator('button.item'); + await items.nth(1).focus(); + await page.keyboard.press('ArrowUp'); + await expect(names).toHaveText([E2, EF, E1]); + await expect(items.nth(0)).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(names).toHaveText([EF, E2, E1]); + // Without dragging: its arrows, or an article then its new place. + await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); + await expect(names).toHaveText([EF, E1, E2]); + await expect(submit).toBeDisabled(); + await items.nth(2).click(); + await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); + await items.nth(1).click(); + await expect(names).toHaveText([EF, E2, E1]); + // « Annuler » gives the section back as it read. + await page.getByRole('button', { name: 'Annuler' }).click(); + await expect(list).toHaveCount(0); + await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(1); + await line.getByRole('button', { name: 'Réordonner' }).click(); + await list.getByRole('button', { name: `Monter « ${E2} »` }).click(); + await expect(names).toHaveText([EF, E2, E1]); + + // Then the vote: the order goes to the vote, and its vote unfolds at once, in the same place. + await submit.click(); + await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); + const fold = line.locator('section.fold'); + await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${ORDER} »`); + await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1]); + await expect(line).toContainText('1 ordre proposé'); + const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, ''); + await fold.getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(page.getByText('Vote enregistré.')).toBeVisible(); + await shot(page, 'c35-3-ordre-vote'); + // Into force, by her voice or a second one: the section reads in its new order. + if (!(await moduleInForce(page, pid))) { + const yvv = await person(browser, 'Yvv'); + const r = await yvv.request.post(`/api/proposals/${pid}/vote`, { data: { choice: 'for' }, headers: CSRF }); + expect(r.ok()).toBe(true); + await yvv.context().close(); + } + expect(await moduleInForce(page, pid)).toBe(true); + await page.goto(TEST_DOC); + await expect(page.locator('section.text article.unit', { hasText: E2 })).toBeVisible(); + const titles = await page.locator('section.text article.unit h2').allTextContents(); + expect(titles.indexOf(E2)).toBeGreaterThan(-1); + expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1)); + await expect(line).toContainText(/adopté le/); +}); + +test('C35 — sur un téléphone : réordonner une section où rien n’est en vigueur, la liste tient, les cibles se touchent', async ({ browser }) => { + const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' }); + const page = await ctx.newPage(); + await signIn(page, 'Chiara'); + await page.goto(DOC); + // Nothing of the technical commitments is in the text: their order closes their section in the list. + await unfold(page); + const line = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' }); + await line.getByRole('button', { name: 'Réordonner' }).click(); + const list = page.getByRole('list', { name: 'Nouvel ordre des articles' }); + const items = list.locator('button.item'); + await expect(items).toHaveCount(11); + await items.nth(3).click(); + await items.nth(0).click(); + await expect(page.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled(); + const holds = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1); + expect(holds).toBe(true); + for (const target of [items.nth(0), list.locator('.grip').first(), list.getByRole('button', { name: /^Monter/ }).nth(1)]) { + const box = await target.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await list.scrollIntoViewIfNeeded(); + await shot(page, 'c35-4-poignees-mobile'); + await page.getByRole('button', { name: 'Annuler' }).click(); + await ctx.close(); +}); + +test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l’article déplié, et en bref dans la liste', async ({ page }) => { + await signIn(page, 'Chiara'); + // Before any vote (Monnaie libre): the Raison d'être, a seed proposal at level 2, quorum 12. + await page.goto(DOC); + const i1 = page.locator('section.text article.unit', { hasText: "Raison d'être" }).first(); + await i1.hover(); + await i1.getByRole('button', { name: 'Le vote' }).click(); + const fold = page.getByRole('region', { name: "Le vote de « Raison d'être »" }); + const need = fold.getByText('9 oui sur 12 votants pour passer', { exact: true }); + await expect(need).toBeVisible(); + // 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 ».'); + // In the list, short: at level 3, quorum 50. + await unfold(page); + await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50'); + + // With votes (the evaluation as the server gives it, ADR 0013): 9 « pour », 4 « contre » at + // level 2, quorum 12, do not pass — 11 « pour » would; 4 « contre » hold the 9 in check. + const title = 'Vérification personnelle de la clé'; + const doc = await testDoc(page); + const target = doc.units.find((u) => u.proposals.some((p) => p.title === title)); + if (!target) throw new Error(`no « ${title} »`); + let shape: 'held' | 'quorum' = 'held'; + await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { + const res = await route.fetch(); + const body = await res.json(); + for (const u of body.units) { + for (const p of u.proposals) { + if (p.title !== title || p.status !== 'open') continue; + p.tally = shape === 'held' ? { for: 9, against: 4 } : { for: 3, against: 0 }; + p.evaluation = { + ...p.evaluation, + cast: shape === 'held' ? 13 : 3, + quorum: 12, + required: shape === 'held' ? 10 : 9, + reached: false, + missing: shape === 'held' ? 2 : 6, + yesNeeded: shape === 'held' ? 11 : 9, + noNeeded: shape === 'held' ? 4 : 0, + }; + } + } + await route.fulfill({ response: res, json: body }); + }); + await page.goto(TEST_DOC); + await unfold(page); + const row = deepRow(page, title); + await expect(row).toContainText('passe à 11 oui · bloquée à 4 non'); + await row.click(); + const theirs = page.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(theirs.getByText('11 oui pour passer · 4 non pour bloquer', { exact: true })).toBeVisible(); + await expect(theirs.getByText('Il manque 2 voix pour.')).toBeVisible(); + await shot(page, 'c36-1-oui-non'); + // Below the quorum, the voters missing hold it alone: no « non » is needed. + shape = 'quorum'; + await page.reload(); + await expect(theirs.getByText('9 oui pour passer · le quorum la bloque', { exact: true })).toBeVisible(); + await expect(deepRow(page, title)).toContainText('passe à 9 oui'); + await expect(deepRow(page, title)).not.toContainText('bloquée'); +}); + +test('C37 — le quorum d’un article : son auteur le fixe avant le premier vote, revient au défaut, et lit les refus', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + const title = 'Quorum choisi par son autrice'; + const id = await propose(page, title, 'Je fixe le quorum de cet article avant que quiconque vote.'); + await page.goto(`${TEST_DOC}?vote=${id}`); + const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); + const quorum = fold.getByRole('region', { name: /^Quorum/ }); + await expect(quorum).toBeVisible(); + // The default of its level, recalled; the test group asks 1. + await expect(quorum.getByText('Par défaut, celui de son cran : 1 votant.')).toBeVisible(); + await expect(quorum.getByRole('button', { name: /^Revenir au défaut/ })).toHaveCount(0); + const field = quorum.getByRole('spinbutton', { name: "Quorum de l'article" }); + await expect(field).toHaveValue('1'); + await field.fill('3'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(page.getByText('Quorum fixé : 3 votants.')).toBeVisible(); + await expect(fold.getByText(/oui sur 3 votants pour passer$/)).toBeVisible(); + await shot(page, 'c37-1-quorum'); + // Back to the default. + await quorum.getByRole('button', { name: 'Revenir au défaut (1)' }).click(); + await expect(page.getByText('Quorum revenu au défaut : 1 votant.')).toBeVisible(); + await expect(fold.getByText(/oui sur 1 votant pour passer$/)).toBeVisible(); + // Out of bounds: refused (400), said in her words. + await field.fill('100000'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(quorum.getByRole('alert')).toHaveText(/^Ce quorum sort des bornes/); + // Someone else never sees it. + const yvv = await person(browser, 'Yvv'); + await yvv.goto(`${TEST_DOC}?vote=${id}`); + await expect(yvv.getByRole('region', { name: `Le vote de « ${title} »` })).toBeVisible(); + await expect(yvv.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); + // A vote arrives while her page is open: refused (409), said in her words; then it is gone. + const r = await yvv.request.post(`/api/proposals/${id}/vote`, { data: { choice: 'against' }, headers: CSRF }); + expect(r.ok()).toBe(true); + await field.fill('2'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change plus."); + await page.reload(); + await expect(fold).toBeVisible(); + await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); + await yvv.context().close(); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 7f0d737..0c10a7a 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { BOB, deepRow, DOC, expectWelcome, person, seedsInForce, signIn, TEST_DOC, testDoc, wallet } from './helpers'; +import { BOB, DOC, expectWelcome, person, seedsInForce, signIn, TEST_DOC, testDoc, unfold, wallet } from './helpers'; // The seed proposals (ADR 0011) are not in force at the start: the controls that read a // text in force find I1, E1 and E2 brought in by the vote in the test group. @@ -37,22 +37,29 @@ test('C2 — l’accueil avec session : le choix du groupe, ceux de la même cl await expect(page).toHaveURL((u) => u.pathname === '/'); }); -test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements sont des Propositions seed', async ({ page }) => { +test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements, Propositions seed, ouvrent le texte', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); - // Nothing in force (Yvv, 2026-11-02): said once, above the orders of the sections. - await expect(page.locator('section.text article.unit')).toHaveCount(0); - await expect(page.getByText("Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.")).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); - // One line per article: the 33, E3 and E7 holding their two variants inside. - await expect(page.locator('ul.deep button.row')).toHaveCount(33); - // The three seed proposals, marked, at the low level they start at. - const seeds = page.locator('ul.deep button.row', { has: page.locator('.tag', { hasText: 'Proposition seed' }) }); - await expect(seeds.locator('.title')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']); + // Nothing in force (Yvv, 2026-11-02): the page says where to begin, once — the seed proposals, + // read whole at the head of the text (lot U), each marked: never the bare page of a text in force. + await expect(page.getByText("Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition.")).toBeVisible(); + const seeds = page.locator('section.text article.unit'); + await expect(seeds.locator('h2')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']); + await expect(seeds.locator('.tag', { hasText: 'Proposition seed' })).toHaveCount(3); await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(3); - for (const row of await seeds.all()) await expect(row).toContainText('0 pour · 0 contre'); - for (const row of await seeds.all()) await expect(row).toContainText('inertie 2 / 5 · basse'); + // At the low level they start at, how far each is from passing. + for (const seed of await seeds.all()) await expect(seed).toContainText('0 pour · 0 contre · 9 oui sur 12'); + for (const seed of await seeds.all()) await expect(seed).toContainText('inertie 2 / 5 · basse'); + // Below, the open proposals, folded by section; the seed proposals are not said twice. + await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); + await expect(page.locator('ul.deep li.section')).toHaveCount(8); + await expect(page.locator('ul.deep button.row')).toHaveCount(0); + await expect(page.locator('ul.deep li.section', { hasText: 'Engagements techniques' })).toContainText('11 articles'); + await unfold(page); + // One line per article: the 33 less the 3 at the head, E3 and E7 holding their two variants inside. + await expect(page.locator('ul.deep button.row')).toHaveCount(30); + await expect(page.locator('ul.deep .tag', { hasText: 'Proposition seed' })).toHaveCount(0); }); test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => { @@ -66,7 +73,8 @@ test('C4 — les actions sont contextuelles, le vote se déplie sous l’article await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); const fold = first.locator('section.fold'); - await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible(); + // How many « oui » pass it, at its quorum (ADR 0013): in force at no vote, the quorum of the test group. + await expect(fold.getByText(/^\d+ oui sur \d+ votants? pour passer$/)).toBeVisible(); await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible(); await fold.getByText('La formule du seuil').click(); await expect(fold.getByText(/W — électorat/)).toBeVisible(); @@ -93,6 +101,7 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr for (const name of ['Yvv', 'Chiara']) { last = await person(browser, name); await last.goto(TEST_DOC); + await unfold(last); await last.locator('ul.deep button.row', { hasText: title }).click(); await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); await expect(last.getByText('Vote enregistré.')).toBeVisible(); @@ -118,7 +127,7 @@ test('C6 — une coquille se corrige sans vote', async ({ page }) => { // What is not the vote lives in the panel, tab « Détails ». await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); const panel = page.locator('aside.panel'); - await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true'); + await expect(panel.getByRole('heading', { name: 'Détails', exact: true })).toBeVisible(); await panel.getByText('Corriger une coquille').click(); const body = panel.getByLabel('Texte', { exact: true }); // Typographic apostrophes: the letters and the signs stay, only their style changes. @@ -195,6 +204,7 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de await invited.goto(`/invitation#${link.split('#')[1]}`); await expect(invited).toHaveURL((u) => u.pathname === '/'); await invited.goto(DOC); + await unfold(invited); await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click(); const fold = invited.locator('section.fold'); // An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs. @@ -209,6 +219,7 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de await expect(fold.getByText('0 pour · 0 contre')).toBeVisible(); // In the test group, the same person votes, directly. await invited.goto(TEST_DOC); + await unfold(invited); await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click(); await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0); @@ -268,6 +279,7 @@ test('C18 — la Raison d’être ouvre le texte, en trois langues', async ({ pa test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); + await unfold(page); const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }); await expect(e3).toHaveCount(1); await expect(e3).toContainText('2 versions'); @@ -283,8 +295,9 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page // A version unfolds its vote below it, as any proposal. await versions.nth(1).click(); await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true'); - // Level 3, the quorum 50 (ADR 0013): 39 « pour » at no vote, the voters missing counted as « non ». - await expect(page.locator('section.fold').getByText('Seuil : 39 voix pour')).toBeVisible(); + // 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). + await expect(page.locator('section.fold .need')).toHaveText(/^39 oui (sur 50 votants pour passer|pour passer · le quorum la bloque)$/); await expect(page).toHaveURL(/vote=/); await e3.click(); await expect(e3).toHaveAttribute('aria-expanded', 'false'); @@ -318,8 +331,10 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await expect(panel.getByText(/^Quorum : en dessous, les votants qui manquent comptent comme « non »\./)).toBeVisible(); await page.keyboard.press('Escape'); - // From the vote of an article: Unicité du compte, a seed proposal, is at 2 / 5, its row is marked. - await deepRow(page, 'Unicité du compte').click(); + // From the vote of an article: Unicité du compte, a seed proposal at the head, is at 2 / 5, its row is marked. + const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); + await unicity.hover(); + await unicity.getByRole('button', { name: 'Le vote' }).click(); const e1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible(); await e1.getByRole('button', { name: 'Réglages de la formule' }).click(); diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index 92c2c9f..2126685 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers'; +import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, unfold, VOTE_ACCOUNT, wallet } from './helpers'; // Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a // basket, and on the Ğ1 chain where the electorate is the web of trust. The chain @@ -20,8 +20,9 @@ test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil await expect(toggle).toHaveAttribute('aria-expanded', 'true'); await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? ''); // How far the threshold is: the water, the totals, the votes asked and those missing. - await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible(); - await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible(); + // (the first: its other versions, inside, have theirs) + await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ }).first()).toBeVisible(); + await expect(fold.getByText(/^\d+ oui sur \d+ votants? pour passer$/)).toBeVisible(); await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible(); await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); await expect(fold.getByText('0 pour · 0 contre')).toBeVisible(); @@ -119,8 +120,9 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); - // Under the seed proposal: her vote, from the chain, and the totals. - const e1 = deepRow(page, 'Unicité du compte'); + // Under the seed proposal, at the head of the text: her vote, from the chain, and the totals. + const seed = (title: string) => page.locator('section.text article.unit', { hasText: title }).getByRole('button', { name: 'Le vote' }); + const e1 = seed('Unicité du compte'); await e1.click(); const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1'); @@ -128,7 +130,7 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur // The last one stands, in the order of the chain: a « contre » included first, but seen late // by the indexer, never overrides the withdrawal included after it. - const e2 = deepRow(page, 'Réciprocité'); + const e2 = seed('Réciprocité'); await e2.click(); const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' }); await control('hide'); @@ -155,8 +157,8 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur // Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain. const other = await person(browser, 'Maëlle'); await other.goto(DOC); - await deepRow(other, "Raison d'être").click(); - await other.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); + await other.locator('section.text article.unit', { hasText: "Raison d'être" }).getByRole('button', { name: 'Le vote' }).click(); + await other.locator('section.text section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible(); await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click(); @@ -191,13 +193,13 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; await page.goto(DOC); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0); - await page.locator('ul.deep button.row').first().click(); - const fold = page.locator('ul.deep section.fold'); + await page.locator('section.text article.unit').first().getByRole('button', { name: 'Le vote' }).click(); + const fold = page.locator('section.text section.fold'); await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); - // The totals and the threshold stay in sight. - // Raison d'être, level 2, the quorum 12 (ADR 0013). - await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible(); + // The totals and how far it is stay in sight: Raison d'être, level 2, the quorum 12 (ADR 0013) — + // « sur 12 votants » before any vote; C23 may have counted a voice already. + await expect(fold.locator('.need')).toHaveText(/^9 oui (sur 12 votants pour passer|pour passer · le quorum la bloque)$/); await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded(); await shot(page, '6-vote-ferme'); // The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ». @@ -239,11 +241,16 @@ test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simul const before = await version(page, title); expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true }); await page.goto(DOC); - // The basket is there, without a wallet: the vote leaves by the server. + // The basket is there, without a wallet: the vote leaves by the server, marked « vote simulé ». await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); + await expect(page.locator('header.head .tag', { hasText: 'vote simulé' })).toBeVisible(); + await unfold(page); await deepRow(page, title).click(); const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); await fold.getByRole('button', { name: 'Contre', exact: true }).click(); + // Said simulated at once, never « enregistré » (review of M6). + await expect(page.getByText('Vote simulé.', { exact: true })).toBeVisible(); + await expect(page.getByText('Vote enregistré.')).toHaveCount(0); await expect(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0); await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/); await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible(); @@ -257,10 +264,19 @@ test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simul const after = await version(page, title); expect(after.p.myVoteState).toBe('simulated'); expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1); + // The basket too says « simulé », never « enregistrés » (review of M6): « contre » again, one line. + await page.getByRole('button', { name: /^Voter en lot/ }).click(); + const basket = page.locator('aside.panel'); + await basket.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Contre' }).click(); + await basket.getByRole('button', { name: 'Voter (1)' }).click(); + await expect(page.getByText('1 vote simulé.', { exact: true })).toBeVisible(); + await expect(page.getByText('enregistré', { exact: false })).toHaveCount(0); + await basket.getByRole('button', { name: 'Fermer le panneau' }).click(); // In her ballots: simulated, counted for her session, never a block of the chain. await page.goto('/bulletins'); const ballot = page.locator('li.ballot').first(); - await expect(ballot.locator('.state')).toHaveText("Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1"); + // Until a human act erases the simulated votes — never « until the chain opens » (review of M6). + await expect(ballot.locator('.state')).toHaveText('Simulé : compté pour « Sacha » tant que les votes simulés ne sont pas effacés'); await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0); } finally { // The next controls need the chain. diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index e15affd..04e9af5 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -90,9 +90,10 @@ } busy = true; try { - const n = await voteDirect(doc, toSend); + const r = await voteDirect(doc, toSend); basket.clear(); - toasts.show(i18n.plural('basket.sent', n)); + // A simulated vote says so, never « enregistré » (ADR 0012). + toasts.show(i18n.plural(r.simulated ? 'basket.sent.simulated' : 'basket.sent', r.count)); } catch (e) { error = errorText(e); } finally { @@ -115,8 +116,8 @@ {/if} {/snippet} -
-

{t('basket.title')}

+ +

{t('basket.intro')}

{#if access && !access.open}

{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

@@ -204,7 +205,7 @@ {#if open}

- {#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if} + {#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else if access?.simulated}{t('vote.simulated.note')}{:else}{t('basket.note.server')}{/if}

{/if} {#if error}{/if} @@ -216,11 +217,6 @@ display: grid; gap: 0.9rem; } - h3 { - font-family: var(--font-text); - font-weight: 500; - font-size: 1.4rem; - } .closed { font-weight: 700; color: var(--ink-soft); diff --git a/web/src/lib/components/Crans.svelte b/web/src/lib/components/Crans.svelte new file mode 100644 index 0000000..cc0d1c5 --- /dev/null +++ b/web/src/lib/components/Crans.svelte @@ -0,0 +1,42 @@ + + + + + {#if short}{full}{:else}{full}{/if} + + + diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index 107c39b..b0bae2e 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -1,52 +1,67 @@ -{#snippet module(p: Proposal)} - {#if p.kind === 'title'}{t('module.title.name')}{/if} - {#each orderNote(p) as note (note)}{note}{/each} +{#snippet need(p: Proposal)} + {@const n = needOf(p)} + {#if n}{n.short}{/if} {/snippet} {#snippet marks(p: Proposal)} @@ -85,97 +100,223 @@ {#if p.mine}{t('deep.mine')}{/if} {/snippet} +{#if sections.length && !all} +
+ +
+{/if} +
    - {#each articles as a, i (a.unit.id)} - {@const unit = a.unit} - {@const best = a.versions[0]} - {#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section} -
  • - {sectionLabel(unit.section)} - {#if onsection && unit.section} - {@const key = unit.section} + {#each sections as s, i (keyOf(s))} + {@const open = isOpen(s)} + {@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))} +
  • +
    + + {#if onsection && s.key && s.articles.length} + {@const key = s.key} {/if} -
  • - {/if} - {#if best && a.versions.length === 1} -
  • - - {#if vote === best.id}
    {@render fold(unit, best)}
    {/if} -
  • - {:else if best} - {@const open = isOpen(a)} - {@const title = headline(a)} - {@const told = toldApart(a)} -
  • - - -
  • - {/if} + + {#if open} +
    + {#if s.order?.id !== reordering} +
      + {#each s.articles as a (a.unit.id)} + {@const unit = a.unit} + {@const best = a.versions[0]} + {#if best && a.versions.length === 1} +
    • + + {#if vote === best.id}
      {@render fold(unit, best)}
      {/if} +
    • + {:else if best} + {@const vopen = isVersionsOpen(a)} + {@const title = headline(a)} + {@const told = toldApart(a)} +
    • + + +
    • + {/if} + {/each} +
    + {/if} + {#if s.order}{@render order(s.order)}{/if} +
    + {/if} + {:else}
  • {t('deep.none')}
  • {/each}
diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index ab013f1..8ea4289 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -1,30 +1,28 @@ -