Fusion du lot U : le lecteur refait (ordre en une ligne au bout de sa section, poignées sur place, titre et ordre votés à leur place, Propositions seed en tête, propositions repliées par section, panneau sans onglets, oui pour passer · non pour bloquer, quorum de l'auteur, votes simulés dits simulés) ; C35 à C37
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 08:48:18 +01:00
co-authored by Claude Opus 5.5
35 changed files with 2109 additions and 714 deletions
+6 -4
View File
@@ -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
+17 -13
View File
@@ -4,7 +4,7 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
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://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première proposition : « 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 · <section> » (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://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous la première Proposition seed : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection. », pas de bouton, les totaux et « 9 oui sur 12 votants pour passer » restent ; le panier ouvert par son adresse (`?onglet=panier`) dit la même chose, son bouton éteint, et **jamais** « Tes votes comptent dès l'envoi » ; dans le groupe d'essai, le panier et les boutons sont là |
| 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-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
| C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « 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) |
@@ -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 · <section> » 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 ».
+4 -1
View File
@@ -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<Entry[]> => (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} »` });
+11
View File
@@ -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;
+10 -5
View File
@@ -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);
+56 -61
View File
@@ -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<boolean> {
const g = await (await page.request.get('/api/groups/essai')).json();
@@ -27,37 +23,39 @@ async function inForce(page: Page, proposalId: string): Promise<boolean> {
/**
* 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<number> {
async function secondVoice(page: Page, browser: Browser, proposalId: string, fold: string, news: string): Promise<number> {
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();
});
+2 -1
View File
@@ -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;
}
+238
View File
@@ -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<boolean> {
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();
});
+34 -19
View File
@@ -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();
+31 -15
View File
@@ -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.
+6 -10
View File
@@ -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}
<section class="basket" aria-labelledby="basket-title">
<h3 id="basket-title">{t('basket.title')}</h3>
<!-- Named by the head of the panel (« Panier de vote »): one name, one place. -->
<section class="basket">
<p class="muted">{t('basket.intro')}</p>
{#if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
@@ -204,7 +205,7 @@
</button>
{#if open}
<p class="faint small note-send">
{#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}
</p>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/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);
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
// The inertia of a module, drawn: five notches side by side, the level filled
// (ADR 0007, « cinq crans »). The water level of a vote stands upright; the
// weight of a text lies flat — two shapes one does not confuse. The words stay
// beside it: « inertie 3 / 5 · moyenne », or only « moyenne » when `short`
// (a screen reader still hears the whole).
import { t } from '$lib/i18n/index.svelte';
import { inertiaBadge } from '$lib/labels';
import type { Inertia } from '$lib/types';
let { level, short = false }: { level: Inertia; short?: boolean } = $props();
const full = $derived(inertiaBadge(level));
</script>
<span class="crans" title={full}>
<span class="notches" aria-hidden="true">
{#each [1, 2, 3, 4, 5] as k (k)}<span class="notch" class:on={k <= level}></span>{/each}
</span>
{#if short}<span class="visually-hidden">{full}</span><span aria-hidden="true">{t(`inertia.name.${level}`)}</span>{:else}<span>{full}</span>{/if}
</span>
<style>
.crans {
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.notches {
display: inline-flex;
gap: 2px;
}
.notch {
width: 5px;
height: 0.62rem;
border-radius: 2px;
background: color-mix(in srgb, var(--ink-faint) 28%, transparent);
}
.notch.on {
background: var(--ink-soft);
}
</style>
+269 -129
View File
@@ -1,52 +1,67 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version unfolds its vote below it; one with several shows the best of
// them and unfolds the others, each with its label, its water level and its
// marks — and each unfolds its own vote.
// The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
// coup d'œil »): each section folded to one line — its name, how many articles are
// proposed, a small water level for each, so one sees where support rises without opening
// anything. A gesture unfolds one, « Tout déplier » all of them; a filter, or a vote
// unfolded by the URL, opens the section that holds it. Inside, one line per article (Yvv,
// 2026-10-30): one with a single version unfolds its vote below it; one with several shows
// the best and unfolds the others. A section not read in the text above is closed here by
// its order.
import type { Snippet } from 'svelte';
import { plain } from '$lib/markdown';
import { inertiaBadge, moduleName, orderNote, relationOf, sectionLabel, titleOf } from '$lib/labels';
import { needOf, relationOf, sectionLabel, titleOf } from '$lib/labels';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions';
import { excerpts, type Article, type DeepSection } from '$lib/versions';
import Crans from './Crans.svelte';
import Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
articles,
sections,
vote,
reordering = null,
all = false,
ontoggle,
fold,
order,
onsection,
nameOf = (id) => id,
}: {
/** Articles and modules (ADR 0009) with open proposals, in document order. */
articles: Article[];
/** The articles with open proposals, by section, each with its order when it closes here. */
sections: DeepSection[];
/** The version whose vote is unfolded. */
vote: string | null;
/** The order module being reordered: its section stays open. */
reordering?: string | null;
/** Every section open (a filter is on). */
all?: boolean;
ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>;
/** The order line of a section, at its end. */
order: Snippet<[Unit]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: string) => void;
/** The title of an article, for the orders proposed. */
nameOf?: (unitId: string) => string;
} = $props();
/** What a line shows of a version: the start of its text, or the order it proposes. */
function excerptOf(p: Proposal): string {
if (p.kind === 'text') return plain(p.body);
if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · ');
return '';
const keyOf = (s: DeepSection) => s.key ?? '';
// Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
let unfolded = $state<Record<string, boolean>>({});
let versionsOpen = $state<Record<string, boolean>>({});
const holds = (s: DeepSection) =>
s.articles.some((a) => a.versions.some((p) => p.id === vote)) ||
(!!s.order && (s.order.id === reordering || [s.order.inForce, ...s.order.proposals].some((p) => p?.id === vote)));
const isOpen = (s: DeepSection) => all || (unfolded[keyOf(s)] ?? holds(s));
const everyOpen = $derived(sections.every((s) => isOpen(s)));
const toggle = (s: DeepSection) => (unfolded[keyOf(s)] = !isOpen(s));
function setAll(open: boolean) {
unfolded = Object.fromEntries(sections.map((s) => [keyOf(s), open]));
}
// Unfolded by hand, or because the version whose vote is unfolded is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
function headline(a: Article): string {
if (a.unit.kind) return moduleName(a.unit);
const text = a.versions.find((p) => p.kind === 'text');
const best = a.versions[0];
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
@@ -66,15 +81,15 @@
const textual = a.versions.filter((p) => p.kind === 'text');
const told = excerpts(textual.map((p) => plain(p.body)));
return new Map([
...a.versions.map((p): [string, string] => [p.id, excerptOf(p)]),
...a.versions.map((p): [string, string] => [p.id, p.kind === 'text' ? plain(p.body) : '']),
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
]);
}
</script>
{#snippet module(p: Proposal)}
{#if p.kind === 'title'}<span class="faint">{t('module.title.name')}</span>{/if}
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
{#snippet need(p: Proposal)}
{@const n = needOf(p)}
{#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
{/snippet}
{#snippet marks(p: Proposal)}
@@ -85,97 +100,223 @@
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet}
{#if sections.length && !all}
<div class="all">
<button type="button" class="linkish" onclick={() => setAll(!everyOpen)}>{t(everyOpen ? 'deep.fold' : 'deep.unfold')}</button>
</div>
{/if}
<ul class="deep">
{#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}
<li class="section">
<span class="eyebrow">{sectionLabel(unit.section)}</span>
{#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'))}
<li class="section" class:open>
<div class="head">
<button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
<span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
<span class="name">{label}</span>
{#if s.articles.length}
<span class="count">{i18n.plural('deep.section.count', s.articles.length)}</span>
<span class="waters" aria-hidden="true">
{#each s.articles as a (a.unit.id)}
{@const best = a.versions[0]}
{#if best}<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} size="mini" />{/if}
{/each}
</span>
{/if}
</button>
{#if onsection && s.key && s.articles.length}
{@const key = s.key}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</li>
{/if}
{#if best && a.versions.length === 1}
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{@render module(best)}
{@render marks(best)}
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const open = isOpen(a)}
{@const title = headline(a)}
{@const told = toldApart(a)}
<li class="article" class:open>
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{title}</span>
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
<span class="meta">
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
</span>
</span>
<span class="chevron"><Icon name="chevron" size={18} /></span>
</button>
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if (p.kind === 'text' || p.kind === 'title') && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
{@render marks(p)}
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
</li>
{/if}
</div>
{#if open}
<div class="body" id="deep-sec-{i}">
{#if s.order?.id !== reordering}
<ul class="rows">
{#each s.articles as a (a.unit.id)}
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if best && a.versions.length === 1}
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{@render marks(best)}
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const vopen = isVersionsOpen(a)}
{@const title = headline(a)}
{@const told = toldApart(a)}
<li class="article" class:open={vopen}>
<button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{title}</span>
{#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</span>{/if}
<span class="meta">
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
</span>
</span>
<span class="chevron"><Icon name="chevron" size={18} /></span>
</button>
<ul class="versions" id="versions-{unit.id}" hidden={!vopen}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{@render need(p)}
{@render marks(p)}
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
{/if}
{#if s.order}{@render order(s.order)}{/if}
</div>
{/if}
</li>
{:else}
<li class="faint none">{t('deep.none')}</li>
{/each}
</ul>
<style>
.deep {
.all {
display: flex;
justify-content: flex-end;
margin-bottom: 0.2rem;
}
.linkish {
border: 0;
background: none;
padding: 0.3rem 0;
min-height: 2.25rem;
color: var(--water);
font-weight: 750;
font-size: 0.86rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.deep,
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.article {
.section {
margin: 0 -1rem;
border-radius: var(--r-card);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.6rem;
}
.toggle {
flex: 1 1 16rem;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem 0.7rem;
min-height: 3rem;
padding: 0.55rem 1rem;
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.toggle:hover {
background: var(--surface);
}
.name {
font-family: var(--font-text);
font-style: italic;
font-size: 1.12rem;
color: var(--ink);
}
.count {
flex: none;
font-size: 0.8rem;
font-weight: 750;
color: var(--water);
white-space: nowrap;
}
.waters {
display: inline-flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 3px;
min-width: 0;
}
.head .chevron {
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.section.open .head .chevron {
transform: rotate(90deg);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
margin: 0 0.6rem 0 auto;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.body {
display: grid;
gap: 0.25rem;
padding: 0 0 0.9rem;
animation: unfold 0.2s var(--ease);
}
.body > :global(.order-line) {
margin: 0.3rem 0 0.4rem;
}
.article {
border-radius: var(--r-card);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.article.open {
@@ -225,17 +366,17 @@
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.count {
color: var(--water);
.need {
color: var(--ink);
}
.chevron {
.row .chevron {
flex: none;
margin-left: auto;
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.open .chevron {
.article.open .row .chevron {
transform: rotate(90deg);
}
.versions {
@@ -272,40 +413,39 @@
font-weight: 600;
color: var(--ink-soft);
}
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.3rem 1rem;
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.fold {
padding: 0 1rem 1rem;
}
.section:first-child {
margin-top: 0.4rem;
}
.none {
padding: 0.8rem 0;
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (max-width: 560px) {
.versions {
padding-left: 0.6rem;
}
}
@media (max-width: 899px) {
.vote-section {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.body {
animation: none;
}
.head .chevron,
.row .chevron,
.toggle,
.row,
.version,
.article {
transition: none;
}
}
</style>
+38 -124
View File
@@ -1,30 +1,28 @@
<script lang="ts">
// The side panel: the basket of the vote, the versions of an article, a new
// proposal, the details of a version, the settings of the formula. The vote of
// one version unfolds under its article, not here.
// The side panel, one thing at a time (lot U, UX-lecteur.md): what is written, or works
// over several modules, or is read beside the text — the basket of the vote, a new
// proposal, the details of a version, the settings of the formula. Named at its head,
// with its cross; no tabs. What is read and decided on one version (its vote, its other
// versions, its inertia, the order of its section) unfolds in place, never here.
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import { answerPanel } from '$lib/objections';
import type { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang, Proposal } from '$lib/types';
import type { Lang } from '$lib/types';
import type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte';
import OrderEditor from './OrderEditor.svelte';
import ProposeView from './ProposeView.svelte';
import TitlePropose from './TitlePropose.svelte';
import VersionDetails from './VersionDetails.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
panel,
lang,
basket,
vote,
onchange,
onclose,
onvote,
@@ -34,61 +32,41 @@
panel: PanelState;
lang: Lang;
basket: BasketState;
/** The version whose vote is unfolded under its article. */
vote: string | null;
onchange: (p: PanelState) => void;
onclose: () => void;
/** Unfolds the vote of a version under its article; `close` closes the panel. */
/** Unfolds the vote of a version where it reads; `close` closes the panel. */
onvote: (proposalId: string, close: boolean) => void;
onsign: (lines: Line[]) => void;
} = $props();
const unit = $derived(doc.unit(panel.unitId));
const versions = $derived(
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
);
// A module (ADR 0009) is proposed with its own gesture: another order, another title.
const proposeLabel = $derived(unit?.kind === 'order' ? t('order.propose') : unit?.kind === 'title' ? t('title.propose') : t('doc.action.propose'));
const nameOf = (id: string) => doc.articleTitle(id, lang, doc.view?.doc.sourceLang ?? 'fr');
/** A version in the list: an order reads as its articles, in its order. */
const nameOfVersion = (v: Proposal) => (v.kind === 'order' && v.order ? v.order.ids.map(nameOf).join(' · ') : titleOf(v, unit));
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
const tabs = $derived(
(
[
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true],
// A version: reached from its vote, or from the list of versions.
['details', t('panel.details'), !!panel.proposalId],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const
).filter(([, , show]) => show),
/** One name per function, the same one that opened it. */
const heading = $derived(
panel.tab === 'basket'
? t('basket.title')
: panel.tab === 'settings'
? t('settings.title')
: panel.tab === 'details'
? t('panel.details')
: unit?.kind === 'title'
? t('title.propose')
: panel.unitId
? t('doc.action.propose')
: t('deep.new'),
);
let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => {
void panel.tab;
void panel.proposalId;
body?.scrollTo({ top: 0 });
});
// On a narrow sheet the tabs scroll: the open one stays in sight.
$effect(() => {
void panel.tab;
tablist?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
</script>
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
<aside class="panel card" aria-labelledby="panel-title">
<div class="bar">
<div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
{/each}
</div>
<h2 id="panel-title">{heading}</h2>
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
</div>
@@ -108,24 +86,6 @@
if (next) onchange(next);
}}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
{#each versions as v (v.id)}
<li>
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="text">
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<span class="title">{nameOfVersion(v)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : v.seedProposal ? t('doc.seedProposal') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
</span>
</button>
</li>
{/each}
</ul>
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{proposeLabel}</button>
{:else if panel.tab === 'propose' && unit?.kind === 'order'}
<OrderEditor {doc} {unit} {nameOf} ondone={(id) => onvote(id, true)} oncancel={onclose} />
{:else if panel.tab === 'propose' && unit?.kind === 'title'}
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
{:else if panel.tab === 'propose'}
@@ -139,7 +99,9 @@
ondone={(r) =>
r.proposalId && r.submitted
? onvote(r.proposalId, true)
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
: r.proposalId
? onchange({ unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' })
: onclose()}
/>
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
@@ -165,38 +127,20 @@
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
padding: 0.9rem 0.7rem 0.4rem 1.2rem;
}
.tabs {
display: flex;
gap: 0.2rem;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
overflow-x: auto;
}
.tabs button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.35rem 0.8rem;
min-height: 2.1rem;
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
white-space: nowrap;
}
.tabs button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
line-height: 1.2;
}
.close {
display: grid;
place-items: center;
flex: none;
width: 2.25rem;
height: 2.25rem;
width: 2.75rem;
height: 2.75rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
@@ -211,41 +155,6 @@
padding: 0.6rem 1.2rem 1.6rem;
overscroll-behavior: contain;
}
.versions {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: grid;
gap: 0.4rem;
}
.version {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.7rem 0.8rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
}
.version:hover,
.version.current {
background: var(--water-wash);
}
.text {
display: grid;
gap: 0.1rem;
}
.title {
font-weight: 750;
}
.small {
font-size: 0.82rem;
}
.held {
color: var(--ochre);
}
@keyframes enter {
from {
transform: translateX(16px);
@@ -270,4 +179,9 @@
}
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
animation: none;
}
}
</style>
@@ -45,8 +45,8 @@
</script>
{#if view && p}
<section class="settings" aria-labelledby="settings-title">
<h3 id="settings-title">{t('settings.title')}</h3>
<!-- Named by the head of the panel (« Réglages de la formule »): one name, one place. -->
<section class="settings">
<p class="muted">{t('settings.intro')}</p>
<ul class="values">
@@ -113,11 +113,6 @@
gap: 0.8rem;
min-width: 0;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
h4 {
margin: 0;
font-size: 0.92rem;
+3 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
// A few line icons, drawn here rather than pulled from a library.
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus' | 'inbox'; size?: number } = $props();
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus' | 'inbox' | 'grip'; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -22,6 +22,8 @@
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
{:else if name === 'plus'}
<path d="M12 5v14M5 12h14" />
{:else if name === 'grip'}
<g fill="currentColor" stroke="none"><circle cx="9" cy="6" r="1.6" /><circle cx="15" cy="6" r="1.6" /><circle cx="9" cy="12" r="1.6" /><circle cx="15" cy="12" r="1.6" /><circle cx="9" cy="18" r="1.6" /><circle cx="15" cy="18" r="1.6" /></g>
{:else if name === 'inbox'}
<path d="M4 13l2.2-7.3A1 1 0 0 1 7.2 5h9.6a1 1 0 0 1 1 .7L20 13v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" /><path d="M4 13h4.5l1 2h5l1-2H20" />
{/if}
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
// The order of a section, at its end (ADR 0009; Yvv, 2026-11-02: « la répétition de
// "ordonnancement" est nulle. Il faudrait que ce soit en fin de section »): one line, never
// the list of its articles again — the handle that marks an order, « Ordre de la section »,
// its weight in notches, its state, the orders proposed. « Le vote » unfolds here the vote
// of any of its versions (the list whole); « Réordonner » puts handles on the articles of
// the section, in place.
import type { Snippet } from 'svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { moduleName } from '$lib/labels';
import type { Proposal, Unit } from '$lib/types';
import Crans from './Crans.svelte';
import Icon from './Icon.svelte';
let {
unit,
vote,
reordering = false,
fold,
reorder,
onvote,
onreorder,
}: {
/** The order module of the section. */
unit: Unit;
/** The version whose vote is unfolded, wherever it lives. */
vote: string | null;
reordering?: boolean;
/** The vote of one of its versions, unfolded under the line. */
fold: Snippet<[Proposal]>;
/** The articles of the section with their handles, while reordering. */
reorder: Snippet<[Unit]>;
/** Unfolds (or folds) the vote of one of its versions. */
onvote: (proposalId: string) => void;
onreorder: () => void;
} = $props();
const p = $derived(unit.inForce);
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order'));
// Any version of the module unfolds here: its order in force, an order proposed, a change of its inertia.
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
</script>
<div class="order-line" class:unfolded={!!shown || reordering} role="group" aria-label={moduleName(unit)}>
<div class="bar">
<span class="mark" aria-hidden="true"><Icon name="grip" size={16} /></span>
<span class="name">{t('order.line')}</span>
<Crans level={unit.inertia} short />
{#if p?.seedMinimal}<span class="faint" title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{:else if p?.enteredAt}<span class="faint">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
{#if proposed.length && !reordering}
{@const first = proposed[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('order.proposed', proposed.length)}</button>
{/if}
{#if !reordering}
<span class="links">
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
<button type="button" onclick={onreorder}>{t('order.reorder')}</button>
</span>
{/if}
</div>
{#if reordering}
{@render reorder(unit)}
{:else if shown}
{@render fold(shown)}
{/if}
</div>
<style>
.order-line {
margin: 0.4rem -1.3rem 1.6rem;
padding: 0.35rem 1.3rem;
border-radius: var(--r-card);
transition: background-color 0.18s ease;
}
.order-line:hover,
.order-line:focus-within,
.order-line.unfolded {
background: var(--surface);
box-shadow: var(--shadow);
}
.order-line.unfolded {
padding-block: 0.5rem 1rem;
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.75rem;
min-height: 2.75rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
}
.mark {
display: inline-flex;
color: var(--water);
opacity: 0.75;
}
.name {
color: var(--ink);
font-weight: 780;
}
.proposed {
border: 0;
background: var(--water-wash);
color: var(--water);
font-weight: 750;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
min-height: 2rem;
border-radius: var(--r-pill);
}
/* Discreet as under an article: the gestures show when the line is pointed at. */
.links {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
margin-left: auto;
opacity: 0;
transition: opacity 0.15s ease;
}
.order-line:hover .links,
.order-line:focus-within .links,
.order-line.unfolded .links {
opacity: 1;
}
@media (hover: none) {
.links {
opacity: 1;
}
}
.links button {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
padding: 0.35rem 0.6rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.links button:hover,
.links button.on {
background: var(--water-wash);
}
.order-line > :global(.reorder) {
margin-top: 0.5rem;
}
@media (max-width: 899px) {
.links button,
.proposed {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.order-line,
.links {
transition: none;
}
}
</style>
-89
View File
@@ -1,89 +0,0 @@
<script lang="ts">
// The order of the articles of a section, at its end (ADR 0009): a module at
// inertia 2, read, voted and proposed with the gestures of an article.
import type { Snippet } from 'svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, moduleName } from '$lib/labels';
import type { Unit } from '$lib/types';
import UnitBlock from './UnitBlock.svelte';
let {
unit,
nameOf,
selected = false,
voteOpen = false,
fold,
onaction,
}: {
/** The order module of the section. */
unit: Unit;
/** The title of an article, in the language it is read in. */
nameOf: (unitId: string) => string;
selected?: boolean;
voteOpen?: boolean;
fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
} = $props();
const p = $derived(unit.inForce);
const members = $derived(unit.members ?? []);
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order').length);
</script>
<UnitBlock
text={{ title: moduleName(unit), body: '', translated: false, stale: false, missing: false }}
module
seedMinimal={!!p?.seedMinimal}
enteredAt={p?.enteredAt ?? null}
votes={(p?.tally.for ?? 0) + (p?.tally.against ?? 0)}
{rivals}
{selected}
voteFor={p?.id ?? null}
{voteOpen}
proposeLabel={t('order.propose')}
{fold}
{onaction}
>
{#snippet meta()}
<span class="meta faint">{i18n.plural('module.order.count', members.length)} · {inertiaBadge(unit.inertia)}</span>
{/snippet}
{#snippet content()}
<!-- In one breath, two lines at most: the whole list unfolds with its vote. -->
<ol class="order" aria-label={t('module.order.inforce')}>
{#each members as id, i (id)}<li><span class="n">{i + 1}</span>&nbsp;{nameOf(id)}</li>{/each}
</ol>
{/snippet}
</UnitBlock>
<style>
.meta {
font-size: 0.8rem;
font-weight: 700;
}
.order {
margin: 0;
padding: 0;
list-style: none;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
font-size: 0.9rem;
line-height: 1.55;
color: var(--ink-soft);
}
.order li {
display: inline;
}
.order li + li::before {
content: '·';
margin: 0 0.45em;
color: var(--ink-faint);
}
.n {
font-weight: 800;
color: var(--water);
font-variant-numeric: tabular-nums;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
// The quorum of an article, set by the person who wrote it before the first vote (ADR 0013;
// Yvv, 2026-11-02: « L'auteur, avant le 1er vote »): shown only when the server says so
// (`canSetQuorum`), with the default of its level recalled and the way back to it. The
// server sets the bounds and refuses: out of bounds (400), a vote already there (409), not
// the author (403) — each said in the person's words.
import { untrack } from 'svelte';
import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Unit } from '$lib/types';
import { errorText } from '$lib/vote';
let { doc, unit }: { doc: DocState; unit: Unit } = $props();
const fallback = $derived(unit.quorumDefault ?? 0);
const current = $derived(unit.quorum ?? fallback);
let value = $state<number | null>(null);
let busy = $state(false);
let error = $state('');
/** Refused because a vote arrived: nothing more to set here. */
let closed = $state(false);
$effect(() => {
const now = current;
untrack(() => (value = now));
});
function said(e: unknown): string {
if (!(e instanceof ApiError)) return errorText(e);
if (e.code === 'quorum') return t('quorum.error.quorum', { w: i18n.number(doc.view?.doc.electorate ?? 0) });
if (e.code === 'voted') return t('quorum.error.voted');
if (e.code === 'forbidden') return t('quorum.error.forbidden');
return errorText(e);
}
async function send(quorum: number | null) {
error = '';
busy = true;
try {
const r = await api.put<{ quorum: number | null; quorumDefault: number }>(`/units/${unit.id}/quorum`, { quorum });
const n = r.quorum ?? r.quorumDefault;
toasts.show(i18n.plural(r.quorum === null ? 'quorum.reset.done' : 'quorum.done', n));
await doc.reload();
} catch (e) {
error = said(e);
if (e instanceof ApiError && (e.code === 'voted' || e.code === 'forbidden')) closed = true;
} finally {
busy = false;
}
}
</script>
<section class="quorum" aria-labelledby="quorum-{unit.id}">
<h4 id="quorum-{unit.id}">{i18n.plural('quorum.heading', current)}</h4>
<p class="faint small">{t('quorum.explain')}</p>
<p class="faint small">{i18n.plural('quorum.default', fallback)}</p>
<div class="row">
<label class="field">
<span class="visually-hidden">{t('quorum.field')}</span>
<input type="number" inputmode="numeric" min="1" step="1" bind:value disabled={busy || closed} />
</label>
<button class="btn small" type="button" disabled={busy || closed || value === null || value === current} onclick={() => value !== null && send(Math.trunc(value))}>{t('quorum.set')}</button>
{#if unit.quorum !== null && unit.quorum !== undefined}
<button class="btn quiet small" type="button" disabled={busy || closed} onclick={() => send(null)}>{t('quorum.reset', { n: fallback })}</button>
{/if}
</div>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</section>
<style>
.quorum {
display: grid;
gap: 0.45rem;
min-width: 0;
}
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 800;
}
.small {
font-size: 0.84rem;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
input {
width: 6.5rem;
min-height: 2.75rem;
padding: 0.4rem 0.7rem;
border: 0;
border-radius: var(--r-input);
background: var(--surface);
color: var(--ink);
font: inherit;
font-weight: 750;
font-variant-numeric: tabular-nums;
}
input:focus-visible {
outline: 2px solid var(--water);
outline-offset: 2px;
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
</style>
@@ -1,8 +1,12 @@
<script lang="ts">
// Another order for a section, with a list one clicks (Yvv, 2026-10-30: « une
// simple liste clipable »): an article, then its new place — or its arrows. With
// the keyboard: Enter chooses an article, the up and down arrows move it. Then
// « Proposer cet ordre »: the order goes to the vote, like an article (ADR 0009).
// Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
// "poignées" aux modules de la section. Et permette de voter. »): the articles of the
// section fold to their titles, a handle before each. One drags a handle (mouse, pen or
// finger: pointer events), or — without dragging — clicks an article then its new place,
// or uses its arrows; with the keyboard, the up and down arrows move the article that has
// the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the
// vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order
// already proposed is voted, never proposed twice.
import { tick, untrack } from 'svelte';
import { flip } from 'svelte/animate';
import { api } from '$lib/api';
@@ -27,13 +31,14 @@
unit: Unit;
/** The title of an article, in the language it is read in. */
nameOf: (unitId: string) => string;
/** The order proposed: its vote unfolds. */
/** The order proposed, or the same one already proposed: its vote unfolds. */
ondone: (proposalId: string) => void;
oncancel?: () => void;
oncancel: () => void;
} = $props();
let list = $state<string[]>([]);
let chosen = $state<string | null>(null);
let dragging = $state<string | null>(null);
let said = $state('');
let busy = $state(false);
let error = $state('');
@@ -41,6 +46,12 @@
const start = $derived(unit.members ?? []);
const changed = $derived(moved(start, list));
const unchanged = $derived(sameOrder(start, list));
/** The same order, already proposed: one votes it rather than proposing a double. */
const existing = $derived(
unit.proposals.find((p) => p.status === 'open' && p.kind === 'order' && sameOrder(p.order?.ids ?? [], list)),
);
/** An article of the section only proposed, not in force: said as such. */
const proposed = (id: string) => !doc.unit(id)?.inForce;
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
// Started again only for another section: a reload never wipes what is being moved.
@@ -99,11 +110,45 @@
}
}
// Dragging by the handle: the article takes the place of the row under the pointer.
function grab(e: PointerEvent, id: string) {
if (busy || e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging = id;
chosen = null;
error = '';
}
function drag(e: PointerEvent) {
const id = dragging;
if (!id || !root) return;
for (const row of root.querySelectorAll<HTMLElement>('li[data-id]')) {
const box = row.getBoundingClientRect();
if (e.clientY < box.top || e.clientY >= box.bottom) continue;
const target = row.dataset.id;
if (target && target !== id) list = place(list, id, target);
return;
}
}
function drop() {
const id = dragging;
if (!id) return;
dragging = null;
tell(id);
void focus(id);
}
async function submit() {
if (unchanged) {
error = t('order.edit.unchanged');
return;
}
if (existing) {
ondone(existing.id);
return;
}
busy = true;
error = '';
try {
@@ -119,15 +164,21 @@
}
</script>
<div class="editor">
<div class="reorder">
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
<ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
{#each list as id, i (id)}
{@const name = nameOf(id)}
<li class:chosen={chosen === id} class:moved={changed.has(id)} animate:flip={{ duration: reduce ? 0 : 160 }}>
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
<li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
<span class="grip" aria-hidden="true" onpointerdown={(e) => grab(e, id)} onpointermove={drag} onpointerup={drop} onpointercancel={drop}>
<Icon name="grip" size={18} />
</span>
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} disabled={busy} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
<span class="n" aria-hidden="true">{i + 1}</span>
<span class="name">{name}</span>
<span class="what">
<span class="name">{name}</span>
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
</span>
</button>
<span class="arrows">
<button type="button" class="arrow up" aria-label={t('order.edit.up', { title: name })} disabled={i === 0 || busy} onclick={() => shift(id, -1)}><Icon name="chevron" size={16} /></button>
@@ -137,20 +188,21 @@
{/each}
</ol>
<p class="visually-hidden" aria-live="polite">{said}</p>
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })}</p>
<p class="faint small">{t('module.order.rule')}</p>
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('module.order.rule')}</p>
{#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions">
{#if oncancel}<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>{/if}
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t('order.edit.submit')}</button>
<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button>
</div>
</div>
<style>
.editor {
.reorder {
display: grid;
gap: 0.7rem;
min-width: 0;
animation: unfold 0.2s var(--ease);
}
.list {
list-style: none;
@@ -162,26 +214,51 @@
.list li {
display: flex;
align-items: center;
gap: 0.3rem;
gap: 0.2rem;
border-radius: var(--r-input);
background: var(--surface-2);
background: var(--surface);
box-shadow: var(--shadow);
transition: background-color 0.15s var(--ease);
}
.list li.moved {
background: var(--moss-wash);
background: color-mix(in srgb, var(--surface) 55%, var(--moss-wash));
}
.list li.chosen {
.list li.chosen,
.list li.dragging {
background: var(--water);
color: var(--on-water);
}
.list li.dragging {
box-shadow: var(--shadow-strong);
}
.grip {
display: grid;
place-items: center;
flex: none;
width: 2.4rem;
min-height: 2.75rem;
align-self: stretch;
color: var(--ink-faint);
cursor: grab;
touch-action: none;
border-radius: var(--r-input) 0 0 var(--r-input);
}
.grip:hover {
color: var(--water);
}
.dragging .grip,
.chosen .grip {
color: inherit;
cursor: grabbing;
}
.item {
flex: 1;
display: flex;
align-items: center;
gap: 0.7rem;
gap: 0.6rem;
min-width: 0;
min-height: 2.75rem;
padding: 0.5rem 0.4rem 0.5rem 0.8rem;
padding: 0.45rem 0.3rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
@@ -191,18 +268,32 @@
}
.n {
flex: none;
min-width: 1.4rem;
min-width: 1.2rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--water);
}
.chosen .n {
.chosen .n,
.dragging .n {
color: inherit;
}
.what {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.6rem;
min-width: 0;
}
.name {
font-weight: 700;
font-family: var(--font-text);
font-weight: 600;
overflow-wrap: anywhere;
}
.chosen .tag,
.dragging .tag {
background: color-mix(in srgb, var(--on-water) 22%, transparent);
color: inherit;
}
.arrows {
display: flex;
flex: none;
@@ -234,6 +325,11 @@
.small {
font-size: 0.84rem;
}
.note {
font-size: 0.88rem;
font-weight: 700;
color: var(--water);
}
.warn {
color: var(--ochre);
font-weight: 700;
@@ -245,7 +341,23 @@
gap: 0.5rem;
justify-content: flex-end;
}
/* On a phone: every target is big enough for a finger. */
@media (max-width: 899px) {
.arrow {
width: 2.75rem;
height: 2.75rem;
}
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.reorder {
animation: none;
}
.list li {
transition: none;
}
+45 -16
View File
@@ -1,49 +1,60 @@
<script lang="ts">
// The title of the document is a module (ADR 0009, inertia 4): under the title,
// the gestures of an article — the vote, the proposals, another title.
// The title of the document is a module (ADR 0009, inertia 4): one line under the title,
// of the same voice as the order of a section — its weight in notches, its state, the
// titles proposed. « Le vote » unfolds here the vote of any of its versions; another title
// is written in the panel.
import type { Snippet } from 'svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, moduleName } from '$lib/labels';
import type { Unit } from '$lib/types';
import { moduleName } from '$lib/labels';
import type { Proposal, Unit } from '$lib/types';
import Crans from './Crans.svelte';
let {
unit,
stale = false,
selected = false,
voteOpen = false,
vote,
fold,
onaction,
onvote,
onpropose,
}: {
/** The title module of the document. */
unit: Unit;
/** The title read is a translation of an older title. */
stale?: boolean;
selected?: boolean;
voteOpen?: boolean;
fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
/** The version whose vote is unfolded, wherever it lives. */
vote: string | null;
fold: Snippet<[Proposal]>;
onvote: (proposalId: string) => void;
onpropose: () => void;
} = $props();
const p = $derived(unit.inForce);
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title').length);
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title'));
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
</script>
<div class="title-unit" class:selected class:unfolded={voteOpen}>
<div class="title-unit" class:selected class:unfolded={!!shown} role="group" aria-label={moduleName(unit)}>
<p class="bar">
<span class="status faint">
{moduleName(unit)} · {inertiaBadge(unit.inertia)}
{moduleName(unit)}
{#if p?.seedMinimal} · <span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
</span>
<Crans level={unit.inertia} />
{#if stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{/if}
{#if proposed.length}
{@const first = proposed[0]}
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('title.proposed', proposed.length)}</button>
{/if}
<span class="links">
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={p ? `vote-${p.id}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
<button type="button" onclick={() => onaction('propose')}>{t('title.propose')}</button>
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
<button type="button" onclick={onpropose}>{t('title.propose')}</button>
</span>
</p>
{#if voteOpen && fold}<div class="fold">{@render fold()}</div>{/if}
{#if shown}<div class="fold">{@render fold(shown)}</div>{/if}
</div>
<style>
@@ -65,11 +76,23 @@
align-items: center;
gap: 0.3rem 0.8rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
min-height: 2.25rem;
}
.status {
font-weight: 700;
}
.proposed {
border: 0;
background: var(--water-wash);
color: var(--water);
font-weight: 750;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
min-height: 2rem;
border-radius: var(--r-pill);
}
.links {
display: flex;
flex-wrap: wrap;
@@ -105,6 +128,12 @@
opacity: 1;
}
}
@media (max-width: 899px) {
.links button,
.proposed {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.title-unit,
.links {
+45 -32
View File
@@ -1,13 +1,23 @@
<script lang="ts">
// An article as the text reads it: its title, its body, its state; two gestures —
// « Le vote » unfolds under it (its other versions inside), « Proposer une modification »
// opens the panel, where one writes (lot U, UX-lecteur.md). A seed proposal (ADR 0011),
// read at the head while nothing is in force, is marked so: never the bare body of a text
// in force.
import type { Snippet } from 'svelte';
import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { needOf } from '$lib/labels';
import type { Proposal } from '$lib/types';
import Crans from './Crans.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
text,
mark = 'unchanged',
seedMinimal = false,
seed = null,
enteredAt = null,
rivals = 0,
selected = false,
@@ -15,17 +25,17 @@
voteFor = null,
voteOpen = false,
votes = 0,
module = false,
proposeLabel,
meta,
content,
fold,
onaction,
}: {
text: Shown;
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
seedMinimal?: boolean;
/** A seed proposal at the head (ADR 0011): how far it is from passing, not in force. */
seed?: Proposal | null;
enteredAt?: string | null;
/** Its other versions open to the vote: said in its state, read in « Le vote ». */
rivals?: number;
selected?: boolean;
actions?: boolean;
@@ -34,51 +44,46 @@
voteOpen?: boolean;
/** The votes on the version in force, shown next to the seed mark. */
votes?: number;
/** A module (ADR 0009): quieter than an article, its own words to propose. */
module?: boolean;
proposeLabel?: string;
/** What the header adds after the title (a count, a level). */
meta?: Snippet;
/** In place of the text, for a module. */
content?: Snippet;
/** The vote, unfolded under the text. */
fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
onaction: (action: 'vote' | 'propose') => void;
} = $props();
</script>
<!-- A module is not an article of the text: a group named by its title. -->
<svelte:element
this={module ? 'div' : 'article'}
<article
class="unit {mark}"
class:module
class:selected
class:seed={!!seed}
class:unfolded={voteOpen}
role={module ? 'group' : undefined}
aria-label={module ? text.title : undefined}
>
<header>
{#if seed}<WaterLevel tally={seed.tally} evaluation={seed.evaluation} blocked={seed.blocked} />{/if}
<h2 class="eyebrow">{text.title}</h2>
{@render meta?.()}
{#if seed}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
</header>
{#if content}{@render content()}{:else}<Prose text={text.body} />{/if}
<Prose text={text.body} />
{#if actions}
<footer class="actions">
<span class="status faint">
{#if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{#if seed}
{@const need = needOf(seed)}
{t('tally.short', { for: seed.tally.for, against: seed.tally.against })}{#if need}{' · '}<span title={need.title}>{need.short}</span>{/if}
<span class="crans"><Crans level={seed.votedAtInertia} short /></span>
{:else if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
{#if rivals > 0} · {i18n.plural('doc.others', rivals)}{/if}
</span>
<span class="links">
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
</span>
</footer>
{/if}
{#if voteOpen && fold}{@render fold()}{/if}
</svelte:element>
</article>
<style>
.unit {
@@ -98,18 +103,12 @@
background: var(--surface);
box-shadow: var(--shadow);
}
/* A module closes its section: set apart, quieter than the articles. */
.unit.module {
margin-top: 0.9rem;
margin-bottom: 1.4rem;
padding-block: 0.85rem 0.6rem;
background: var(--water-wash);
/* A seed proposal: the water of a vote under way, never the bare page of a text in force. */
.unit.seed {
background: color-mix(in srgb, var(--water-wash) 55%, transparent);
}
.unit.module:hover,
.unit.module:focus-within,
.unit.module.selected,
.unit.module.unfolded {
background: var(--surface);
.unit.seed :global(.prose) {
color: var(--ink-soft);
}
header {
display: flex;
@@ -127,6 +126,9 @@
.replaced {
background: var(--moss-wash);
}
.crans {
margin-left: 0.6rem;
}
.actions {
display: flex;
flex-wrap: wrap;
@@ -168,4 +170,15 @@
opacity: 1;
}
}
@media (max-width: 899px) {
.links button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.unit,
.links {
transition: none;
}
}
</style>
+133 -9
View File
@@ -5,22 +5,26 @@
// never who voted what —, how far the threshold is, your vote and where it
// stands, then the inertia and the formula. A version held back by an
// objection says so, and tells each one what is theirs to do (ADR 0010); it
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The
// rest of a version (typos, translations, objections, history) lives in the
// panel, tab « Détails ».
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). Lot U: how
// many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
// its other versions — each opens its own vote where it reads —, and the quorum its
// author may set before the first vote. The rest of a version (typos, translations,
// objections, history) lives in the panel, « Détails ».
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
import { needOf, orderNote, titleOf } from '$lib/labels';
import { holding } from '$lib/objections';
import { moved } from '$lib/order';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Crans from './Crans.svelte';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import QuorumSetting from './QuorumSetting.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
@@ -36,7 +40,7 @@
doc: DocState;
proposal: Proposal;
unit: Unit;
/** Unfolds the vote of another version (a change of inertia). */
/** Unfolds the vote of another version, where it reads (another text, a change of inertia). */
onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void;
ondetails: (proposalId: string) => void;
@@ -54,6 +58,8 @@
}
let detail = $state<Detail | null>(null);
/** « Autres versions », folded until asked: the vote stays short. */
let othersOpen = $state(false);
let busy = $state(false);
let error = $state('');
let root: HTMLElement | undefined = $state();
@@ -72,6 +78,26 @@
const shifted = $derived(order && p.status !== 'in_force' ? moved(unit.members ?? [], order.ids) : new Set<string>());
const source = $derived(view?.doc.sourceLang ?? 'fr');
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
/** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */
const need = $derived(needOf(p));
/** The other live versions of the module (its changes of inertia live with its inertia). */
const others = $derived(
[unit.inForce, ...unit.proposals.filter((x) => x.status === 'open' && x.kind !== 'inertia')].filter(
(x): x is Proposal => !!x && x.id !== p.id,
),
);
/** What tells another version apart, in a few words. */
function otherLabel(v: Proposal): string {
if (v.kind === 'order') {
if (v.status === 'in_force') return t('module.order.inforce');
return v.author?.name ? t('panel.by', { name: v.author.name }) : t('module.order.proposal');
}
if (v.kind === 'remove') return titleOf(v, unit);
if (v.label) return `${v.title} · ${t('deep.variant', { label: v.label })}`;
if (v.status !== 'in_force' && v.author?.name) return `${v.title} · ${t('panel.by', { name: v.author.name })}`;
return v.title;
}
$effect(() => {
const id = p.id;
@@ -119,8 +145,9 @@
}
busy = true;
try {
await voteDirect(doc, lines);
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
const r = await voteDirect(doc, lines);
// A simulated vote says so, never « enregistré » (ADR 0012).
toasts.show(t(!choice ? 'vote.retracted' : r.simulated ? 'vote.saved.simulated' : 'vote.saved'));
} catch (e) {
error = errorText(e);
} finally {
@@ -135,10 +162,10 @@
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
<p class="faint small"><Crans level={p.votedAtInertia} /></p>
</div>
</div>
{#if p.blocked}
@@ -223,7 +250,35 @@
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="more">
{#if others.length}
<button type="button" class="disclose" aria-expanded={othersOpen} aria-controls="others-{p.id}" onclick={() => (othersOpen = !othersOpen)}>
<span class="tri" aria-hidden="true"></span>{t('vote.others.count', { n: others.length })}
</button>
{/if}
{#if others.length && othersOpen}
<section class="others" id="others-{p.id}" aria-label={t('vote.others')}>
<ul>
{#each others as v (v.id)}
<li>
<button type="button" class="other" onclick={() => onopen(v.id)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="about">
<span class="label">{otherLabel(v)}</span>
<span class="faint small">
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
{#if v.status === 'in_force'} · {t('basket.inforce')}{/if}
{#if v.seedProposal} · {t('doc.seedProposal')}{/if}
{#if v.blocked} · {t('deep.held')}{/if}
</span>
</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if}
{#if detail && view}
<Formula
electorate={detail.electorate}
@@ -269,6 +324,72 @@
.state {
font-weight: 700;
}
.need {
font-weight: 750;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
text-decoration: underline dotted color-mix(in srgb, var(--ink-faint) 60%, transparent);
text-underline-offset: 4px;
cursor: help;
}
.others {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.disclose {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.25rem;
padding: 0;
border: 0;
background: none;
color: var(--water);
font-weight: 750;
font-size: 0.92rem;
}
.tri {
width: 0;
height: 0;
border-block: 0.3rem solid transparent;
border-left: 0.45rem solid currentColor;
transition: transform 0.15s var(--ease);
}
.disclose[aria-expanded='true'] .tri {
transform: rotate(90deg);
}
.others ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.15rem;
}
.other {
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 2.75rem;
padding: 0.45rem 0.6rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
}
.other:hover {
background: var(--surface);
}
.about {
display: grid;
min-width: 0;
}
.label {
font-weight: 750;
font-size: 0.92rem;
}
.order {
margin: 0;
padding-left: 1.6rem;
@@ -399,5 +520,8 @@
.dot {
animation: none;
}
.tri {
transition: none;
}
}
</style>
+12 -1
View File
@@ -9,7 +9,7 @@
evaluation,
blocked = false,
size = 'row',
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'row' | 'large' } = $props();
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'mini' | 'row' | 'large' } = $props();
const ratio = $derived(
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0,
@@ -40,6 +40,12 @@
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
overflow: hidden;
}
/* A section folded: one small bar per article, where the support rises. */
.level.mini {
width: 5px;
height: 1.1rem;
border-radius: 3px;
}
.level.large {
width: 14px;
height: 5.5rem;
@@ -58,4 +64,9 @@
.blocked {
box-shadow: inset 0 0 0 2px var(--ochre);
}
@media (prefers-reduced-motion: reduce) {
.water {
transition: none;
}
}
</style>
+51 -17
View File
@@ -8,8 +8,13 @@ import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all';
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
/**
* The panel, one thing at a time (lot U, UX-lecteur.md): the basket of the vote, a new
* proposal, the details of a version, the settings of the formula. What is read and decided
* on one version — its vote, its other versions, its inertia, the order of its section —
* unfolds in place.
*/
export type Tab = 'basket' | 'propose' | 'details' | 'settings';
export interface PanelState {
unitId: string | null;
@@ -25,9 +30,9 @@ export interface PanelState {
edit?: boolean;
}
/** The tabs as the URL names them. */
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
/** The panel as the URL names it (`?onglet=`); an old `onglet=propositions` unfolds the vote instead. */
export const TABS: Record<string, Tab> = { panier: 'basket', proposer: 'propose', details: 'details', reglages: 'settings' };
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', propose: 'proposer', details: 'details', settings: 'reglages' };
/** The query of a reader's URL: the unfolded vote and the panel. */
export function panelSearch(panel: PanelState | null, vote: string | null): string {
@@ -66,6 +71,20 @@ export function shown(
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
}
/** A version as the text reads it: in force, or a seed proposal at the head (`seed`). */
export interface Entry {
unit: Unit;
proposal: Proposal;
seed: boolean;
}
export interface TextSection {
section: string | null;
entries: Entry[];
/** Its order, when it has something to decide (two articles or more). */
order: Unit | undefined;
}
export class DocState {
view = $state<DocView | null>(null);
error = $state<string | null>(null);
@@ -155,20 +174,35 @@ export class DocState {
}
/**
* The text in force by section, each closed by its order (ADR 0009) — shown even
* where no article is in force yet, as long as it has something to decide.
* The seed proposals (ADR 0011), read at the head of the text while no article is in
* force: one knows where to begin. Never once an article is in force.
*/
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] {
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = [];
for (const u of this.view?.units ?? []) {
let last = out.at(-1);
if (!last || last.section !== u.section) {
last = { section: u.section, units: [], order: this.orderOf(u.section) };
out.push(last);
}
if (u.inForce) last.units.push(u);
get seedHead(): Entry[] {
if (this.inForce.length) return [];
const out: Entry[] = [];
for (const unit of this.view?.units ?? []) {
const proposal = unit.proposals.find((p) => p.status === 'open' && p.seedProposal);
if (proposal) out.push({ unit, proposal, seed: true });
}
return out.filter((s) => s.units.length || s.order);
return out;
}
/**
* The text as it reads, by section: the articles in force — or, while none is, the seed
* proposals —, each section closed by its order (ADR 0009) when it has something to decide.
* A section with nothing to read here has its order at its end among the proposals.
*/
get textSections(): TextSection[] {
const entries: Entry[] = this.inForce.length
? this.inForce.flatMap((unit) => (unit.inForce ? [{ unit, proposal: unit.inForce, seed: false }] : []))
: this.seedHead;
const out: TextSection[] = [];
for (const e of entries) {
const last = out.at(-1);
if (last && last.section === e.unit.section) last.entries.push(e);
else out.push({ section: e.unit.section, entries: [e], order: this.orderOf(e.unit.section) });
}
return out;
}
/** Open proposals, of articles and modules, in document order; drafts apart. */
+50 -12
View File
@@ -128,11 +128,13 @@ export const en: Messages = {
'doc.confirmed': 'confirmed by vote',
'doc.adopted': 'adopted on {date}',
'doc.action.vote': 'Voting',
'doc.action.proposals': 'Proposals',
'doc.action.propose': 'Propose a change',
'doc.basket': 'Vote in bulk',
'doc.basket.count': 'Vote in bulk · {n}',
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
'doc.empty.seeds': 'No article is in force yet. Start with the seed proposals: they come into force by the vote, like any proposal.',
'doc.others.one': '{n} other version open',
'doc.others.other': '{n} other versions open',
'doc.published': 'Published to the text repository',
'doc.publish.pending': 'Publication pending',
@@ -158,6 +160,11 @@ export const en: Messages = {
'deep.versions.other': '{n} versions',
'deep.version': 'version {n}',
'deep.lead': 'leading: {label}',
'deep.section.none': 'No section',
'deep.section.count.one': '{n} article',
'deep.section.count.other': '{n} articles',
'deep.unfold': 'Unfold all',
'deep.fold': 'Fold all',
'section.introduction': 'Introduction',
'section.fondamental': 'Fundamental commitments',
'section.technique': 'Technical commitments',
@@ -169,8 +176,6 @@ export const en: Messages = {
'module.title.name': 'Title of the document',
'module.order.name': 'Order of the articles',
'module.order.named': 'Order of the articles · {section}',
'module.order.count.one': '{n} article',
'module.order.count.other': '{n} articles',
'module.order.inforce': 'Order in force',
'module.order.proposal': 'Order proposed',
'module.order.added.one': '{n} article added since, placed at the end',
@@ -179,24 +184,31 @@ export const en: Messages = {
'module.order.dropped.other': '{n} articles gone since',
'module.order.same': 'it is the order in force',
'module.order.rule': 'A new article goes at the end of its section. If it arrives while an order is under vote, it is added at its end, and the votes stay.',
'order.propose': 'Propose another order',
'order.edit.label': 'New order of the articles',
'order.edit.help': 'Click an article, then its new place, or use its arrows. With the keyboard: Enter to choose it, up and down arrows to move it.',
'order.edit.help': 'Take an article by its handle and drop it in its place. Without dragging: click an article, then its new place, or use its arrows. With the keyboard: up and down arrows.',
'order.edit.up': 'Move “{title}” up',
'order.edit.down': 'Move “{title}” down',
'order.edit.chosen': '“{title}” chosen: click its new place.',
'order.edit.moved': '“{title}” now in place {n} of {total}.',
'order.edit.submit': 'Propose this order',
'order.edit.unchanged': 'This is the order in force: move at least one article.',
'order.edit.exists': 'This order is already proposed: vote for it rather than proposing it twice.',
'order.edit.vote': 'Vote on this order',
'order.edit.proposed': 'proposed',
'order.line': 'Order of the section',
'order.reorder': 'Reorder',
'order.proposed.one': '{n} order proposed',
'order.proposed.other': '{n} orders proposed',
'order.edit.note': 'An order is voted at inertia {level}, like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre.',
'order.submitted': 'Order submitted to vote.',
'title.propose': 'Propose another title',
'title.propose.label': 'New title',
'title.propose.note': 'The title of the document is voted at inertia {level}. Its translations stay editorial: they will read “to review”.',
'title.submitted': 'Title submitted to vote.',
'title.proposed.one': '{n} title proposed',
'title.proposed.other': '{n} titles proposed',
'tally.short': '{for} for · {against} against',
'tally.required': 'Threshold: {n} votes in favor',
'tally.missing.one': '{n} more vote in favor needed.',
'tally.missing.other': '{n} more votes in favor needed.',
'tally.reached': 'Threshold reached.',
@@ -210,11 +222,7 @@ export const en: Messages = {
'inertia.badge': 'inertia {level}',
'panel.vote': 'Voting',
'panel.proposals': 'Proposals',
'panel.propose': 'Propose',
'panel.settings': 'Settings',
'panel.basket': 'Basket',
'panel.details': 'Details',
'panel.close': 'Close panel',
'panel.inforce': 'In force',
@@ -230,6 +238,19 @@ export const en: Messages = {
'vote.against': 'Against',
'vote.retract': 'Withdraw my vote',
'vote.saved': 'Vote saved.',
'vote.saved.simulated': 'Vote simulated.',
'vote.simulated.mark': 'simulated vote',
'vote.others': 'Other versions',
'vote.others.count': 'Other versions ({n})',
'vote.need': '{yes} yes to pass · {no} no to block',
'vote.need.quorum': '{yes} yes to pass · the quorum blocks it',
'vote.need.first.one': '{yes} yes out of {quorum} voter to pass',
'vote.need.first.other': '{yes} yes out of {quorum} voters to pass',
'vote.need.short': 'passes at {yes} yes · blocked at {no} no',
'vote.need.short.quorum': 'passes at {yes} yes',
'vote.need.short.first': '{yes} yes out of {quorum}',
'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as “no”.',
'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as “no”.',
'vote.retracted': 'Vote withdrawn.',
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
'vote.reconfirm': 'Reconfirm: {choice}',
@@ -256,7 +277,7 @@ export const en: Messages = {
'vote.mine.saved': 'saved',
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
'vote.mine.simulated': 'simulated',
'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until it is erased when the chain opens.',
'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until a human decision erases the simulated votes.',
'vote.tally.simulated.one': 'of which {n} simulated',
'vote.tally.simulated.other': 'of which {n} simulated',
'vote.mine.refused': 'Your last vote was not counted: {reason}',
@@ -270,6 +291,21 @@ export const en: Messages = {
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.',
'vote.members': 'Only members of this group vote here.',
'vote.details': 'Typos, translations, objections, history',
'quorum.heading.one': 'Quorum · {n} voter',
'quorum.heading.other': 'Quorum · {n} voters',
'quorum.default.one': 'By default, that of its level: {n} voter.',
'quorum.default.other': 'By default, that of its level: {n} voters.',
'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as “no”.',
'quorum.field': 'Quorum of the article',
'quorum.set': 'Set this quorum',
'quorum.reset': 'Back to the default ({n})',
'quorum.done.one': 'Quorum set: {n} voter.',
'quorum.done.other': 'Quorum set: {n} voters.',
'quorum.reset.done.one': 'Quorum back to the default: {n} voter.',
'quorum.reset.done.other': 'Quorum back to the default: {n} voters.',
'quorum.error.quorum': 'This quorum is out of bounds: at least 2 voters (1 in the test group), at most {w}, the electorate.',
'quorum.error.voted': 'Someone has already voted on this article: its quorum no longer changes.',
'quorum.error.forbidden': 'Only the person who wrote the article sets its quorum, before the first vote.',
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
@@ -295,6 +331,8 @@ export const en: Messages = {
'basket.empty': 'Nothing in the basket yet.',
'basket.sent.one': '{n} vote saved.',
'basket.sent.other': '{n} votes saved.',
'basket.sent.simulated.one': '{n} vote simulated.',
'basket.sent.simulated.other': '{n} votes simulated.',
'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}',
'basket.held': 'an objection holds it back: against or withdraw, no “for”',
@@ -341,7 +379,7 @@ export const en: Messages = {
'ballots.lines.other': '{n} votes',
'ballots.status.pending': 'Waiting for the Ğ1 chain',
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
'ballots.status.simulated': 'Simulated: counted for “{name}” until the Ğ1 chain opens',
'ballots.status.simulated': 'Simulated: counted for “{name}” until the simulated votes are erased',
'ballots.status.refused': 'Not counted: {reason}',
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
'ballots.block': 'block {n}',
+50 -12
View File
@@ -128,11 +128,13 @@ export const es: Messages = {
'doc.confirmed': 'confirmado por la votación',
'doc.adopted': 'adoptado el {date}',
'doc.action.vote': 'Votación',
'doc.action.proposals': 'Propuestas',
'doc.action.propose': 'Proponer una modificación',
'doc.basket': 'Votar en bloque',
'doc.basket.count': 'Votar en bloque · {n}',
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
'doc.empty.seeds': 'Ningún artículo está aún en vigor. Empieza por las propuestas semilla: entran en vigor por el voto, como cualquier propuesta.',
'doc.others.one': '{n} otra versión en curso',
'doc.others.other': '{n} otras versiones en curso',
'doc.published': 'Publicado en el repositorio de textos',
'doc.publish.pending': 'Publicación pendiente',
@@ -158,6 +160,11 @@ export const es: Messages = {
'deep.versions.other': '{n} versiones',
'deep.version': 'versión {n}',
'deep.lead': 'en cabeza: {label}',
'deep.section.none': 'Sin sección',
'deep.section.count.one': '{n} artículo',
'deep.section.count.other': '{n} artículos',
'deep.unfold': 'Desplegar todo',
'deep.fold': 'Plegar todo',
'section.introduction': 'Introducción',
'section.fondamental': 'Compromisos fundamentales',
'section.technique': 'Compromisos técnicos',
@@ -169,8 +176,6 @@ export const es: Messages = {
'module.title.name': 'Título del documento',
'module.order.name': 'Orden de los artículos',
'module.order.named': 'Orden de los artículos · {section}',
'module.order.count.one': '{n} artículo',
'module.order.count.other': '{n} artículos',
'module.order.inforce': 'Orden en vigor',
'module.order.proposal': 'Orden propuesto',
'module.order.added.one': '{n} artículo añadido desde entonces, al final',
@@ -179,24 +184,31 @@ export const es: Messages = {
'module.order.dropped.other': '{n} artículos salidos desde entonces',
'module.order.same': 'es el orden en vigor',
'module.order.rule': 'Un artículo nuevo se coloca al final de su sección. Si llega durante la votación de un orden, se añade al final, y los votos se mantienen.',
'order.propose': 'Proponer otro orden',
'order.edit.label': 'Nuevo orden de los artículos',
'order.edit.help': 'Haz clic en un artículo y luego en su nuevo lugar, o usa sus flechas. Con el teclado: Intro para elegirlo, flechas arriba y abajo para moverlo.',
'order.edit.help': 'Toma un artículo por su asa y suéltalo en su lugar. Sin arrastrar: haz clic en un artículo y luego en su nuevo lugar, o usa sus flechas. Con el teclado: flechas arriba y abajo.',
'order.edit.up': 'Subir «{title}»',
'order.edit.down': 'Bajar «{title}»',
'order.edit.chosen': '«{title}» elegido: haz clic en su nuevo lugar.',
'order.edit.moved': '«{title}» en el lugar {n} de {total}.',
'order.edit.submit': 'Proponer este orden',
'order.edit.unchanged': 'Es el orden en vigor: mueve al menos un artículo.',
'order.edit.exists': 'Este orden ya está propuesto: vótalo en lugar de proponerlo dos veces.',
'order.edit.vote': 'Votar este orden',
'order.edit.proposed': 'propuesto',
'order.line': 'Orden de la sección',
'order.reorder': 'Reordenar',
'order.proposed.one': '{n} orden propuesto',
'order.proposed.other': '{n} órdenes propuestos',
'order.edit.note': 'Un orden se vota con la inercia {level}, como un artículo: desplegado, en la cesta, por la cadena Ğ1 en Monnaie libre.',
'order.submitted': 'Orden sometido a votación.',
'title.propose': 'Proponer otro título',
'title.propose.label': 'Nuevo título',
'title.propose.note': 'El título del documento se vota con la inercia {level}. Sus traducciones siguen siendo editoriales: pasarán a «por revisar».',
'title.submitted': 'Título sometido a votación.',
'title.proposed.one': '{n} título propuesto',
'title.proposed.other': '{n} títulos propuestos',
'tally.short': '{for} a favor · {against} en contra',
'tally.required': 'Umbral: {n} votos a favor',
'tally.missing.one': 'Falta {n} voto a favor.',
'tally.missing.other': 'Faltan {n} votos a favor.',
'tally.reached': 'Umbral alcanzado.',
@@ -210,11 +222,7 @@ export const es: Messages = {
'inertia.badge': 'inercia {level}',
'panel.vote': 'Votación',
'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer',
'panel.settings': 'Ajustes',
'panel.basket': 'Cesta',
'panel.details': 'Detalles',
'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor',
@@ -230,6 +238,19 @@ export const es: Messages = {
'vote.against': 'En contra',
'vote.retract': 'Retirar mi voto',
'vote.saved': 'Voto registrado.',
'vote.saved.simulated': 'Voto simulado.',
'vote.simulated.mark': 'voto simulado',
'vote.others': 'Otras versiones',
'vote.others.count': 'Otras versiones ({n})',
'vote.need': '{yes} sí para aprobar · {no} no para bloquear',
'vote.need.quorum': '{yes} sí para aprobar · el quórum la bloquea',
'vote.need.first.one': '{yes} sí de {quorum} votante para aprobar',
'vote.need.first.other': '{yes} sí de {quorum} votantes para aprobar',
'vote.need.short': 'aprueba con {yes} sí · bloqueada con {no} no',
'vote.need.short.quorum': 'aprueba con {yes} sí',
'vote.need.short.first': '{yes} sí de {quorum}',
'vote.need.title.one': 'Quórum: {n} votante. Por debajo, los votantes que faltan cuentan como «no».',
'vote.need.title.other': 'Quórum: {n} votantes. Por debajo, los votantes que faltan cuentan como «no».',
'vote.retracted': 'Voto retirado.',
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
'vote.reconfirm': 'Reconfirmar: {choice}',
@@ -256,7 +277,7 @@ export const es: Messages = {
'vote.mine.saved': 'registrado',
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
'vote.mine.simulated': 'simulado',
'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», hasta que se borre al abrirse la cadena.',
'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», mientras una decisión humana no borre los votos simulados.',
'vote.tally.simulated.one': 'de ellos {n} simulado',
'vote.tally.simulated.other': 'de ellos {n} simulados',
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
@@ -270,6 +291,21 @@ export const es: Messages = {
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.',
'vote.members': 'Aquí solo votan los miembros de este grupo.',
'vote.details': 'Erratas, traducciones, objeciones, historial',
'quorum.heading.one': 'Quórum · {n} votante',
'quorum.heading.other': 'Quórum · {n} votantes',
'quorum.default.one': 'Por defecto, el de su nivel: {n} votante.',
'quorum.default.other': 'Por defecto, el de su nivel: {n} votantes.',
'quorum.explain': 'Fijas el quórum de este artículo mientras nadie haya votado. Por debajo, los votantes que faltan cuentan como «no».',
'quorum.field': 'Quórum del artículo',
'quorum.set': 'Fijar este quórum',
'quorum.reset': 'Volver al valor por defecto ({n})',
'quorum.done.one': 'Quórum fijado: {n} votante.',
'quorum.done.other': 'Quórum fijado: {n} votantes.',
'quorum.reset.done.one': 'Quórum de vuelta al valor por defecto: {n} votante.',
'quorum.reset.done.other': 'Quórum de vuelta al valor por defecto: {n} votantes.',
'quorum.error.quorum': 'Este quórum se sale de los límites: al menos 2 votantes (1 en el grupo de prueba), como mucho {w}, el electorado.',
'quorum.error.voted': 'Alguien ya ha votado sobre este artículo: su quórum ya no cambia.',
'quorum.error.forbidden': 'Solo la persona que escribió el artículo fija su quórum, antes del primer voto.',
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
@@ -295,6 +331,8 @@ export const es: Messages = {
'basket.empty': 'Nada en la cesta por ahora.',
'basket.sent.one': '{n} voto registrado.',
'basket.sent.other': '{n} votos registrados.',
'basket.sent.simulated.one': '{n} voto simulado.',
'basket.sent.simulated.other': '{n} votos simulados.',
'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}',
'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»',
@@ -341,7 +379,7 @@ export const es: Messages = {
'ballots.lines.other': '{n} votos',
'ballots.status.pending': 'Esperando a la cadena Ğ1',
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
'ballots.status.simulated': 'Simulado: contado para «{name}» hasta que se abra la cadena Ğ1',
'ballots.status.simulated': 'Simulado: contado para «{name}» mientras no se borren los votos simulados',
'ballots.status.refused': 'No contado: {reason}',
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
'ballots.block': 'bloque {n}',
+50 -12
View File
@@ -132,11 +132,13 @@ export const fr = {
'doc.confirmed': 'confirmé par le vote',
'doc.adopted': 'adopté le {date}',
'doc.action.vote': 'Le vote',
'doc.action.proposals': 'Propositions',
'doc.action.propose': 'Proposer une modification',
'doc.basket': 'Voter en lot',
'doc.basket.count': 'Voter en lot · {n}',
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
'doc.empty.seeds': "Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition.",
'doc.others.one': '{n} autre version en cours',
'doc.others.other': '{n} autres versions en cours',
'doc.published': 'Publié sur le dépôt des textes',
'doc.publish.pending': 'Publication en attente',
@@ -162,6 +164,11 @@ export const fr = {
'deep.versions.other': '{n} versions',
'deep.version': 'version {n}',
'deep.lead': 'en tête : {label}',
'deep.section.none': 'Sans section',
'deep.section.count.one': '{n} article',
'deep.section.count.other': '{n} articles',
'deep.unfold': 'Tout déplier',
'deep.fold': 'Tout replier',
'section.introduction': 'Introduction',
'section.fondamental': 'Engagements fondamentaux',
'section.technique': 'Engagements techniques',
@@ -173,8 +180,6 @@ export const fr = {
'module.title.name': 'Titre du document',
'module.order.name': 'Ordre des articles',
'module.order.named': 'Ordre des articles · {section}',
'module.order.count.one': '{n} article',
'module.order.count.other': '{n} articles',
'module.order.inforce': 'Ordre en vigueur',
'module.order.proposal': 'Ordre proposé',
'module.order.added.one': '{n} article entré depuis, placé à la fin',
@@ -183,24 +188,31 @@ export const fr = {
'module.order.dropped.other': '{n} articles sortis depuis',
'module.order.same': "c'est l'ordre en vigueur",
'module.order.rule': "Un nouvel article se place à la fin de sa section. S'il arrive pendant le vote d'un ordre, il s'y ajoute à la fin, et les votes restent.",
'order.propose': 'Proposer un autre ordre',
'order.edit.label': 'Nouvel ordre des articles',
'order.edit.help': 'Clique un article, puis sa nouvelle place, ou utilise ses flèches. Au clavier : Entrée pour le choisir, flèches haut et bas pour le déplacer.',
'order.edit.help': 'Prends un article par sa poignée et dépose-le à sa place. Sans glisser : clique un article, puis sa nouvelle place, ou utilise ses flèches. Au clavier : flèches haut et bas.',
'order.edit.up': 'Monter « {title} »',
'order.edit.down': 'Descendre « {title} »',
'order.edit.chosen': '« {title} » choisi : clique sa nouvelle place.',
'order.edit.moved': '« {title} » en place {n} sur {total}.',
'order.edit.submit': 'Proposer cet ordre',
'order.edit.unchanged': "C'est l'ordre en vigueur : déplace au moins un article.",
'order.edit.exists': 'Cet ordre est déjà proposé : vote-le plutôt que de le proposer deux fois.',
'order.edit.vote': 'Voter cet ordre',
'order.edit.proposed': 'proposé',
'order.line': 'Ordre de la section',
'order.reorder': 'Réordonner',
'order.proposed.one': '{n} ordre proposé',
'order.proposed.other': '{n} ordres proposés',
'order.edit.note': "Un ordre se vote à l'inertie {level}, comme un article : déplié, dans le panier, par la chaîne Ğ1 dans Monnaie libre.",
'order.submitted': 'Ordre proposé au vote.',
'title.propose': 'Proposer un autre titre',
'title.propose.label': 'Nouveau titre',
'title.propose.note': "Le titre du document se vote à l'inertie {level}. Ses traductions restent éditoriales : elles passeront « à revoir ».",
'title.submitted': 'Titre proposé au vote.',
'title.proposed.one': '{n} titre proposé',
'title.proposed.other': '{n} titres proposés',
'tally.short': '{for} pour · {against} contre',
'tally.required': 'Seuil : {n} voix pour',
'tally.missing.one': 'Il manque {n} voix pour.',
'tally.missing.other': 'Il manque {n} voix pour.',
'tally.reached': 'Seuil atteint.',
@@ -214,11 +226,7 @@ export const fr = {
'inertia.badge': 'inertie {level}',
'panel.vote': 'Le vote',
'panel.proposals': 'Propositions',
'panel.propose': 'Proposer',
'panel.settings': 'Réglages',
'panel.basket': 'Panier',
'panel.details': 'Détails',
'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur',
@@ -234,6 +242,19 @@ export const fr = {
'vote.against': 'Contre',
'vote.retract': 'Retirer mon vote',
'vote.saved': 'Vote enregistré.',
'vote.saved.simulated': 'Vote simulé.',
'vote.simulated.mark': 'vote simulé',
'vote.others': 'Autres versions',
'vote.others.count': 'Autres versions ({n})',
'vote.need': '{yes} oui pour passer · {no} non pour bloquer',
'vote.need.quorum': '{yes} oui pour passer · le quorum la bloque',
'vote.need.first.one': '{yes} oui sur {quorum} votant pour passer',
'vote.need.first.other': '{yes} oui sur {quorum} votants pour passer',
'vote.need.short': 'passe à {yes} oui · bloquée à {no} non',
'vote.need.short.quorum': 'passe à {yes} oui',
'vote.need.short.first': '{yes} oui sur {quorum}',
'vote.need.title.one': 'Quorum : {n} votant. En dessous, les votants qui manquent comptent comme « non ».',
'vote.need.title.other': 'Quorum : {n} votants. En dessous, les votants qui manquent comptent comme « non ».',
'vote.retracted': 'Vote retiré.',
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
'vote.reconfirm': 'Reconfirmer : {choice}',
@@ -260,7 +281,7 @@ export const fr = {
'vote.mine.saved': 'enregistré',
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
'vote.mine.simulated': 'simulé',
'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », jusqu'à son effacement à l'ouverture de la chaîne.",
'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », tant qu'une décision humaine n'efface pas les votes simulés.",
'vote.tally.simulated.one': 'dont {n} simulé',
'vote.tally.simulated.other': 'dont {n} simulés',
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
@@ -274,6 +295,21 @@ export const fr = {
'vote.chain.off': "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.",
'vote.members': 'Seuls les membres de ce groupe votent ici.',
'vote.details': 'Coquilles, traductions, objections, historique',
'quorum.heading.one': 'Quorum · {n} votant',
'quorum.heading.other': 'Quorum · {n} votants',
'quorum.default.one': 'Par défaut, celui de son cran : {n} votant.',
'quorum.default.other': 'Par défaut, celui de son cran : {n} votants.',
'quorum.explain': "Tu fixes le quorum de cet article tant que personne n'a voté. En dessous, les votants qui manquent comptent comme « non ».",
'quorum.field': "Quorum de l'article",
'quorum.set': 'Fixer ce quorum',
'quorum.reset': 'Revenir au défaut ({n})',
'quorum.done.one': 'Quorum fixé : {n} votant.',
'quorum.done.other': 'Quorum fixé : {n} votants.',
'quorum.reset.done.one': 'Quorum revenu au défaut : {n} votant.',
'quorum.reset.done.other': 'Quorum revenu au défaut : {n} votants.',
'quorum.error.quorum': "Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus {w}, l'électorat.",
'quorum.error.voted': "Quelqu'un a déjà voté sur cet article : son quorum ne change plus.",
'quorum.error.forbidden': "Seule la personne qui a écrit l'article fixe son quorum, avant le premier vote.",
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
@@ -299,6 +335,8 @@ export const fr = {
'basket.empty': "Rien dans le panier pour l'instant.",
'basket.sent.one': '{n} vote enregistré.',
'basket.sent.other': '{n} votes enregistrés.',
'basket.sent.simulated.one': '{n} vote simulé.',
'basket.sent.simulated.other': '{n} votes simulés.',
'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}',
'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »',
@@ -345,7 +383,7 @@ export const fr = {
'ballots.lines.other': '{n} votes',
'ballots.status.pending': 'En attente de la chaîne Ğ1',
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
'ballots.status.simulated': "Simulé : compté pour « {name} » jusqu'à l'ouverture de la chaîne Ğ1",
'ballots.status.simulated': 'Simulé : compté pour « {name} » tant que les votes simulés ne sont pas effacés',
'ballots.status.refused': 'Pas compté : {reason}',
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
'ballots.block': 'bloc {n}',
+22
View File
@@ -53,3 +53,25 @@ export function orderNote(p: Proposal): string[] {
return notes;
}
/**
* How many « oui » pass a version, how many « non » block it (Yvv, 2026-11-02: « combien il
* faut de oui pour passer et combien de non pour maintenir l'opposition »), from what the
* server says (ADR 0013): `yesNeeded`, the total of *for* that passes with the *against* cast
* now; `noNeeded`, the total of *against* that holds the *for* cast now in check — 0 when
* the quorum holds them alone. Before any vote: « 9 oui sur 12 votants ». The quorum goes
* to the tooltip (`title`), never to the title of a line.
*/
export function needOf(p: Proposal): { long: string; short: string; title: string } | null {
const e = p.evaluation;
if (!e || e.yesNeeded === undefined || e.noNeeded === undefined || e.quorum === undefined) return null;
const yes = e.yesNeeded;
const no = e.noNeeded;
const quorum = e.quorum;
const title = i18n.plural('vote.need.title', quorum);
if (e.cast === 0) {
return { long: i18n.plural('vote.need.first', quorum, { yes, quorum }), short: t('vote.need.short.first', { yes, quorum }), title };
}
if (no === 0) return { long: t('vote.need.quorum', { yes }), short: t('vote.need.short.quorum', { yes }), title };
return { long: t('vote.need', { yes, no }), short: t('vote.need.short', { yes, no }), title };
}
+33
View File
@@ -78,3 +78,36 @@ export function articles(items: { unit: Unit; proposal: Proposal }[], filter: Fi
return { unit, versions: ranked, leads: !!best && !!next && compare(best, next) < 0 };
});
}
/** A section of « Propositions en cours » (lot U): its articles, and its order when it closes here. */
export interface DeepSection {
key: string | null;
articles: Article[];
/** The order of the section, when the section is not read in the text above. */
order?: Unit;
}
/**
* The open proposals by section, in the order of the document (`keys`, the sections as the
* document lists them): each section holds its articles, then its order when the order
* closes it here — a section read in the text above has its order there. A section with
* only its order to show is kept.
*/
export function deepSections(articles: Article[], orders: Unit[], keys: (string | null)[]): DeepSection[] {
const byKey = new Map<string | null, DeepSection>();
for (const a of articles) {
const s = byKey.get(a.unit.section) ?? { key: a.unit.section, articles: [] };
s.articles.push(a);
byKey.set(a.unit.section, s);
}
for (const order of orders) {
const s = byKey.get(order.section) ?? { key: order.section, articles: [] };
s.order = order;
byKey.set(order.section, s);
}
const rank = (k: string | null) => {
const i = keys.indexOf(k);
return i < 0 ? keys.length : i;
};
return [...byKey.values()].sort((a, b) => rank(a.key) - rank(b.key));
}
+8 -5
View File
@@ -39,12 +39,15 @@ export function announce(doc: DocState, entered: Entered[]) {
}
}
/** In a managed group: every line in one write on the server. Returns how many were written. */
export async function voteDirect(doc: DocState, lines: Line[]): Promise<number> {
/**
* Every line in one write on the server: a managed group, or the simulation while the chain
* is closed (ADR 0012). Returns how many were written, and whether they were simulated.
*/
export async function voteDirect(doc: DocState, lines: Line[]): Promise<{ count: number; simulated: boolean }> {
const id = doc.view?.doc.id;
if (!id) return 0;
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
if (!id) return { count: 0, simulated: false };
const r = await api.post<{ count: number; entered: Entered[]; simulated?: boolean }>(`/docs/${id}/votes`, { lines });
await doc.reload();
announce(doc, r.entered);
return r.count;
return { count: r.count, simulated: !!r.simulated };
}
+191 -94
View File
@@ -1,4 +1,10 @@
<script lang="ts">
// The reader of a document (lot U, docs/chantiers/acte-certification/UX-lecteur.md). In
// place, what is read and decided on what one has before one's eyes: the vote of a version
// and its other versions, its inertia, the order of a section (handles on its articles).
// On the right, what is written, or works over several modules: proposing, the basket,
// the details of a version, the settings of the formula. Short at a glance: the text, each
// section closed by one line of order; the open proposals folded by section.
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte';
@@ -6,20 +12,22 @@
import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import OrderModule from '$lib/components/OrderModule.svelte';
import OrderLine from '$lib/components/OrderLine.svelte';
import Reorder from '$lib/components/Reorder.svelte';
import TitleBar from '$lib/components/TitleBar.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { sections, votableIds } from '$lib/basket';
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
import { DocState, panelSearch, shown, TABS, type Entry, type PanelState, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { sectionLabel } from '$lib/labels';
import { answerPanel } from '$lib/objections';
import { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote';
import { articles, type Filter } from '$lib/versions';
import { articles, deepSections, type Filter } from '$lib/versions';
const doc = new DocState();
const basket = new BasketState();
@@ -53,10 +61,20 @@
edit: q.get('modifier') === '1',
};
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
/**
* The version whose vote is unfolded where it reads (`?vote=`; the old `onglet=vote` too,
* and the old `onglet=propositions`, whose versions now live inside the vote).
*/
const vote = $derived.by(() => {
const q = page.url.searchParams;
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
const onglet = q.get('onglet');
if (q.get('vote')) return q.get('vote');
if (onglet === 'vote') return q.get('version');
if (onglet === 'propositions') {
const u = doc.unit(q.get('article'));
return q.get('version') ?? u?.inForce?.id ?? u?.proposals.find((p) => p.status === 'open')?.id ?? null;
}
return null;
});
function go(nextPanel: PanelState | null, nextVote: string | null) {
@@ -73,6 +91,22 @@
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
// Reordering a section: handles on its articles, in place (one at a time).
let reordering = $state<string | null>(null);
function startReorder(order: Unit) {
reordering = order.id;
// Its vote folds: the line now holds the articles with their handles.
if ([order.inForce, ...order.proposals].some((p) => p?.id === vote)) go(panel, null);
}
// An old link « proposer un autre ordre » (onglet=proposer on an order) reorders in place.
$effect(() => {
if (panel?.tab !== 'propose') return;
const u = doc.unit(panel.unitId);
if (u?.kind !== 'order') return;
reordering = u.id;
openPanel(null);
});
// On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines);
@@ -92,34 +126,42 @@
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
// One line per article; an article stays when one of its versions passes the filter.
const deep = $derived(articles(doc.open, filter));
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
// The title of the document, a module (ADR 0009): its gestures under the title.
// The title of the document, a module (ADR 0009): its line under the title.
const titleUnit = $derived(view?.modules.title ?? null);
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
if (action === 'vote') {
if (version) toggleVote(version);
return;
}
openPanel({ unitId: u.id, proposalId: null, tab: action });
}
// The text shows the vote of its own versions; the list below, that of the others.
// The modules in force are read in place too: the title at the head, each order at the end of its section.
const reading = $derived(doc.mode === 'inforce' || !doc.preview);
/** The text as it reads, by section, each closed by its order. */
const textSections = $derived(reading ? doc.textSections : []);
/** The versions the text shows: their vote unfolds there, never in the list below. */
const textIds = $derived(
new Set([
titleUnit?.inForce?.id,
...(doc.mode === 'inforce' || !doc.preview
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id)),
]),
new Set(
reading
? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
: (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
),
);
const inText = $derived(!!vote && textIds.has(vote));
// « Propositions en cours »: the articles (the modules are voted at their place), the seed
// proposals of the head apart; by section, each closed by its order when the text does not.
const deep = $derived(articles(doc.open.filter((x) => !x.unit.kind && !textIds.has(x.proposal.id)), filter));
const textKeys = $derived(new Set(textSections.map((s) => s.section)));
const deepOrders = $derived(
filter === 'all' ? (view?.modules.orders ?? []).filter((o) => !o.moot && !textKeys.has(o.section)) : [],
);
const sectionKeys = $derived([...new Set((view?.units ?? []).map((u) => u.section))]);
const deepSecs = $derived(deepSections(deep, deepOrders, sectionKeys));
const canVote = $derived(!!view?.vote.open);
/** The other versions of an article open to the vote, besides the one read. */
const othersOf = (e: Entry) => e.unit.proposals.filter((p) => p.status === 'open' && p.kind !== 'inertia' && p.id !== e.proposal.id).length;
function onEntry(e: Entry, action: 'vote' | 'propose') {
if (action === 'vote') toggleVote(e.proposal.id);
// From a seed proposal (nothing in force): its text to start from.
else openPanel({ unitId: e.unit.id, proposalId: null, tab: 'propose', prefill: e.seed ? { title: e.proposal.title, body: e.proposal.body } : null });
}
</script>
{#snippet foldOf(unit: Unit, p: Proposal)}
@@ -135,6 +177,28 @@
/>
{/snippet}
{#snippet moduleFold(p: Proposal)}
{@const u = doc.unit(p.unitId)}
{#if u}{@render foldOf(u, p)}{/if}
{/snippet}
{#snippet reorderOf(o: Unit)}
<Reorder
{doc}
unit={o}
{nameOf}
oncancel={() => (reordering = null)}
ondone={(id) => {
reordering = null;
go(panel, id);
}}
/>
{/snippet}
{#snippet orderLine(o: Unit)}
<OrderLine unit={o} {vote} reordering={reordering === o.id} fold={moduleFold} reorder={reorderOf} onvote={(id) => toggleVote(id)} onreorder={() => startReorder(o)} />
{/snippet}
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
@@ -146,17 +210,16 @@
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
</p>
<h1>{title}</h1>
{#if titleUnit?.inForce}
{@const tp = titleUnit.inForce}
{#if titleUnit}
<TitleBar
unit={titleUnit}
stale={titleStale}
selected={panel?.unitId === titleUnit.id}
voteOpen={inText && vote === tp.id}
onaction={(action) => onUnit(titleUnit, action)}
>
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
</TitleBar>
{vote}
fold={moduleFold}
onvote={(id) => toggleVote(id)}
onpropose={() => openPanel({ unitId: titleUnit.id, proposalId: null, tab: 'propose' })}
/>
{/if}
<p class="summary">{summary}</p>
<p class="facts faint">
@@ -165,25 +228,25 @@
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p>
{#if canVote}
<div class="basket-row">
<!-- One line of tools — the basket, the language of the text —, then the readings. -->
<div class="tools">
{#if canVote}
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
</button>
</div>
{/if}
<div class="controls">
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
{/each}
</div>
<div class="seg small" role="radiogroup" aria-label={t('doc.lang.label')}>
{#if view.vote.simulated}<span class="tag" title={t('vote.simulated.note')}>{t('vote.simulated.mark')}</span>{/if}
{/if}
<div class="seg small langs" role="radiogroup" aria-label={t('doc.lang.label')}>
{#each LANGS as l (l)}
<button type="button" role="radio" aria-checked={lang === l} class:on={lang === l} onclick={() => { doc.original = l === source && i18n.lang !== source; if (l !== source) i18n.set(l); }}>{l.toUpperCase()}</button>
{/each}
</div>
</div>
<div class="seg views" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
{/each}
</div>
{#if lang !== source}
<p class="note faint">{t('doc.lang.translation')} <button class="linkish" type="button" onclick={() => (doc.original = true)}>{t('doc.lang.original')}</button></p>
{/if}
@@ -191,44 +254,37 @@
</header>
<section class="text" aria-label={t('doc.view.inforce')}>
{#if doc.mode === 'inforce' || !doc.preview}
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
{#each s.units as u (u.id)}
{#if u.inForce}
{@const p = u.inForce}
<UnitBlock
text={shown(p, lang, source)}
seedMinimal={p.seedMinimal}
enteredAt={p.enteredAt}
votes={p.tally.for + p.tally.against}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
voteFor={p.id}
voteOpen={inText && vote === p.id}
onaction={(action) => onUnit(u, action)}
>
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
</UnitBlock>
{#if reading}
<!-- Nothing in force (ADR 0011): the seed proposals at the head, said once. -->
{#if !doc.inForce.length}<p class="lead">{t(textSections.length ? 'doc.empty.seeds' : 'doc.empty')}</p>{/if}
{#each textSections as s, i (`${i}:${s.section}`)}
{@const label = sectionLabel(s.section)}
<div class="text-section">
{#if label}<p class="section-name">{label}</p>{/if}
{#if !s.order || reordering !== s.order.id}
{#each s.entries as e (e.proposal.id)}
{@const p = e.proposal}
<UnitBlock
text={shown(p, lang, source)}
seed={e.seed ? p : null}
seedMinimal={!e.seed && p.seedMinimal}
enteredAt={e.seed ? null : p.enteredAt}
votes={p.tally.for + p.tally.against}
rivals={othersOf(e)}
selected={panel?.unitId === e.unit.id}
voteFor={p.id}
voteOpen={vote === p.id}
onaction={(action) => onEntry(e, action)}
>
{#snippet fold()}{@render foldOf(e.unit, p)}{/snippet}
</UnitBlock>
{/each}
{/if}
{/each}
{#if s.order?.inForce}
{@const o = s.order}
{@const op = s.order.inForce}
<OrderModule
unit={o}
{nameOf}
selected={panel?.unitId === o.id}
voteOpen={inText && vote === op.id}
onaction={(action) => onUnit(o, action)}
>
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
</OrderModule>
{/if}
{#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
</div>
{/each}
{:else}
{#each doc.preview as pu (pu.unitId)}
{#each doc.preview ?? [] as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
{@const p = doc.proposal(id)}
@@ -237,13 +293,17 @@
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
voteFor={id}
voteOpen={inText && !!id && vote === id}
onaction={(action) => u && onUnit(u, action, id)}
voteOpen={!!id && vote === id}
onaction={(action) => {
if (action === 'vote') {
if (id) toggleVote(id);
} else if (u) openPanel({ unitId: u.id, proposalId: null, tab: 'propose' });
}}
>
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
</UnitBlock>
{/each}
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{#if (doc.preview ?? []).every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{/if}
</section>
@@ -264,7 +324,7 @@
<div class="deep-head">
<h2 id="deep-title">{t('deep.title')}</h2>
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</p>
<p class="muted">{t('deep.note')}</p>
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
<button type="button" role="radio" aria-checked={filter === f} class:on={filter === f} onclick={() => (filter = f)}>{t(`deep.filter.${f}`)}</button>
@@ -272,11 +332,13 @@
</div>
</div>
<DeepList
articles={deep}
vote={inText ? null : vote}
sections={deepSecs}
vote={textIds.has(vote ?? '') ? null : vote}
{reordering}
all={filter !== 'all'}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
{nameOf}
order={orderLine}
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
/>
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
@@ -284,7 +346,7 @@
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
<DocPanel {doc} {panel} {lang} {basket} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
{/if}
{#if signing && view.vote.chain}
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
@@ -339,18 +401,18 @@
font-size: 0.85rem;
font-weight: 700;
}
.controls {
.tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
justify-content: space-between;
margin-top: 1.6rem;
margin-top: 1.2rem;
}
.basket-row {
margin-top: 1.4rem;
.langs {
margin-left: auto;
}
.basket-row + .controls {
margin-top: 0.8rem;
.views {
margin-top: 0.7rem;
}
.basket-btn {
background: var(--water);
@@ -372,6 +434,7 @@
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
white-space: nowrap;
}
.seg.small button {
padding-inline: 0.65rem;
@@ -402,6 +465,23 @@
.text {
margin-top: 2.2rem;
}
.lead {
margin-bottom: 1.2rem;
font-size: 0.92rem;
font-weight: 650;
color: var(--ink-soft);
}
.text-section + .text-section {
margin-top: 0.6rem;
}
/* The name of a section: where one is, without a number — the italic of the text, softly. */
.section-name {
margin: 0 0 0.3rem;
font-family: var(--font-text);
font-style: italic;
font-size: 1.12rem;
color: var(--ink-soft);
}
.deep {
padding-top: 2rem;
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
@@ -412,7 +492,7 @@
.deep-head {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
margin-bottom: 0.6rem;
}
.deep-head h2 {
font-family: var(--font-text);
@@ -437,4 +517,21 @@
.new {
margin-top: 1.4rem;
}
/* On a phone: the three readings on one line that scrolls, the targets big enough for a finger. */
@media (max-width: 899px) {
.seg.views {
flex-wrap: nowrap;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.seg button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.reader {
transition: none;
}
}
</style>
+38
View File
@@ -0,0 +1,38 @@
// « Combien il faut de oui pour passer, combien de non pour bloquer » (Yvv, 2026-11-02),
// told from what the server says (ADR 0013): yesNeeded, noNeeded, the quorum.
import { describe, expect, it } from 'vitest';
import { needOf } from '../../src/lib/labels';
import type { Evaluation, Proposal } from '../../src/lib/types';
function version(tally: { for: number; against: number }, e: Partial<Evaluation> | null): Proposal {
const evaluation: Evaluation | null = e
? { cast: tally.for + tally.against, required: 0, reached: false, missing: 0, requiredRatio: 0, wellRated: false, ...e }
: null;
return { tally, evaluation } as unknown as Proposal;
}
describe('the yes to pass, the no to block', () => {
it('before any vote: the yes asked of the quorum', () => {
const need = needOf(version({ for: 0, against: 0 }, { quorum: 12, yesNeeded: 9, noNeeded: 0 }));
expect(need?.long).toBe('9 oui sur 12 votants pour passer');
expect(need?.short).toBe('9 oui sur 12');
expect(need?.title).toBe('Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».');
});
it('one voter of quorum: singular', () => {
expect(needOf(version({ for: 0, against: 0 }, { quorum: 1, yesNeeded: 1, noNeeded: 0 }))?.long).toBe('1 oui sur 1 votant pour passer');
});
it('9 for and 4 against at level 2, quorum 12: 11 yes pass, 4 no block', () => {
const need = needOf(version({ for: 9, against: 4 }, { quorum: 12, yesNeeded: 11, noNeeded: 4 }));
expect(need?.long).toBe('11 oui pour passer · 4 non pour bloquer');
expect(need?.short).toBe('passe à 11 oui · bloquée à 4 non');
});
it('below the quorum, the voters missing hold it alone: no « non » to count', () => {
const need = needOf(version({ for: 3, against: 0 }, { quorum: 12, yesNeeded: 9, noNeeded: 0 }));
expect(need?.long).toBe('9 oui pour passer · le quorum la bloque');
expect(need?.short).toBe('passe à 9 oui');
});
it('nothing said without the numbers of the server', () => {
expect(needOf(version({ for: 0, against: 0 }, null))).toBeNull();
expect(needOf(version({ for: 0, against: 0 }, { quorum: 12 }))).toBeNull();
});
});
+30 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Proposal, Unit } from '../../src/lib/types';
import { articles, excerpts, level } from '../../src/lib/versions';
import { articles, deepSections, excerpts, level } from '../../src/lib/versions';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return {
@@ -96,3 +96,32 @@ describe('the versions of an article', () => {
expect(excerpts(['Seule.'])).toEqual(['Seule.']);
});
});
describe('the open proposals by section (lot U)', () => {
const inSection = (u: Unit, section: string | null): Unit => ({ ...u, section });
it('each section holds its articles in document order, and its order when it closes there', () => {
const t1 = inSection(unit('T1', [prop('t1', 'T1')]), 'technique');
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
const t2 = inSection(unit('T2', [prop('t2', 'T2')]), 'technique');
const order = { ...inSection(unit('O', []), 'technique'), kind: 'order' as const };
const list = deepSections(articles(items(t1, t2, c1)), [order], ['introduction', 'technique', 'conclusion']);
expect(list.map((s) => s.key)).toEqual(['technique', 'conclusion']);
expect(at(list, 0).articles.map((a) => a.unit.id)).toEqual(['T1', 'T2']);
expect(at(list, 0).order?.id).toBe('O');
expect(at(list, 1).order).toBeUndefined();
});
it('a section with only its order to show is kept, at its place in the document', () => {
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
const order = { ...inSection(unit('O', []), 'introduction'), kind: 'order' as const };
const list = deepSections(articles(items(c1)), [order], ['introduction', 'conclusion']);
expect(list.map((s) => [s.key, s.articles.length, s.order?.id ?? null])).toEqual([
['introduction', 0, 'O'],
['conclusion', 1, null],
]);
});
it('the articles without a section come after the sections the document lists', () => {
const loose = unit('N1', [prop('n1', 'N1')]);
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
expect(deepSections(articles(items(loose, c1)), [], ['conclusion']).map((s) => s.key)).toEqual(['conclusion', null]);
});
});