forked from yvv/decision
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,8 @@ web/src/
|
|||||||
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
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),
|
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
|
||||||
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList,
|
# 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…
|
# TitleBar / TitlePropose (le titre du document), Seal…
|
||||||
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
|
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
|
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é
|
lib/order.ts # PUR : placer, monter / descendre, ce qui a bougé
|
||||||
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
||||||
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
# 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]/decisions · g/[group]/mandats ·
|
||||||
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
# g/[group]/protocoles (à venir) · acces (trustinWallet) · messages (la boîte) ;
|
||||||
# /groupes, /decisions… redirigent
|
# /groupes, /decisions… redirigent
|
||||||
web/e2e/ # Playwright C1–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
|
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
@@ -114,7 +116,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
|||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 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
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e
|
|||||||
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
|
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
|
||||||
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
|
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
|
||||||
|
|
||||||
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22, C25, C26, C28–C31) 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
|
d'essai** (caché, quorum 1 à chaque cran — une voix seule y passe, ADR 0013 —, jamais publié) — jamais sur le texte de la communauté. Au départ, rien
|
||||||
n'y est en vigueur (ADR 0011) : les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24,
|
n'y est en vigueur (ADR 0011) : les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24,
|
||||||
C28 à C31) y font d'abord entrer Raison d'être, Unicité du compte et Réciprocité par le vote (une voix
|
C28 à C31) y font d'abord entrer Raison d'être, Unicité du compte et Réciprocité par le vote (une voix
|
||||||
@@ -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 |
|
| 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 |
|
| 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 » |
|
| 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 » | 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 ») |
|
| 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) |
|
| 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 |
|
| C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille |
|
||||||
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
||||||
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil sous la voûte tient à 412 et à 360 de large (les trois points dans l'ouverture, rien ne déborde) et montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les 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 |
|
| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis |
|
||||||
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
|
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
|
||||||
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
|
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
|
||||||
| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant |
|
| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant |
|
||||||
| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur |
|
| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 41 modules à voter · dont 41 sans ton vote » (33 articles, le titre, l'ordre de 7 sections), puis « dont 40 » ; la ligne ouvre le lecteur |
|
||||||
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification → « Si tout passe », puis EN, puis ES | en tête du texte, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
||||||
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « 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 |
|
| 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, 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 » |
|
| 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 », « 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 |
|
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « N oui sur Q votants pour passer » (le quorum en infobulle), « Il manque … », les crans de l'article, « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
|
||||||
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
|
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
|
||||||
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité (Propositions seed), « Signer et envoyer (2) » ; puis, 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 |
|
| 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 : « 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à |
|
| 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 : 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 |
|
| 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 : « 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 » |
|
| 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. » |
|
| C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude. » |
|
||||||
| C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris |
|
| C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris |
|
||||||
| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste |
|
| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste |
|
||||||
@@ -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 » |
|
| 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 |
|
| C32 | Lâcher ou garder un bulletin | Monnaie libre, un portefeuille dans le navigateur (Eve, chaîne simulée), « Propositions en cours » → Document de révocation : « Contre » le nœud éteint ; puis « Contre », fermer la fenêtre pendant que le portefeuille demande, refuser dans le portefeuille ; puis « Contre », fermer, signer dans le portefeuille ; une autre personne, sans portefeuille : « Contre » → « Passer par Ğecko », fermer, payer depuis un téléphone | nœud éteint : « La chaîne Ğ1 ne répond pas. Réessaie dans un moment. », le bulletin lâché aussitôt, rien « en attente » sous l'article ; fenêtre fermée puis refus : lâché ; fenêtre fermée puis signature : gardé, et la chaîne le confirme sans la page (« Ton vote : contre · inscrit sur la chaîne Ğ1 ») ; Ğecko, fenêtre fermée : « En attente de la chaîne Ğ1 : contre. », puis confirmé sans la page |
|
||||||
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
|
| C33 | Le reçu de ton vote | Monnaie libre, un portefeuille : « Contre » sur Ne jamais certifier seul (« Propositions en cours »), « Télécharger ton reçu », « Tes bulletins » ; sur « Tes bulletins », « Fichier du reçu » : le reçu ; puis coller le reçu avec un choix changé, « Vérifier » ; une autre personne ouvre « Tes bulletins » | le reçu `recu-vote-<date>-<id>.json` : les votes, le sel, la remarque `ld1:…`, « confirmed », l'identité « Alice » ; « Tes bulletins de vote » : « Compté pour l'identité Ğ1 « Alice » » et la remarque ; « Le reçu tient : ses votes et son sel donnent bien ce commentaire. », « Ne jamais certifier seul : contre » ; changé : « Le reçu ne tient pas » ; l'autre personne : « Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1. », et le reçu lui est refusé |
|
||||||
| C34 | Le vote simulé, la chaîne fermée (ADR 0012) | Monnaie libre sur un serveur sans compte des votes (l'automate ferme la chaîne du serveur jetable, puis la rouvre) : « Propositions en cours » → Logiciel auditable → « Contre », sans portefeuille ; puis « 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
|
## 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 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-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 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 ».
|
||||||
@@ -12,8 +12,11 @@ import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } fr
|
|||||||
type Entry = { id: string; status: string; comment: string };
|
type Entry = { id: string; status: string; comment: string };
|
||||||
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
|
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) {
|
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.
|
// 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();
|
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} »` });
|
return page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
|
|||||||
@@ -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 }) });
|
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). */
|
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
|
||||||
export async function shot(page: Page, name: string) {
|
export async function shot(page: Page, name: string) {
|
||||||
const dir = process.env.LD_SHOTS;
|
const dir = process.env.LD_SHOTS;
|
||||||
|
|||||||
+10
-5
@@ -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();
|
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
|
// 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).
|
// in force at the start, the seed proposals open the text — lot U).
|
||||||
await page.locator('ul.deep button.row').first().click();
|
const first = page.locator('section.text article.unit').first();
|
||||||
const fold = page.locator('ul.deep section.fold');
|
const toggle = first.getByRole('button', { name: 'Le vote' });
|
||||||
await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible();
|
// 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.
|
// 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();
|
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||||
const panel = page.locator('aside.panel');
|
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);
|
await expect(panel.locator('table tbody tr')).toHaveCount(5);
|
||||||
const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
|
const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
|
||||||
expect(fits).toBe(true);
|
expect(fits).toBe(true);
|
||||||
|
|||||||
+56
-61
@@ -1,22 +1,18 @@
|
|||||||
import { devices, expect, test, type Browser, type Page } from '@playwright/test';
|
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
|
// 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
|
// order of its articles (inertia 2); at the head of the document, its title (inertia 4).
|
||||||
// the document, its title (inertia 4). Both voted like an article: unfolded, in the
|
// Both voted like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre
|
||||||
// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs).
|
// (simulated here: e2e/chain-mock.mjs). Lot U (Yvv, 2026-11-02): the order is one line at
|
||||||
// The server is shared with the specs that follow: the test group keeps its articles
|
// the end of its section, never the list of its articles again; « Réordonner » puts handles
|
||||||
// and their votes; its conclusion is reordered and its title changes.
|
// 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 K1 = 'Règles abrégées de la toile de confiance';
|
||||||
const K2 = 'Monnaie G1';
|
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. */
|
/** Whether a version of a module of the test group is in force now. */
|
||||||
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
||||||
const g = await (await page.request.get('/api/groups/essai')).json();
|
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
|
* 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
|
* 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
|
* members B^W vanishes and, the quorum being 1, one voice may suffice (ADR 0013). Returns
|
||||||
* number of voices for it.
|
* 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)) {
|
if (await inForce(page, proposalId)) {
|
||||||
await expect(page.getByText(news)).toBeVisible();
|
await expect(page.getByText(news)).toBeVisible();
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const yvv = await person(browser, 'Yvv');
|
const yvv = await person(browser, 'Yvv');
|
||||||
await yvv.goto(TEST_DOC);
|
await yvv.goto(`${TEST_DOC}?vote=${proposalId}`);
|
||||||
await voteFor(yvv, row);
|
await yvv.getByRole('region', { name: fold }).getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(yvv.getByText(news)).toBeVisible();
|
await expect(yvv.getByText(news)).toBeVisible();
|
||||||
return 2;
|
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 signIn(page, 'Chiara');
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
// Each section ends with its order; a section of one article has nothing to decide.
|
await unfold(page);
|
||||||
const modules = page.locator('section.text [role="group"]');
|
// 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(modules).toHaveCount(7);
|
||||||
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
|
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
|
||||||
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
|
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('Ordre de la section');
|
||||||
await expect(order).toContainText('2 articles · inertie 2 / 5 · basse');
|
await expect(order).toContainText('inertie 2 / 5 · basse');
|
||||||
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
|
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 order.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c25-1-ordre-lecture');
|
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
|
// Its vote unfolds under its line, like an article's, the list whole.
|
||||||
// votes is seen in Monnaie libre, below: here one voice may confirm a seed).
|
|
||||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||||
await fond.hover();
|
await fond.hover();
|
||||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
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 expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
||||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||||
|
|
||||||
// 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.hover();
|
||||||
await order.getByRole('button', { name: 'Proposer un autre ordre' }).click();
|
await order.getByRole('button', { name: 'Réordonner' }).click();
|
||||||
await expect(page).toHaveURL(/onglet=proposer/);
|
await expect(page.locator('aside.panel')).toHaveCount(0);
|
||||||
const panel = page.locator('aside.panel');
|
const list = order.getByRole('list', { name: 'Nouvel ordre des articles' });
|
||||||
const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' });
|
|
||||||
const items = list.locator('button.item');
|
const items = list.locator('button.item');
|
||||||
const names = list.locator('button.item .name');
|
const names = list.locator('button.item .name');
|
||||||
await expect(names).toHaveText([K1, K2]);
|
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 expect(submit).toBeDisabled();
|
||||||
await items.nth(1).click();
|
await items.nth(1).click();
|
||||||
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||||
await items.nth(0).click();
|
await items.nth(0).click();
|
||||||
await expect(names).toHaveText([K2, K1]);
|
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();
|
await expect(submit).toBeEnabled();
|
||||||
// With the keyboard: the moved article keeps the focus, the arrows move it.
|
// With the keyboard: the moved article keeps the focus, the arrows move it.
|
||||||
await expect(items.nth(0)).toBeFocused();
|
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 page.keyboard.press('ArrowUp');
|
||||||
await expect(names).toHaveText([K2, K1]);
|
await expect(names).toHaveText([K2, K1]);
|
||||||
// Or its arrows.
|
// Or its arrows.
|
||||||
await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click();
|
await order.getByRole('button', { name: `Descendre « ${K2} »` }).click();
|
||||||
await panel.getByRole('button', { name: `Monter « ${K2} »` }).click();
|
await order.getByRole('button', { name: `Monter « ${K2} »` }).click();
|
||||||
await expect(names).toHaveText([K2, K1]);
|
await expect(names).toHaveText([K2, K1]);
|
||||||
await shot(page, 'c25-2-ordre-propose');
|
await shot(page, 'c25-2-ordre-propose');
|
||||||
await submit.click();
|
await submit.click();
|
||||||
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
|
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
|
||||||
|
|
||||||
// Its vote unfolds in the list of proposals, the list it proposes in sight.
|
// Its vote unfolds at the line, the list it proposes in sight; never a line among the proposals.
|
||||||
const fold = page.locator('ul.deep section.fold');
|
const fold = order.locator('section.fold');
|
||||||
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
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(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]);
|
||||||
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
await expect(order).toContainText('1 ordre proposé');
|
||||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
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-/, '');
|
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||||
await shot(page, 'c25-3-ordre-vote');
|
await shot(page, 'c25-3-ordre-vote');
|
||||||
// Into force (her voice, or a second one): the section reads in its new order.
|
// 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.
|
// In the basket too: at the end of its section.
|
||||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||||
|
const panel = page.locator('aside.panel');
|
||||||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||||||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
// In force now: its vote stays unfolded under its line, the list whole, in the new order.
|
||||||
// In force now: its vote stays unfolded, under the module, the list whole.
|
|
||||||
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
|
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 order.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||||
await expect(order).toContainText(/adopté le/);
|
await expect(order).toContainText(/adopté le/);
|
||||||
|
await unfold(page);
|
||||||
const rows = await page.locator('ul.deep button.row .title').allTextContents();
|
const rows = await page.locator('ul.deep button.row .title').allTextContents();
|
||||||
expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1));
|
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 signIn(page, 'Chiara');
|
||||||
await page.goto(TEST_DOC);
|
await page.goto(TEST_DOC);
|
||||||
const head = page.locator('header.head');
|
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');
|
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 head.hover();
|
||||||
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
|
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
|
||||||
await expect(page).toHaveURL(/onglet=proposer/);
|
await expect(page).toHaveURL(/onglet=proposer/);
|
||||||
const panel = page.locator('aside.panel');
|
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' });
|
const input = panel.getByRole('textbox', { name: 'Nouveau titre' });
|
||||||
await expect(input).toHaveValue("Acte d'engagement Certification");
|
await expect(input).toHaveValue("Acte d'engagement Certification");
|
||||||
const submit = panel.getByRole('button', { name: 'Proposer un autre titre' });
|
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 shot(page, 'c26-1-titre-propose');
|
||||||
await submit.click();
|
await submit.click();
|
||||||
await expect(page.getByText('Titre proposé au vote.')).toBeVisible();
|
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(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-/, '');
|
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
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.
|
// The document changes title; its translations stay editorial, to review.
|
||||||
await page.reload();
|
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');
|
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.
|
// The chain is waited for: the server reads the indexer every 10 seconds.
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
|
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 fond.scrollIntoViewIfNeeded();
|
||||||
await shot(page, 'c25-4-ordre-mobile');
|
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 page.goto(TEST_DOC);
|
||||||
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
|
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
|
||||||
await shot(page, 'c26-4-titre-mobile');
|
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();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ async function openBox(page: Page, unread: number) {
|
|||||||
async function details(page: Page, id: string) {
|
async function details(page: Page, id: string) {
|
||||||
await page.goto(`${TEST_DOC}?onglet=details&version=${id}`);
|
await page.goto(`${TEST_DOC}?onglet=details&version=${id}`);
|
||||||
const panel = page.locator('aside.panel');
|
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;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
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
|
// 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.
|
// 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 === '/');
|
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 signIn(page, 'Chiara');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
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.
|
// Nothing in force (Yvv, 2026-11-02): the page says where to begin, once — the seed proposals,
|
||||||
await expect(page.locator('section.text article.unit')).toHaveCount(0);
|
// 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 en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.")).toBeVisible();
|
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();
|
||||||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
const seeds = page.locator('section.text article.unit');
|
||||||
// One line per article: the 33, E3 and E7 holding their two variants inside.
|
await expect(seeds.locator('h2')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']);
|
||||||
await expect(page.locator('ul.deep button.row')).toHaveCount(33);
|
await expect(seeds.locator('.tag', { hasText: 'Proposition seed' })).toHaveCount(3);
|
||||||
// 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é']);
|
|
||||||
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);
|
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');
|
// At the low level they start at, how far each is from passing.
|
||||||
for (const row of await seeds.all()) await expect(row).toContainText('inertie 2 / 5 · basse');
|
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 }) => {
|
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 toggle.click();
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
const fold = first.locator('section.fold');
|
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 expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
|
||||||
await fold.getByText('La formule du seuil').click();
|
await fold.getByText('La formule du seuil').click();
|
||||||
await expect(fold.getByText(/W — électorat/)).toBeVisible();
|
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']) {
|
for (const name of ['Yvv', 'Chiara']) {
|
||||||
last = await person(browser, name);
|
last = await person(browser, name);
|
||||||
await last.goto(TEST_DOC);
|
await last.goto(TEST_DOC);
|
||||||
|
await unfold(last);
|
||||||
await last.locator('ul.deep button.row', { hasText: title }).click();
|
await last.locator('ul.deep button.row', { hasText: title }).click();
|
||||||
await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||||
await expect(last.getByText('Vote enregistré.')).toBeVisible();
|
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 ».
|
// What is not the vote lives in the panel, tab « Détails ».
|
||||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||||
const panel = page.locator('aside.panel');
|
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();
|
await panel.getByText('Corriger une coquille').click();
|
||||||
const body = panel.getByLabel('Texte', { exact: true });
|
const body = panel.getByLabel('Texte', { exact: true });
|
||||||
// Typographic apostrophes: the letters and the signs stay, only their style changes.
|
// 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 invited.goto(`/invitation#${link.split('#')[1]}`);
|
||||||
await expect(invited).toHaveURL((u) => u.pathname === '/');
|
await expect(invited).toHaveURL((u) => u.pathname === '/');
|
||||||
await invited.goto(DOC);
|
await invited.goto(DOC);
|
||||||
|
await unfold(invited);
|
||||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||||
const fold = invited.locator('section.fold');
|
const fold = invited.locator('section.fold');
|
||||||
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
|
// 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();
|
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
|
||||||
// In the test group, the same person votes, directly.
|
// In the test group, the same person votes, directly.
|
||||||
await invited.goto(TEST_DOC);
|
await invited.goto(TEST_DOC);
|
||||||
|
await unfold(invited);
|
||||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
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').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||||
await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0);
|
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 }) => {
|
test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
|
await unfold(page);
|
||||||
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
||||||
await expect(e3).toHaveCount(1);
|
await expect(e3).toHaveCount(1);
|
||||||
await expect(e3).toContainText('2 versions');
|
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.
|
// A version unfolds its vote below it, as any proposal.
|
||||||
await versions.nth(1).click();
|
await versions.nth(1).click();
|
||||||
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
|
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 ».
|
// Level 3, the quorum 50 (ADR 0013): 39 « oui » pass it, the voters missing counted as « non »
|
||||||
await expect(page.locator('section.fold').getByText('Seuil : 39 voix pour')).toBeVisible();
|
// (« 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 expect(page).toHaveURL(/vote=/);
|
||||||
await e3.click();
|
await e3.click();
|
||||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
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 expect(panel.getByText(/^Quorum : en dessous, les votants qui manquent comptent comme « non »\./)).toBeVisible();
|
||||||
await page.keyboard.press('Escape');
|
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.
|
// From the vote of an article: Unicité du compte, a seed proposal at the head, is at 2 / 5, its row is marked.
|
||||||
await deepRow(page, 'Unicité du compte').click();
|
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 »' });
|
const e1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
|
||||||
await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
|
await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
|
||||||
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
|
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||||
|
|||||||
+31
-15
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
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
|
// 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
|
// 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-expanded', 'true');
|
||||||
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
|
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.
|
// 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();
|
// (the first: its other versions, inside, have theirs)
|
||||||
await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible();
|
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(/^Il manque \d+ voix pour\.$/)).toBeVisible();
|
||||||
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
|
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
|
||||||
await expect(fold.getByText('0 pour · 0 contre')).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 expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
|
||||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||||
|
|
||||||
// Under the seed proposal: her vote, from the chain, and the totals.
|
// Under the seed proposal, at the head of the text: her vote, from the chain, and the totals.
|
||||||
const e1 = deepRow(page, 'Unicité du compte');
|
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();
|
await e1.click();
|
||||||
const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
|
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');
|
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
|
// 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.
|
// by the indexer, never overrides the withdrawal included after it.
|
||||||
const e2 = deepRow(page, 'Réciprocité');
|
const e2 = seed('Réciprocité');
|
||||||
await e2.click();
|
await e2.click();
|
||||||
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
|
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
|
||||||
await control('hide');
|
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.
|
// 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');
|
const other = await person(browser, 'Maëlle');
|
||||||
await other.goto(DOC);
|
await other.goto(DOC);
|
||||||
await deepRow(other, "Raison d'être").click();
|
await other.locator('section.text article.unit', { hasText: "Raison d'être" }).getByRole('button', { name: 'Le vote' }).click();
|
||||||
await other.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).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' });
|
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 expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible();
|
||||||
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
|
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 page.goto(DOC);
|
||||||
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
|
||||||
await page.locator('ul.deep button.row').first().click();
|
await page.locator('section.text article.unit').first().getByRole('button', { name: 'Le vote' }).click();
|
||||||
const fold = page.locator('ul.deep section.fold');
|
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.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);
|
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||||
// The totals and the threshold stay in sight.
|
// The totals and how far it is stay in sight: Raison d'être, level 2, the quorum 12 (ADR 0013) —
|
||||||
// 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.getByText('Seuil : 9 voix pour')).toBeVisible();
|
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 fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
||||||
await shot(page, '6-vote-ferme');
|
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 ».
|
// 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);
|
const before = await version(page, title);
|
||||||
expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true });
|
expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true });
|
||||||
await page.goto(DOC);
|
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.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();
|
await deepRow(page, title).click();
|
||||||
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
|
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
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(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0);
|
||||||
await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/);
|
await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/);
|
||||||
await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible();
|
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);
|
const after = await version(page, title);
|
||||||
expect(after.p.myVoteState).toBe('simulated');
|
expect(after.p.myVoteState).toBe('simulated');
|
||||||
expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1);
|
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.
|
// In her ballots: simulated, counted for her session, never a block of the chain.
|
||||||
await page.goto('/bulletins');
|
await page.goto('/bulletins');
|
||||||
const ballot = page.locator('li.ballot').first();
|
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);
|
await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
// The next controls need the chain.
|
// The next controls need the chain.
|
||||||
|
|||||||
@@ -90,9 +90,10 @@
|
|||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const n = await voteDirect(doc, toSend);
|
const r = await voteDirect(doc, toSend);
|
||||||
basket.clear();
|
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) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
error = errorText(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,8 +116,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<section class="basket" aria-labelledby="basket-title">
|
<!-- Named by the head of the panel (« Panier de vote »): one name, one place. -->
|
||||||
<h3 id="basket-title">{t('basket.title')}</h3>
|
<section class="basket">
|
||||||
<p class="muted">{t('basket.intro')}</p>
|
<p class="muted">{t('basket.intro')}</p>
|
||||||
{#if access && !access.open}
|
{#if access && !access.open}
|
||||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||||
@@ -204,7 +205,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<p class="faint small note-send">
|
<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>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
@@ -216,11 +217,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
h3 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
.closed {
|
.closed {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,52 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
|
// The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
|
||||||
// single version unfolds its vote below it; one with several shows the best of
|
// coup d'œil »): each section folded to one line — its name, how many articles are
|
||||||
// them and unfolds the others, each with its label, its water level and its
|
// proposed, a small water level for each, so one sees where support rises without opening
|
||||||
// marks — and each unfolds its own vote.
|
// 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 type { Snippet } from 'svelte';
|
||||||
import { plain } from '$lib/markdown';
|
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 { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import type { Proposal, Unit } from '$lib/types';
|
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 Icon from './Icon.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
import WaterLevel from './WaterLevel.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
articles,
|
sections,
|
||||||
vote,
|
vote,
|
||||||
|
reordering = null,
|
||||||
|
all = false,
|
||||||
ontoggle,
|
ontoggle,
|
||||||
fold,
|
fold,
|
||||||
|
order,
|
||||||
onsection,
|
onsection,
|
||||||
nameOf = (id) => id,
|
|
||||||
}: {
|
}: {
|
||||||
/** Articles and modules (ADR 0009) with open proposals, in document order. */
|
/** The articles with open proposals, by section, each with its order when it closes here. */
|
||||||
articles: Article[];
|
sections: DeepSection[];
|
||||||
/** The version whose vote is unfolded. */
|
/** The version whose vote is unfolded. */
|
||||||
vote: string | null;
|
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;
|
ontoggle: (unit: Unit, p: Proposal) => void;
|
||||||
fold: Snippet<[Unit, Proposal]>;
|
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. */
|
/** Opens the basket on a section; absent when this session does not vote here. */
|
||||||
onsection?: (section: string) => void;
|
onsection?: (section: string) => void;
|
||||||
/** The title of an article, for the orders proposed. */
|
|
||||||
nameOf?: (unitId: string) => string;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/** What a line shows of a version: the start of its text, or the order it proposes. */
|
const keyOf = (s: DeepSection) => s.key ?? '';
|
||||||
function excerptOf(p: Proposal): string {
|
// Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
|
||||||
if (p.kind === 'text') return plain(p.body);
|
let unfolded = $state<Record<string, boolean>>({});
|
||||||
if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · ');
|
let versionsOpen = $state<Record<string, boolean>>({});
|
||||||
return '';
|
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.
|
const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
|
||||||
let unfolded = $state<Record<string, boolean>>({});
|
const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
|
||||||
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));
|
|
||||||
|
|
||||||
/** The title of the article: its best text version, or what the best proposal is. */
|
/** The title of the article: its best text version, or what the best proposal is. */
|
||||||
function headline(a: Article): string {
|
function headline(a: Article): string {
|
||||||
if (a.unit.kind) return moduleName(a.unit);
|
|
||||||
const text = a.versions.find((p) => p.kind === 'text');
|
const text = a.versions.find((p) => p.kind === 'text');
|
||||||
const best = a.versions[0];
|
const best = a.versions[0];
|
||||||
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
|
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 textual = a.versions.filter((p) => p.kind === 'text');
|
||||||
const told = excerpts(textual.map((p) => plain(p.body)));
|
const told = excerpts(textual.map((p) => plain(p.body)));
|
||||||
return new Map([
|
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)]),
|
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet module(p: Proposal)}
|
{#snippet need(p: Proposal)}
|
||||||
{#if p.kind === 'title'}<span class="faint">{t('module.title.name')}</span>{/if}
|
{@const n = needOf(p)}
|
||||||
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
|
{#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet marks(p: Proposal)}
|
{#snippet marks(p: Proposal)}
|
||||||
@@ -85,97 +100,223 @@
|
|||||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||||
{/snippet}
|
{/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">
|
<ul class="deep">
|
||||||
{#each articles as a, i (a.unit.id)}
|
{#each sections as s, i (keyOf(s))}
|
||||||
{@const unit = a.unit}
|
{@const open = isOpen(s)}
|
||||||
{@const best = a.versions[0]}
|
{@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))}
|
||||||
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
|
<li class="section" class:open>
|
||||||
<li class="section">
|
<div class="head">
|
||||||
<span class="eyebrow">{sectionLabel(unit.section)}</span>
|
<button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
|
||||||
{#if onsection && unit.section}
|
<span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
|
||||||
{@const key = unit.section}
|
<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>
|
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</div>
|
||||||
{/if}
|
{#if open}
|
||||||
{#if best && a.versions.length === 1}
|
<div class="body" id="deep-sec-{i}">
|
||||||
<li class="article" class:open={vote === best.id}>
|
{#if s.order?.id !== reordering}
|
||||||
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
|
<ul class="rows">
|
||||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
{#each s.articles as a (a.unit.id)}
|
||||||
<span class="main">
|
{@const unit = a.unit}
|
||||||
<span class="title">{titleOf(best, unit)}</span>
|
{@const best = a.versions[0]}
|
||||||
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
|
{#if best && a.versions.length === 1}
|
||||||
<span class="meta">
|
<li class="article" class:open={vote === best.id}>
|
||||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
|
||||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||||
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
|
<span class="main">
|
||||||
{@render module(best)}
|
<span class="title">{titleOf(best, unit)}</span>
|
||||||
{@render marks(best)}
|
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||||
</span>
|
<span class="meta">
|
||||||
</span>
|
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||||
</button>
|
{@render need(best)}
|
||||||
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
|
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||||
</li>
|
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
|
||||||
{:else if best}
|
{@render marks(best)}
|
||||||
{@const open = isOpen(a)}
|
</span>
|
||||||
{@const title = headline(a)}
|
</span>
|
||||||
{@const told = toldApart(a)}
|
</button>
|
||||||
<li class="article" class:open>
|
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
|
||||||
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}>
|
</li>
|
||||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
{:else if best}
|
||||||
<span class="main">
|
{@const vopen = isVersionsOpen(a)}
|
||||||
<span class="title">{title}</span>
|
{@const title = headline(a)}
|
||||||
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
|
{@const told = toldApart(a)}
|
||||||
<span class="meta">
|
<li class="article" class:open={vopen}>
|
||||||
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
<button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
|
||||||
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
<span class="main">
|
||||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
<span class="title">{title}</span>
|
||||||
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
|
{#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</span>{/if}
|
||||||
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
<span class="meta">
|
||||||
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
||||||
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
|
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
||||||
</span>
|
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||||
</span>
|
{@render need(best)}
|
||||||
<span class="chevron"><Icon name="chevron" size={18} /></span>
|
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||||
</button>
|
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
|
||||||
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||||
{#each a.versions as p (p.id)}
|
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||||
<li>
|
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||||
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
|
</span>
|
||||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
</span>
|
||||||
<span class="main">
|
<span class="chevron"><Icon name="chevron" size={18} /></span>
|
||||||
<span class="label">{labelOf(p, a)}{#if (p.kind === 'text' || p.kind === 'title') && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
</button>
|
||||||
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
<ul class="versions" id="versions-{unit.id}" hidden={!vopen}>
|
||||||
<span class="meta">
|
{#each a.versions as p (p.id)}
|
||||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
<li>
|
||||||
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
|
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
|
||||||
{@render marks(p)}
|
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||||
</span>
|
<span class="main">
|
||||||
</span>
|
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||||
</button>
|
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
||||||
{#if vote === p.id}{@render fold(unit, p)}{/if}
|
<span class="meta">
|
||||||
</li>
|
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||||
{/each}
|
{@render need(p)}
|
||||||
</ul>
|
{@render marks(p)}
|
||||||
</li>
|
</span>
|
||||||
{/if}
|
</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}
|
{:else}
|
||||||
<li class="faint none">{t('deep.none')}</li>
|
<li class="faint none">{t('deep.none')}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<style>
|
<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;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.article {
|
.section {
|
||||||
margin: 0 -1rem;
|
margin: 0 -1rem;
|
||||||
border-radius: var(--r-card);
|
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;
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
.article.open {
|
.article.open {
|
||||||
@@ -225,17 +366,17 @@
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.count {
|
.need {
|
||||||
color: var(--water);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.chevron {
|
.row .chevron {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
transition: transform 0.2s var(--ease);
|
transition: transform 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
.open .chevron {
|
.article.open .row .chevron {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
.versions {
|
.versions {
|
||||||
@@ -272,40 +413,39 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink-soft);
|
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 {
|
.fold {
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 1rem;
|
||||||
}
|
}
|
||||||
.section:first-child {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
.none {
|
.none {
|
||||||
padding: 0.8rem 0;
|
padding: 0.8rem 0;
|
||||||
}
|
}
|
||||||
|
@keyframes unfold {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.versions {
|
.versions {
|
||||||
padding-left: 0.6rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The side panel: the basket of the vote, the versions of an article, a new
|
// The side panel, one thing at a time (lot U, UX-lecteur.md): what is written, or works
|
||||||
// proposal, the details of a version, the settings of the formula. The vote of
|
// over several modules, or is read beside the text — the basket of the vote, a new
|
||||||
// one version unfolds under its article, not here.
|
// 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 type { DocState, PanelState } from '$lib/doc.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { relationOf, titleOf } from '$lib/labels';
|
|
||||||
import { answerPanel } from '$lib/objections';
|
import { answerPanel } from '$lib/objections';
|
||||||
import type { BasketState } from '$lib/picks.svelte';
|
import type { BasketState } from '$lib/picks.svelte';
|
||||||
import { session } from '$lib/session.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 type { Line } from '$lib/vote';
|
||||||
import BallotPanel from './BallotPanel.svelte';
|
import BallotPanel from './BallotPanel.svelte';
|
||||||
import FormulaSettings from './FormulaSettings.svelte';
|
import FormulaSettings from './FormulaSettings.svelte';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import OrderEditor from './OrderEditor.svelte';
|
|
||||||
import ProposeView from './ProposeView.svelte';
|
import ProposeView from './ProposeView.svelte';
|
||||||
import TitlePropose from './TitlePropose.svelte';
|
import TitlePropose from './TitlePropose.svelte';
|
||||||
import VersionDetails from './VersionDetails.svelte';
|
import VersionDetails from './VersionDetails.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
doc,
|
doc,
|
||||||
panel,
|
panel,
|
||||||
lang,
|
lang,
|
||||||
basket,
|
basket,
|
||||||
vote,
|
|
||||||
onchange,
|
onchange,
|
||||||
onclose,
|
onclose,
|
||||||
onvote,
|
onvote,
|
||||||
@@ -34,61 +32,41 @@
|
|||||||
panel: PanelState;
|
panel: PanelState;
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
basket: BasketState;
|
basket: BasketState;
|
||||||
/** The version whose vote is unfolded under its article. */
|
|
||||||
vote: string | null;
|
|
||||||
onchange: (p: PanelState) => void;
|
onchange: (p: PanelState) => void;
|
||||||
onclose: () => 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;
|
onvote: (proposalId: string, close: boolean) => void;
|
||||||
onsign: (lines: Line[]) => void;
|
onsign: (lines: Line[]) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const unit = $derived(doc.unit(panel.unitId));
|
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 voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
|
||||||
const tabs = $derived(
|
/** One name per function, the same one that opened it. */
|
||||||
(
|
const heading = $derived(
|
||||||
[
|
panel.tab === 'basket'
|
||||||
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
|
? t('basket.title')
|
||||||
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
|
: panel.tab === 'settings'
|
||||||
['propose', t('panel.propose'), true],
|
? t('settings.title')
|
||||||
// A version: reached from its vote, or from the list of versions.
|
: panel.tab === 'details'
|
||||||
['details', t('panel.details'), !!panel.proposalId],
|
? t('panel.details')
|
||||||
// Reached from the head of the document or from a vote; shown while it is open.
|
: unit?.kind === 'title'
|
||||||
['settings', t('panel.settings'), panel.tab === 'settings'],
|
? t('title.propose')
|
||||||
] as const
|
: panel.unitId
|
||||||
).filter(([, , show]) => show),
|
? t('doc.action.propose')
|
||||||
|
: t('deep.new'),
|
||||||
);
|
);
|
||||||
let body: HTMLElement | undefined = $state();
|
let body: HTMLElement | undefined = $state();
|
||||||
let tablist: HTMLElement | undefined = $state();
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void panel.tab;
|
void panel.tab;
|
||||||
void panel.proposalId;
|
void panel.proposalId;
|
||||||
body?.scrollTo({ top: 0 });
|
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>
|
</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="bar">
|
||||||
<div class="tabs" role="tablist" bind:this={tablist}>
|
<h2 id="panel-title">{heading}</h2>
|
||||||
{#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>
|
|
||||||
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,24 +86,6 @@
|
|||||||
if (next) onchange(next);
|
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'}
|
{:else if panel.tab === 'propose' && unit?.kind === 'title'}
|
||||||
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
|
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
|
||||||
{:else if panel.tab === 'propose'}
|
{:else if panel.tab === 'propose'}
|
||||||
@@ -139,7 +99,9 @@
|
|||||||
ondone={(r) =>
|
ondone={(r) =>
|
||||||
r.proposalId && r.submitted
|
r.proposalId && r.submitted
|
||||||
? onvote(r.proposalId, true)
|
? 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'}
|
{:else if panel.tab === 'settings'}
|
||||||
<FormulaSettings {doc} proposalId={voteId} />
|
<FormulaSettings {doc} proposalId={voteId} />
|
||||||
@@ -165,38 +127,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
|
padding: 0.9rem 0.7rem 0.4rem 1.2rem;
|
||||||
}
|
}
|
||||||
.tabs {
|
h2 {
|
||||||
display: flex;
|
font-family: var(--font-text);
|
||||||
gap: 0.2rem;
|
font-weight: 500;
|
||||||
background: var(--surface-2);
|
font-size: 1.35rem;
|
||||||
padding: 3px;
|
line-height: 1.2;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
.close {
|
.close {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 2.25rem;
|
width: 2.75rem;
|
||||||
height: 2.25rem;
|
height: 2.75rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--r-icon);
|
border-radius: var(--r-icon);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -211,41 +155,6 @@
|
|||||||
padding: 0.6rem 1.2rem 1.6rem;
|
padding: 0.6rem 1.2rem 1.6rem;
|
||||||
overscroll-behavior: contain;
|
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 {
|
@keyframes enter {
|
||||||
from {
|
from {
|
||||||
transform: translateX(16px);
|
transform: translateX(16px);
|
||||||
@@ -270,4 +179,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if view && p}
|
{#if view && p}
|
||||||
<section class="settings" aria-labelledby="settings-title">
|
<!-- Named by the head of the panel (« Réglages de la formule »): one name, one place. -->
|
||||||
<h3 id="settings-title">{t('settings.title')}</h3>
|
<section class="settings">
|
||||||
<p class="muted">{t('settings.intro')}</p>
|
<p class="muted">{t('settings.intro')}</p>
|
||||||
|
|
||||||
<ul class="values">
|
<ul class="values">
|
||||||
@@ -113,11 +113,6 @@
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
h3 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A few line icons, drawn here rather than pulled from a library.
|
// 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>
|
</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">
|
<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" />
|
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
|
||||||
{:else if name === 'plus'}
|
{:else if name === 'plus'}
|
||||||
<path d="M12 5v14M5 12h14" />
|
<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'}
|
{: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" />
|
<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}
|
{/if}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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> {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>
|
|
||||||
@@ -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>
|
||||||
+136
-24
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Another order for a section, with a list one clicks (Yvv, 2026-10-30: « une
|
// Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
|
||||||
// simple liste clipable »): an article, then its new place — or its arrows. With
|
// "poignées" aux modules de la section. Et permette de voter. »): the articles of the
|
||||||
// the keyboard: Enter chooses an article, the up and down arrows move it. Then
|
// section fold to their titles, a handle before each. One drags a handle (mouse, pen or
|
||||||
// « Proposer cet ordre »: the order goes to the vote, like an article (ADR 0009).
|
// 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 { tick, untrack } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
@@ -27,13 +31,14 @@
|
|||||||
unit: Unit;
|
unit: Unit;
|
||||||
/** The title of an article, in the language it is read in. */
|
/** The title of an article, in the language it is read in. */
|
||||||
nameOf: (unitId: string) => string;
|
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;
|
ondone: (proposalId: string) => void;
|
||||||
oncancel?: () => void;
|
oncancel: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let list = $state<string[]>([]);
|
let list = $state<string[]>([]);
|
||||||
let chosen = $state<string | null>(null);
|
let chosen = $state<string | null>(null);
|
||||||
|
let dragging = $state<string | null>(null);
|
||||||
let said = $state('');
|
let said = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -41,6 +46,12 @@
|
|||||||
const start = $derived(unit.members ?? []);
|
const start = $derived(unit.members ?? []);
|
||||||
const changed = $derived(moved(start, list));
|
const changed = $derived(moved(start, list));
|
||||||
const unchanged = $derived(sameOrder(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;
|
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.
|
// 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() {
|
async function submit() {
|
||||||
if (unchanged) {
|
if (unchanged) {
|
||||||
error = t('order.edit.unchanged');
|
error = t('order.edit.unchanged');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (existing) {
|
||||||
|
ondone(existing.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
@@ -119,15 +164,21 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor">
|
<div class="reorder">
|
||||||
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
|
<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}>
|
<ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
|
||||||
{#each list as id, i (id)}
|
{#each list as id, i (id)}
|
||||||
{@const name = nameOf(id)}
|
{@const name = nameOf(id)}
|
||||||
<li class:chosen={chosen === id} class:moved={changed.has(id)} animate:flip={{ duration: reduce ? 0 : 160 }}>
|
<li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
|
||||||
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
|
<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="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>
|
</button>
|
||||||
<span class="arrows">
|
<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>
|
<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}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
<p class="visually-hidden" aria-live="polite">{said}</p>
|
<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('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('module.order.rule')}</p>
|
||||||
<p class="faint small">{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}
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if oncancel}<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>{/if}
|
<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('order.edit.submit')}</button>
|
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.editor {
|
.reorder {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
animation: unfold 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -162,26 +214,51 @@
|
|||||||
.list li {
|
.list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.2rem;
|
||||||
border-radius: var(--r-input);
|
border-radius: var(--r-input);
|
||||||
background: var(--surface-2);
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
transition: background-color 0.15s var(--ease);
|
transition: background-color 0.15s var(--ease);
|
||||||
}
|
}
|
||||||
.list li.moved {
|
.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);
|
background: var(--water);
|
||||||
color: var(--on-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 {
|
.item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.6rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
padding: 0.5rem 0.4rem 0.5rem 0.8rem;
|
padding: 0.45rem 0.3rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--r-input);
|
border-radius: var(--r-input);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -191,18 +268,32 @@
|
|||||||
}
|
}
|
||||||
.n {
|
.n {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-width: 1.4rem;
|
min-width: 1.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--water);
|
color: var(--water);
|
||||||
}
|
}
|
||||||
.chosen .n {
|
.chosen .n,
|
||||||
|
.dragging .n {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.what {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.name {
|
.name {
|
||||||
font-weight: 700;
|
font-family: var(--font-text);
|
||||||
|
font-weight: 600;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.chosen .tag,
|
||||||
|
.dragging .tag {
|
||||||
|
background: color-mix(in srgb, var(--on-water) 22%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
.arrows {
|
.arrows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -234,6 +325,11 @@
|
|||||||
.small {
|
.small {
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
.note {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
.warn {
|
.warn {
|
||||||
color: var(--ochre);
|
color: var(--ochre);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -245,7 +341,23 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
justify-content: flex-end;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reorder {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
.list li {
|
.list li {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,60 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The title of the document is a module (ADR 0009, inertia 4): under the title,
|
// The title of the document is a module (ADR 0009, inertia 4): one line under the title,
|
||||||
// the gestures of an article — the vote, the proposals, another 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 type { Snippet } from 'svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import { inertiaBadge, moduleName } from '$lib/labels';
|
import { moduleName } from '$lib/labels';
|
||||||
import type { Unit } from '$lib/types';
|
import type { Proposal, Unit } from '$lib/types';
|
||||||
|
import Crans from './Crans.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
unit,
|
unit,
|
||||||
stale = false,
|
stale = false,
|
||||||
selected = false,
|
selected = false,
|
||||||
voteOpen = false,
|
vote,
|
||||||
fold,
|
fold,
|
||||||
onaction,
|
onvote,
|
||||||
|
onpropose,
|
||||||
}: {
|
}: {
|
||||||
/** The title module of the document. */
|
/** The title module of the document. */
|
||||||
unit: Unit;
|
unit: Unit;
|
||||||
/** The title read is a translation of an older title. */
|
/** The title read is a translation of an older title. */
|
||||||
stale?: boolean;
|
stale?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
voteOpen?: boolean;
|
/** The version whose vote is unfolded, wherever it lives. */
|
||||||
fold?: Snippet;
|
vote: string | null;
|
||||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
fold: Snippet<[Proposal]>;
|
||||||
|
onvote: (proposalId: string) => void;
|
||||||
|
onpropose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const p = $derived(unit.inForce);
|
const p = $derived(unit.inForce);
|
||||||
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
|
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
|
||||||
const 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>
|
</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">
|
<p class="bar">
|
||||||
<span class="status faint">
|
<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>
|
{#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}
|
{:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<Crans level={unit.inertia} />
|
||||||
{#if stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{/if}
|
{#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">
|
<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>
|
{#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={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
<button type="button" onclick={onpropose}>{t('title.propose')}</button>
|
||||||
<button type="button" onclick={() => onaction('propose')}>{t('title.propose')}</button>
|
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{#if voteOpen && fold}<div class="fold">{@render fold()}</div>{/if}
|
{#if shown}<div class="fold">{@render fold(shown)}</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -65,11 +76,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem 0.8rem;
|
gap: 0.3rem 0.8rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
font-weight: 700;
|
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 {
|
.links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -105,6 +128,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.links button,
|
||||||
|
.proposed {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.title-unit,
|
.title-unit,
|
||||||
.links {
|
.links {
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
<script lang="ts">
|
<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 { Snippet } from 'svelte';
|
||||||
import type { Shown } from '$lib/doc.svelte';
|
import type { Shown } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.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 Prose from './Prose.svelte';
|
||||||
|
import WaterLevel from './WaterLevel.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
text,
|
text,
|
||||||
mark = 'unchanged',
|
mark = 'unchanged',
|
||||||
seedMinimal = false,
|
seedMinimal = false,
|
||||||
|
seed = null,
|
||||||
enteredAt = null,
|
enteredAt = null,
|
||||||
rivals = 0,
|
rivals = 0,
|
||||||
selected = false,
|
selected = false,
|
||||||
@@ -15,17 +25,17 @@
|
|||||||
voteFor = null,
|
voteFor = null,
|
||||||
voteOpen = false,
|
voteOpen = false,
|
||||||
votes = 0,
|
votes = 0,
|
||||||
module = false,
|
|
||||||
proposeLabel,
|
proposeLabel,
|
||||||
meta,
|
|
||||||
content,
|
|
||||||
fold,
|
fold,
|
||||||
onaction,
|
onaction,
|
||||||
}: {
|
}: {
|
||||||
text: Shown;
|
text: Shown;
|
||||||
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
|
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
|
||||||
seedMinimal?: boolean;
|
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;
|
enteredAt?: string | null;
|
||||||
|
/** Its other versions open to the vote: said in its state, read in « Le vote ». */
|
||||||
rivals?: number;
|
rivals?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
actions?: boolean;
|
actions?: boolean;
|
||||||
@@ -34,51 +44,46 @@
|
|||||||
voteOpen?: boolean;
|
voteOpen?: boolean;
|
||||||
/** The votes on the version in force, shown next to the seed mark. */
|
/** The votes on the version in force, shown next to the seed mark. */
|
||||||
votes?: number;
|
votes?: number;
|
||||||
/** A module (ADR 0009): quieter than an article, its own words to propose. */
|
|
||||||
module?: boolean;
|
|
||||||
proposeLabel?: string;
|
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. */
|
/** The vote, unfolded under the text. */
|
||||||
fold?: Snippet;
|
fold?: Snippet;
|
||||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
onaction: (action: 'vote' | 'propose') => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- A module is not an article of the text: a group named by its title. -->
|
<article
|
||||||
<svelte:element
|
|
||||||
this={module ? 'div' : 'article'}
|
|
||||||
class="unit {mark}"
|
class="unit {mark}"
|
||||||
class:module
|
|
||||||
class:selected
|
class:selected
|
||||||
|
class:seed={!!seed}
|
||||||
class:unfolded={voteOpen}
|
class:unfolded={voteOpen}
|
||||||
role={module ? 'group' : undefined}
|
|
||||||
aria-label={module ? text.title : undefined}
|
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
|
{#if seed}<WaterLevel tally={seed.tally} evaluation={seed.evaluation} blocked={seed.blocked} />{/if}
|
||||||
<h2 class="eyebrow">{text.title}</h2>
|
<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 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}
|
{#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>
|
</header>
|
||||||
{#if content}{@render content()}{:else}<Prose text={text.body} />{/if}
|
<Prose text={text.body} />
|
||||||
{#if actions}
|
{#if actions}
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
<span class="status faint">
|
<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}
|
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||||
|
{#if rivals > 0} · {i18n.plural('doc.others', rivals)}{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="links">
|
<span class="links">
|
||||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
|
||||||
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
|
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
{#if voteOpen && fold}{@render fold()}{/if}
|
{#if voteOpen && fold}{@render fold()}{/if}
|
||||||
</svelte:element>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.unit {
|
.unit {
|
||||||
@@ -98,18 +103,12 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
/* A module closes its section: set apart, quieter than the articles. */
|
/* A seed proposal: the water of a vote under way, never the bare page of a text in force. */
|
||||||
.unit.module {
|
.unit.seed {
|
||||||
margin-top: 0.9rem;
|
background: color-mix(in srgb, var(--water-wash) 55%, transparent);
|
||||||
margin-bottom: 1.4rem;
|
|
||||||
padding-block: 0.85rem 0.6rem;
|
|
||||||
background: var(--water-wash);
|
|
||||||
}
|
}
|
||||||
.unit.module:hover,
|
.unit.seed :global(.prose) {
|
||||||
.unit.module:focus-within,
|
color: var(--ink-soft);
|
||||||
.unit.module.selected,
|
|
||||||
.unit.module.unfolded {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -127,6 +126,9 @@
|
|||||||
.replaced {
|
.replaced {
|
||||||
background: var(--moss-wash);
|
background: var(--moss-wash);
|
||||||
}
|
}
|
||||||
|
.crans {
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -168,4 +170,15 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.links button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.unit,
|
||||||
|
.links {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,22 +5,26 @@
|
|||||||
// never who voted what —, how far the threshold is, your vote and where it
|
// 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
|
// 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
|
// 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
|
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). Lot U: how
|
||||||
// rest of a version (typos, translations, objections, history) lives in the
|
// many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
|
||||||
// panel, tab « Détails ».
|
// 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 { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { DocState } from '$lib/doc.svelte';
|
import type { DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.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 { holding } from '$lib/objections';
|
||||||
import { moved } from '$lib/order';
|
import { moved } from '$lib/order';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import { toasts } from '$lib/toast.svelte';
|
import { toasts } from '$lib/toast.svelte';
|
||||||
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
|
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
|
||||||
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||||
|
import Crans from './Crans.svelte';
|
||||||
import Formula from './Formula.svelte';
|
import Formula from './Formula.svelte';
|
||||||
import InertiaPicker from './InertiaPicker.svelte';
|
import InertiaPicker from './InertiaPicker.svelte';
|
||||||
|
import QuorumSetting from './QuorumSetting.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
import WaterLevel from './WaterLevel.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -36,7 +40,7 @@
|
|||||||
doc: DocState;
|
doc: DocState;
|
||||||
proposal: Proposal;
|
proposal: Proposal;
|
||||||
unit: Unit;
|
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;
|
onopen: (proposalId: string) => void;
|
||||||
onsettings: (proposalId: string) => void;
|
onsettings: (proposalId: string) => void;
|
||||||
ondetails: (proposalId: string) => void;
|
ondetails: (proposalId: string) => void;
|
||||||
@@ -54,6 +58,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
|
/** « Autres versions », folded until asked: the vote stays short. */
|
||||||
|
let othersOpen = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let root: HTMLElement | undefined = $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 shifted = $derived(order && p.status !== 'in_force' ? moved(unit.members ?? [], order.ids) : new Set<string>());
|
||||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||||
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
|
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
|
||||||
|
/** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
const id = p.id;
|
const id = p.id;
|
||||||
@@ -119,8 +145,9 @@
|
|||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await voteDirect(doc, lines);
|
const r = await voteDirect(doc, lines);
|
||||||
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
// A simulated vote says so, never « enregistré » (ADR 0012).
|
||||||
|
toasts.show(t(!choice ? 'vote.retracted' : r.simulated ? 'vote.saved.simulated' : 'vote.saved'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
error = errorText(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -135,10 +162,10 @@
|
|||||||
<div class="numbers">
|
<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>
|
<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}
|
{#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>
|
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
<p class="faint small"><Crans level={p.votedAtInertia} /></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if p.blocked}
|
{#if p.blocked}
|
||||||
@@ -223,7 +250,35 @@
|
|||||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||||
|
|
||||||
<div class="more">
|
<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 p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
|
||||||
|
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if}
|
||||||
{#if detail && view}
|
{#if detail && view}
|
||||||
<Formula
|
<Formula
|
||||||
electorate={detail.electorate}
|
electorate={detail.electorate}
|
||||||
@@ -269,6 +324,72 @@
|
|||||||
.state {
|
.state {
|
||||||
font-weight: 700;
|
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 {
|
.order {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.6rem;
|
padding-left: 1.6rem;
|
||||||
@@ -399,5 +520,8 @@
|
|||||||
.dot {
|
.dot {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.tri {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
evaluation,
|
evaluation,
|
||||||
blocked = false,
|
blocked = false,
|
||||||
size = 'row',
|
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(
|
const ratio = $derived(
|
||||||
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0,
|
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);
|
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
|
||||||
overflow: hidden;
|
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 {
|
.level.large {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 5.5rem;
|
height: 5.5rem;
|
||||||
@@ -58,4 +64,9 @@
|
|||||||
.blocked {
|
.blocked {
|
||||||
box-shadow: inset 0 0 0 2px var(--ochre);
|
box-shadow: inset 0 0 0 2px var(--ochre);
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.water {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+51
-17
@@ -8,8 +8,13 @@ import type { GroupView } from './group';
|
|||||||
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
||||||
|
|
||||||
export type View = 'inforce' | 'wellrated' | 'all';
|
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 {
|
export interface PanelState {
|
||||||
unitId: string | null;
|
unitId: string | null;
|
||||||
@@ -25,9 +30,9 @@ export interface PanelState {
|
|||||||
edit?: boolean;
|
edit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tabs as the URL names them. */
|
/** The panel as the URL names it (`?onglet=`); an old `onglet=propositions` unfolds the vote instead. */
|
||||||
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
export const TABS: Record<string, Tab> = { panier: 'basket', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||||
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
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. */
|
/** The query of a reader's URL: the unfolded vote and the panel. */
|
||||||
export function panelSearch(panel: PanelState | null, vote: string | null): string {
|
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 };
|
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 {
|
export class DocState {
|
||||||
view = $state<DocView | null>(null);
|
view = $state<DocView | null>(null);
|
||||||
error = $state<string | 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
|
* The seed proposals (ADR 0011), read at the head of the text while no article is in
|
||||||
* where no article is in force yet, as long as it has something to decide.
|
* force: one knows where to begin. Never once an article is in force.
|
||||||
*/
|
*/
|
||||||
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] {
|
get seedHead(): Entry[] {
|
||||||
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = [];
|
if (this.inForce.length) return [];
|
||||||
for (const u of this.view?.units ?? []) {
|
const out: Entry[] = [];
|
||||||
let last = out.at(-1);
|
for (const unit of this.view?.units ?? []) {
|
||||||
if (!last || last.section !== u.section) {
|
const proposal = unit.proposals.find((p) => p.status === 'open' && p.seedProposal);
|
||||||
last = { section: u.section, units: [], order: this.orderOf(u.section) };
|
if (proposal) out.push({ unit, proposal, seed: true });
|
||||||
out.push(last);
|
|
||||||
}
|
|
||||||
if (u.inForce) last.units.push(u);
|
|
||||||
}
|
}
|
||||||
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. */
|
/** Open proposals, of articles and modules, in document order; drafts apart. */
|
||||||
|
|||||||
+50
-12
@@ -128,11 +128,13 @@ export const en: Messages = {
|
|||||||
'doc.confirmed': 'confirmed by vote',
|
'doc.confirmed': 'confirmed by vote',
|
||||||
'doc.adopted': 'adopted on {date}',
|
'doc.adopted': 'adopted on {date}',
|
||||||
'doc.action.vote': 'Voting',
|
'doc.action.vote': 'Voting',
|
||||||
'doc.action.proposals': 'Proposals',
|
|
||||||
'doc.action.propose': 'Propose a change',
|
'doc.action.propose': 'Propose a change',
|
||||||
'doc.basket': 'Vote in bulk',
|
'doc.basket': 'Vote in bulk',
|
||||||
'doc.basket.count': 'Vote in bulk · {n}',
|
'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': '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.published': 'Published to the text repository',
|
||||||
'doc.publish.pending': 'Publication pending',
|
'doc.publish.pending': 'Publication pending',
|
||||||
|
|
||||||
@@ -158,6 +160,11 @@ export const en: Messages = {
|
|||||||
'deep.versions.other': '{n} versions',
|
'deep.versions.other': '{n} versions',
|
||||||
'deep.version': 'version {n}',
|
'deep.version': 'version {n}',
|
||||||
'deep.lead': 'leading: {label}',
|
'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.introduction': 'Introduction',
|
||||||
'section.fondamental': 'Fundamental commitments',
|
'section.fondamental': 'Fundamental commitments',
|
||||||
'section.technique': 'Technical commitments',
|
'section.technique': 'Technical commitments',
|
||||||
@@ -169,8 +176,6 @@ export const en: Messages = {
|
|||||||
'module.title.name': 'Title of the document',
|
'module.title.name': 'Title of the document',
|
||||||
'module.order.name': 'Order of the articles',
|
'module.order.name': 'Order of the articles',
|
||||||
'module.order.named': 'Order of the articles · {section}',
|
'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.inforce': 'Order in force',
|
||||||
'module.order.proposal': 'Order proposed',
|
'module.order.proposal': 'Order proposed',
|
||||||
'module.order.added.one': '{n} article added since, placed at the end',
|
'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.dropped.other': '{n} articles gone since',
|
||||||
'module.order.same': 'it is the order in force',
|
'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.',
|
'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.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.up': 'Move “{title}” up',
|
||||||
'order.edit.down': 'Move “{title}” down',
|
'order.edit.down': 'Move “{title}” down',
|
||||||
'order.edit.chosen': '“{title}” chosen: click its new place.',
|
'order.edit.chosen': '“{title}” chosen: click its new place.',
|
||||||
'order.edit.moved': '“{title}” now in place {n} of {total}.',
|
'order.edit.moved': '“{title}” now in place {n} of {total}.',
|
||||||
'order.edit.submit': 'Propose this order',
|
'order.edit.submit': 'Propose this order',
|
||||||
'order.edit.unchanged': 'This is the order in force: move at least one article.',
|
'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.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.',
|
'order.submitted': 'Order submitted to vote.',
|
||||||
'title.propose': 'Propose another title',
|
'title.propose': 'Propose another title',
|
||||||
'title.propose.label': 'New 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.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.submitted': 'Title submitted to vote.',
|
||||||
|
'title.proposed.one': '{n} title proposed',
|
||||||
|
'title.proposed.other': '{n} titles proposed',
|
||||||
|
|
||||||
'tally.short': '{for} for · {against} against',
|
'tally.short': '{for} for · {against} against',
|
||||||
'tally.required': 'Threshold: {n} votes in favor',
|
|
||||||
'tally.missing.one': '{n} more vote in favor needed.',
|
'tally.missing.one': '{n} more vote in favor needed.',
|
||||||
'tally.missing.other': '{n} more votes in favor needed.',
|
'tally.missing.other': '{n} more votes in favor needed.',
|
||||||
'tally.reached': 'Threshold reached.',
|
'tally.reached': 'Threshold reached.',
|
||||||
@@ -210,11 +222,7 @@ export const en: Messages = {
|
|||||||
'inertia.badge': 'inertia {level}',
|
'inertia.badge': 'inertia {level}',
|
||||||
|
|
||||||
|
|
||||||
'panel.vote': 'Voting',
|
|
||||||
'panel.proposals': 'Proposals',
|
|
||||||
'panel.propose': 'Propose',
|
'panel.propose': 'Propose',
|
||||||
'panel.settings': 'Settings',
|
|
||||||
'panel.basket': 'Basket',
|
|
||||||
'panel.details': 'Details',
|
'panel.details': 'Details',
|
||||||
'panel.close': 'Close panel',
|
'panel.close': 'Close panel',
|
||||||
'panel.inforce': 'In force',
|
'panel.inforce': 'In force',
|
||||||
@@ -230,6 +238,19 @@ export const en: Messages = {
|
|||||||
'vote.against': 'Against',
|
'vote.against': 'Against',
|
||||||
'vote.retract': 'Withdraw my vote',
|
'vote.retract': 'Withdraw my vote',
|
||||||
'vote.saved': 'Vote saved.',
|
'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.retracted': 'Vote withdrawn.',
|
||||||
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
||||||
'vote.reconfirm': 'Reconfirm: {choice}',
|
'vote.reconfirm': 'Reconfirm: {choice}',
|
||||||
@@ -256,7 +277,7 @@ export const en: Messages = {
|
|||||||
'vote.mine.saved': 'saved',
|
'vote.mine.saved': 'saved',
|
||||||
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
||||||
'vote.mine.simulated': 'simulated',
|
'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.one': 'of which {n} simulated',
|
||||||
'vote.tally.simulated.other': 'of which {n} simulated',
|
'vote.tally.simulated.other': 'of which {n} simulated',
|
||||||
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
'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.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.members': 'Only members of this group vote here.',
|
||||||
'vote.details': 'Typos, translations, objections, history',
|
'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.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.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.',
|
'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.empty': 'Nothing in the basket yet.',
|
||||||
'basket.sent.one': '{n} vote saved.',
|
'basket.sent.one': '{n} vote saved.',
|
||||||
'basket.sent.other': '{n} votes 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.section': 'Vote on this section',
|
||||||
'basket.mine': 'your vote: {choice}',
|
'basket.mine': 'your vote: {choice}',
|
||||||
'basket.held': 'an objection holds it back: against or withdraw, no “for”',
|
'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.lines.other': '{n} votes',
|
||||||
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
||||||
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
|
'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.refused': 'Not counted: {reason}',
|
||||||
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
||||||
'ballots.block': 'block {n}',
|
'ballots.block': 'block {n}',
|
||||||
|
|||||||
+50
-12
@@ -128,11 +128,13 @@ export const es: Messages = {
|
|||||||
'doc.confirmed': 'confirmado por la votación',
|
'doc.confirmed': 'confirmado por la votación',
|
||||||
'doc.adopted': 'adoptado el {date}',
|
'doc.adopted': 'adoptado el {date}',
|
||||||
'doc.action.vote': 'Votación',
|
'doc.action.vote': 'Votación',
|
||||||
'doc.action.proposals': 'Propuestas',
|
|
||||||
'doc.action.propose': 'Proponer una modificación',
|
'doc.action.propose': 'Proponer una modificación',
|
||||||
'doc.basket': 'Votar en bloque',
|
'doc.basket': 'Votar en bloque',
|
||||||
'doc.basket.count': 'Votar en bloque · {n}',
|
'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': '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.published': 'Publicado en el repositorio de textos',
|
||||||
'doc.publish.pending': 'Publicación pendiente',
|
'doc.publish.pending': 'Publicación pendiente',
|
||||||
|
|
||||||
@@ -158,6 +160,11 @@ export const es: Messages = {
|
|||||||
'deep.versions.other': '{n} versiones',
|
'deep.versions.other': '{n} versiones',
|
||||||
'deep.version': 'versión {n}',
|
'deep.version': 'versión {n}',
|
||||||
'deep.lead': 'en cabeza: {label}',
|
'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.introduction': 'Introducción',
|
||||||
'section.fondamental': 'Compromisos fundamentales',
|
'section.fondamental': 'Compromisos fundamentales',
|
||||||
'section.technique': 'Compromisos técnicos',
|
'section.technique': 'Compromisos técnicos',
|
||||||
@@ -169,8 +176,6 @@ export const es: Messages = {
|
|||||||
'module.title.name': 'Título del documento',
|
'module.title.name': 'Título del documento',
|
||||||
'module.order.name': 'Orden de los artículos',
|
'module.order.name': 'Orden de los artículos',
|
||||||
'module.order.named': 'Orden de los artículos · {section}',
|
'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.inforce': 'Orden en vigor',
|
||||||
'module.order.proposal': 'Orden propuesto',
|
'module.order.proposal': 'Orden propuesto',
|
||||||
'module.order.added.one': '{n} artículo añadido desde entonces, al final',
|
'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.dropped.other': '{n} artículos salidos desde entonces',
|
||||||
'module.order.same': 'es el orden en vigor',
|
'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.',
|
'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.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.up': 'Subir «{title}»',
|
||||||
'order.edit.down': 'Bajar «{title}»',
|
'order.edit.down': 'Bajar «{title}»',
|
||||||
'order.edit.chosen': '«{title}» elegido: haz clic en su nuevo lugar.',
|
'order.edit.chosen': '«{title}» elegido: haz clic en su nuevo lugar.',
|
||||||
'order.edit.moved': '«{title}» en el lugar {n} de {total}.',
|
'order.edit.moved': '«{title}» en el lugar {n} de {total}.',
|
||||||
'order.edit.submit': 'Proponer este orden',
|
'order.edit.submit': 'Proponer este orden',
|
||||||
'order.edit.unchanged': 'Es el orden en vigor: mueve al menos un artículo.',
|
'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.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.',
|
'order.submitted': 'Orden sometido a votación.',
|
||||||
'title.propose': 'Proponer otro título',
|
'title.propose': 'Proponer otro título',
|
||||||
'title.propose.label': 'Nuevo 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.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.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.short': '{for} a favor · {against} en contra',
|
||||||
'tally.required': 'Umbral: {n} votos a favor',
|
|
||||||
'tally.missing.one': 'Falta {n} voto a favor.',
|
'tally.missing.one': 'Falta {n} voto a favor.',
|
||||||
'tally.missing.other': 'Faltan {n} votos a favor.',
|
'tally.missing.other': 'Faltan {n} votos a favor.',
|
||||||
'tally.reached': 'Umbral alcanzado.',
|
'tally.reached': 'Umbral alcanzado.',
|
||||||
@@ -210,11 +222,7 @@ export const es: Messages = {
|
|||||||
'inertia.badge': 'inercia {level}',
|
'inertia.badge': 'inercia {level}',
|
||||||
|
|
||||||
|
|
||||||
'panel.vote': 'Votación',
|
|
||||||
'panel.proposals': 'Propuestas',
|
|
||||||
'panel.propose': 'Proponer',
|
'panel.propose': 'Proponer',
|
||||||
'panel.settings': 'Ajustes',
|
|
||||||
'panel.basket': 'Cesta',
|
|
||||||
'panel.details': 'Detalles',
|
'panel.details': 'Detalles',
|
||||||
'panel.close': 'Cerrar el panel',
|
'panel.close': 'Cerrar el panel',
|
||||||
'panel.inforce': 'En vigor',
|
'panel.inforce': 'En vigor',
|
||||||
@@ -230,6 +238,19 @@ export const es: Messages = {
|
|||||||
'vote.against': 'En contra',
|
'vote.against': 'En contra',
|
||||||
'vote.retract': 'Retirar mi voto',
|
'vote.retract': 'Retirar mi voto',
|
||||||
'vote.saved': 'Voto registrado.',
|
'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.retracted': 'Voto retirado.',
|
||||||
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
||||||
'vote.reconfirm': 'Reconfirmar: {choice}',
|
'vote.reconfirm': 'Reconfirmar: {choice}',
|
||||||
@@ -256,7 +277,7 @@ export const es: Messages = {
|
|||||||
'vote.mine.saved': 'registrado',
|
'vote.mine.saved': 'registrado',
|
||||||
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
||||||
'vote.mine.simulated': 'simulado',
|
'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.one': 'de ellos {n} simulado',
|
||||||
'vote.tally.simulated.other': 'de ellos {n} simulados',
|
'vote.tally.simulated.other': 'de ellos {n} simulados',
|
||||||
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
'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.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.members': 'Aquí solo votan los miembros de este grupo.',
|
||||||
'vote.details': 'Erratas, traducciones, objeciones, historial',
|
'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.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.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.',
|
'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.empty': 'Nada en la cesta por ahora.',
|
||||||
'basket.sent.one': '{n} voto registrado.',
|
'basket.sent.one': '{n} voto registrado.',
|
||||||
'basket.sent.other': '{n} votos registrados.',
|
'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.section': 'Votar esta sección',
|
||||||
'basket.mine': 'tu voto: {choice}',
|
'basket.mine': 'tu voto: {choice}',
|
||||||
'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»',
|
'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.lines.other': '{n} votos',
|
||||||
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
||||||
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
|
'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.refused': 'No contado: {reason}',
|
||||||
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
||||||
'ballots.block': 'bloque {n}',
|
'ballots.block': 'bloque {n}',
|
||||||
|
|||||||
+50
-12
@@ -132,11 +132,13 @@ export const fr = {
|
|||||||
'doc.confirmed': 'confirmé par le vote',
|
'doc.confirmed': 'confirmé par le vote',
|
||||||
'doc.adopted': 'adopté le {date}',
|
'doc.adopted': 'adopté le {date}',
|
||||||
'doc.action.vote': 'Le vote',
|
'doc.action.vote': 'Le vote',
|
||||||
'doc.action.proposals': 'Propositions',
|
|
||||||
'doc.action.propose': 'Proposer une modification',
|
'doc.action.propose': 'Proposer une modification',
|
||||||
'doc.basket': 'Voter en lot',
|
'doc.basket': 'Voter en lot',
|
||||||
'doc.basket.count': 'Voter en lot · {n}',
|
'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': "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.published': 'Publié sur le dépôt des textes',
|
||||||
'doc.publish.pending': 'Publication en attente',
|
'doc.publish.pending': 'Publication en attente',
|
||||||
|
|
||||||
@@ -162,6 +164,11 @@ export const fr = {
|
|||||||
'deep.versions.other': '{n} versions',
|
'deep.versions.other': '{n} versions',
|
||||||
'deep.version': 'version {n}',
|
'deep.version': 'version {n}',
|
||||||
'deep.lead': 'en tête : {label}',
|
'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.introduction': 'Introduction',
|
||||||
'section.fondamental': 'Engagements fondamentaux',
|
'section.fondamental': 'Engagements fondamentaux',
|
||||||
'section.technique': 'Engagements techniques',
|
'section.technique': 'Engagements techniques',
|
||||||
@@ -173,8 +180,6 @@ export const fr = {
|
|||||||
'module.title.name': 'Titre du document',
|
'module.title.name': 'Titre du document',
|
||||||
'module.order.name': 'Ordre des articles',
|
'module.order.name': 'Ordre des articles',
|
||||||
'module.order.named': 'Ordre des articles · {section}',
|
'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.inforce': 'Ordre en vigueur',
|
||||||
'module.order.proposal': 'Ordre proposé',
|
'module.order.proposal': 'Ordre proposé',
|
||||||
'module.order.added.one': '{n} article entré depuis, placé à la fin',
|
'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.dropped.other': '{n} articles sortis depuis',
|
||||||
'module.order.same': "c'est l'ordre en vigueur",
|
'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.",
|
'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.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.up': 'Monter « {title} »',
|
||||||
'order.edit.down': 'Descendre « {title} »',
|
'order.edit.down': 'Descendre « {title} »',
|
||||||
'order.edit.chosen': '« {title} » choisi : clique sa nouvelle place.',
|
'order.edit.chosen': '« {title} » choisi : clique sa nouvelle place.',
|
||||||
'order.edit.moved': '« {title} » en place {n} sur {total}.',
|
'order.edit.moved': '« {title} » en place {n} sur {total}.',
|
||||||
'order.edit.submit': 'Proposer cet ordre',
|
'order.edit.submit': 'Proposer cet ordre',
|
||||||
'order.edit.unchanged': "C'est l'ordre en vigueur : déplace au moins un article.",
|
'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.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.',
|
'order.submitted': 'Ordre proposé au vote.',
|
||||||
'title.propose': 'Proposer un autre titre',
|
'title.propose': 'Proposer un autre titre',
|
||||||
'title.propose.label': 'Nouveau 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.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.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.short': '{for} pour · {against} contre',
|
||||||
'tally.required': 'Seuil : {n} voix pour',
|
|
||||||
'tally.missing.one': 'Il manque {n} voix pour.',
|
'tally.missing.one': 'Il manque {n} voix pour.',
|
||||||
'tally.missing.other': 'Il manque {n} voix pour.',
|
'tally.missing.other': 'Il manque {n} voix pour.',
|
||||||
'tally.reached': 'Seuil atteint.',
|
'tally.reached': 'Seuil atteint.',
|
||||||
@@ -214,11 +226,7 @@ export const fr = {
|
|||||||
'inertia.badge': 'inertie {level}',
|
'inertia.badge': 'inertie {level}',
|
||||||
|
|
||||||
|
|
||||||
'panel.vote': 'Le vote',
|
|
||||||
'panel.proposals': 'Propositions',
|
|
||||||
'panel.propose': 'Proposer',
|
'panel.propose': 'Proposer',
|
||||||
'panel.settings': 'Réglages',
|
|
||||||
'panel.basket': 'Panier',
|
|
||||||
'panel.details': 'Détails',
|
'panel.details': 'Détails',
|
||||||
'panel.close': 'Fermer le panneau',
|
'panel.close': 'Fermer le panneau',
|
||||||
'panel.inforce': 'En vigueur',
|
'panel.inforce': 'En vigueur',
|
||||||
@@ -234,6 +242,19 @@ export const fr = {
|
|||||||
'vote.against': 'Contre',
|
'vote.against': 'Contre',
|
||||||
'vote.retract': 'Retirer mon vote',
|
'vote.retract': 'Retirer mon vote',
|
||||||
'vote.saved': 'Vote enregistré.',
|
'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.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.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}',
|
'vote.reconfirm': 'Reconfirmer : {choice}',
|
||||||
@@ -260,7 +281,7 @@ export const fr = {
|
|||||||
'vote.mine.saved': 'enregistré',
|
'vote.mine.saved': 'enregistré',
|
||||||
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
||||||
'vote.mine.simulated': 'simulé',
|
'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.one': 'dont {n} simulé',
|
||||||
'vote.tally.simulated.other': 'dont {n} simulés',
|
'vote.tally.simulated.other': 'dont {n} simulés',
|
||||||
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
'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.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.members': 'Seuls les membres de ce groupe votent ici.',
|
||||||
'vote.details': 'Coquilles, traductions, objections, historique',
|
'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.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.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.',
|
'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.empty': "Rien dans le panier pour l'instant.",
|
||||||
'basket.sent.one': '{n} vote enregistré.',
|
'basket.sent.one': '{n} vote enregistré.',
|
||||||
'basket.sent.other': '{n} votes enregistrés.',
|
'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.section': 'Voter cette section',
|
||||||
'basket.mine': 'ton vote : {choice}',
|
'basket.mine': 'ton vote : {choice}',
|
||||||
'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »',
|
'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.lines.other': '{n} votes',
|
||||||
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
||||||
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
|
'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.refused': 'Pas compté : {reason}',
|
||||||
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
||||||
'ballots.block': 'bloc {n}',
|
'ballots.block': 'bloc {n}',
|
||||||
|
|||||||
@@ -53,3 +53,25 @@ export function orderNote(p: Proposal): string[] {
|
|||||||
return notes;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
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
@@ -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;
|
const id = doc.view?.doc.id;
|
||||||
if (!id) return 0;
|
if (!id) return { count: 0, simulated: false };
|
||||||
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
|
const r = await api.post<{ count: number; entered: Entered[]; simulated?: boolean }>(`/docs/${id}/votes`, { lines });
|
||||||
await doc.reload();
|
await doc.reload();
|
||||||
announce(doc, r.entered);
|
announce(doc, r.entered);
|
||||||
return r.count;
|
return { count: r.count, simulated: !!r.simulated };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<script lang="ts">
|
<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 { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import ChainSign from '$lib/components/ChainSign.svelte';
|
import ChainSign from '$lib/components/ChainSign.svelte';
|
||||||
@@ -6,20 +12,22 @@
|
|||||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||||
import Guard from '$lib/components/Guard.svelte';
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
import Icon from '$lib/components/Icon.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 TitleBar from '$lib/components/TitleBar.svelte';
|
||||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||||
import { sections, votableIds } from '$lib/basket';
|
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 { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||||
|
import { sectionLabel } from '$lib/labels';
|
||||||
import { answerPanel } from '$lib/objections';
|
import { answerPanel } from '$lib/objections';
|
||||||
import { BasketState } from '$lib/picks.svelte';
|
import { BasketState } from '$lib/picks.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||||
import type { Line } from '$lib/vote';
|
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 doc = new DocState();
|
||||||
const basket = new BasketState();
|
const basket = new BasketState();
|
||||||
@@ -53,10 +61,20 @@
|
|||||||
edit: q.get('modifier') === '1',
|
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 vote = $derived.by(() => {
|
||||||
const q = page.url.searchParams;
|
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) {
|
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 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);
|
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.
|
// On the Ğ1 chain: one signing at a time, over the page.
|
||||||
let signing = $state<Line[] | null>(null);
|
let signing = $state<Line[] | null>(null);
|
||||||
const sign = (lines: Line[]) => (signing = lines);
|
const sign = (lines: Line[]) => (signing = lines);
|
||||||
@@ -92,34 +126,42 @@
|
|||||||
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
||||||
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
||||||
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.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);
|
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 titleUnit = $derived(view?.modules.title ?? null);
|
||||||
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
|
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
|
||||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||||
|
|
||||||
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
const reading = $derived(doc.mode === 'inforce' || !doc.preview);
|
||||||
if (action === 'vote') {
|
/** The text as it reads, by section, each closed by its order. */
|
||||||
if (version) toggleVote(version);
|
const textSections = $derived(reading ? doc.textSections : []);
|
||||||
return;
|
/** The versions the text shows: their vote unfolds there, never in the list below. */
|
||||||
}
|
|
||||||
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 textIds = $derived(
|
const textIds = $derived(
|
||||||
new Set([
|
new Set(
|
||||||
titleUnit?.inForce?.id,
|
reading
|
||||||
...(doc.mode === 'inforce' || !doc.preview
|
? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
|
||||||
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
|
: (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.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);
|
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>
|
</script>
|
||||||
|
|
||||||
{#snippet foldOf(unit: Unit, p: Proposal)}
|
{#snippet foldOf(unit: Unit, p: Proposal)}
|
||||||
@@ -135,6 +177,28 @@
|
|||||||
/>
|
/>
|
||||||
{/snippet}
|
{/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>
|
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
||||||
|
|
||||||
{#if doc.error}
|
{#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>
|
<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>
|
</p>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{#if titleUnit?.inForce}
|
{#if titleUnit}
|
||||||
{@const tp = titleUnit.inForce}
|
|
||||||
<TitleBar
|
<TitleBar
|
||||||
unit={titleUnit}
|
unit={titleUnit}
|
||||||
stale={titleStale}
|
stale={titleStale}
|
||||||
selected={panel?.unitId === titleUnit.id}
|
selected={panel?.unitId === titleUnit.id}
|
||||||
voteOpen={inText && vote === tp.id}
|
{vote}
|
||||||
onaction={(action) => onUnit(titleUnit, action)}
|
fold={moduleFold}
|
||||||
>
|
onvote={(id) => toggleVote(id)}
|
||||||
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
|
onpropose={() => openPanel({ unitId: titleUnit.id, proposalId: null, tab: 'propose' })}
|
||||||
</TitleBar>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="summary">{summary}</p>
|
<p class="summary">{summary}</p>
|
||||||
<p class="facts faint">
|
<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>
|
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if canVote}
|
<!-- One line of tools — the basket, the language of the text —, then the readings. -->
|
||||||
<div class="basket-row">
|
<div class="tools">
|
||||||
|
{#if canVote}
|
||||||
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
|
<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')}
|
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{#if view.vote.simulated}<span class="tag" title={t('vote.simulated.note')}>{t('vote.simulated.mark')}</span>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="controls">
|
<div class="seg small langs" role="radiogroup" aria-label={t('doc.lang.label')}>
|
||||||
<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')}>
|
|
||||||
{#each LANGS as l (l)}
|
{#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>
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#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>
|
<p class="note faint">{t('doc.lang.translation')} <button class="linkish" type="button" onclick={() => (doc.original = true)}>{t('doc.lang.original')}</button></p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -191,44 +254,37 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="text" aria-label={t('doc.view.inforce')}>
|
<section class="text" aria-label={t('doc.view.inforce')}>
|
||||||
{#if doc.mode === 'inforce' || !doc.preview}
|
{#if reading}
|
||||||
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
|
<!-- Nothing in force (ADR 0011): the seed proposals at the head, said once. -->
|
||||||
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
|
{#if !doc.inForce.length}<p class="lead">{t(textSections.length ? 'doc.empty.seeds' : 'doc.empty')}</p>{/if}
|
||||||
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
|
{#each textSections as s, i (`${i}:${s.section}`)}
|
||||||
{#each s.units as u (u.id)}
|
{@const label = sectionLabel(s.section)}
|
||||||
{#if u.inForce}
|
<div class="text-section">
|
||||||
{@const p = u.inForce}
|
{#if label}<p class="section-name">{label}</p>{/if}
|
||||||
<UnitBlock
|
{#if !s.order || reordering !== s.order.id}
|
||||||
text={shown(p, lang, source)}
|
{#each s.entries as e (e.proposal.id)}
|
||||||
seedMinimal={p.seedMinimal}
|
{@const p = e.proposal}
|
||||||
enteredAt={p.enteredAt}
|
<UnitBlock
|
||||||
votes={p.tally.for + p.tally.against}
|
text={shown(p, lang, source)}
|
||||||
rivals={rivalsOf(u)}
|
seed={e.seed ? p : null}
|
||||||
selected={panel?.unitId === u.id}
|
seedMinimal={!e.seed && p.seedMinimal}
|
||||||
voteFor={p.id}
|
enteredAt={e.seed ? null : p.enteredAt}
|
||||||
voteOpen={inText && vote === p.id}
|
votes={p.tally.for + p.tally.against}
|
||||||
onaction={(action) => onUnit(u, action)}
|
rivals={othersOf(e)}
|
||||||
>
|
selected={panel?.unitId === e.unit.id}
|
||||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
voteFor={p.id}
|
||||||
</UnitBlock>
|
voteOpen={vote === p.id}
|
||||||
|
onaction={(action) => onEntry(e, action)}
|
||||||
|
>
|
||||||
|
{#snippet fold()}{@render foldOf(e.unit, p)}{/snippet}
|
||||||
|
</UnitBlock>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
|
||||||
{#if s.order?.inForce}
|
</div>
|
||||||
{@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}
|
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each doc.preview as pu (pu.unitId)}
|
{#each doc.preview ?? [] as pu (pu.unitId)}
|
||||||
{@const u = doc.unit(pu.unitId)}
|
{@const u = doc.unit(pu.unitId)}
|
||||||
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
||||||
{@const p = doc.proposal(id)}
|
{@const p = doc.proposal(id)}
|
||||||
@@ -237,13 +293,17 @@
|
|||||||
mark={pu.mark}
|
mark={pu.mark}
|
||||||
selected={panel?.unitId === pu.unitId}
|
selected={panel?.unitId === pu.unitId}
|
||||||
voteFor={id}
|
voteFor={id}
|
||||||
voteOpen={inText && !!id && vote === id}
|
voteOpen={!!id && vote === id}
|
||||||
onaction={(action) => u && onUnit(u, action, 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}
|
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
|
||||||
</UnitBlock>
|
</UnitBlock>
|
||||||
{/each}
|
{/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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -264,7 +324,7 @@
|
|||||||
|
|
||||||
<div class="deep-head">
|
<div class="deep-head">
|
||||||
<h2 id="deep-title">{t('deep.title')}</h2>
|
<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')}>
|
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
|
||||||
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
|
{#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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<DeepList
|
<DeepList
|
||||||
articles={deep}
|
sections={deepSecs}
|
||||||
vote={inText ? null : vote}
|
vote={textIds.has(vote ?? '') ? null : vote}
|
||||||
|
{reordering}
|
||||||
|
all={filter !== 'all'}
|
||||||
ontoggle={(_, p) => toggleVote(p.id)}
|
ontoggle={(_, p) => toggleVote(p.id)}
|
||||||
fold={foldOf}
|
fold={foldOf}
|
||||||
{nameOf}
|
order={orderLine}
|
||||||
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if panel}
|
{#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}
|
||||||
{#if signing && view.vote.chain}
|
{#if signing && view.vote.chain}
|
||||||
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
||||||
@@ -339,18 +401,18 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.controls {
|
.tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
justify-content: space-between;
|
margin-top: 1.2rem;
|
||||||
margin-top: 1.6rem;
|
|
||||||
}
|
}
|
||||||
.basket-row {
|
.langs {
|
||||||
margin-top: 1.4rem;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.basket-row + .controls {
|
.views {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.7rem;
|
||||||
}
|
}
|
||||||
.basket-btn {
|
.basket-btn {
|
||||||
background: var(--water);
|
background: var(--water);
|
||||||
@@ -372,6 +434,7 @@
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.seg.small button {
|
.seg.small button {
|
||||||
padding-inline: 0.65rem;
|
padding-inline: 0.65rem;
|
||||||
@@ -402,6 +465,23 @@
|
|||||||
.text {
|
.text {
|
||||||
margin-top: 2.2rem;
|
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 {
|
.deep {
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
|
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
|
||||||
@@ -412,7 +492,7 @@
|
|||||||
.deep-head {
|
.deep-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
.deep-head h2 {
|
.deep-head h2 {
|
||||||
font-family: var(--font-text);
|
font-family: var(--font-text);
|
||||||
@@ -437,4 +517,21 @@
|
|||||||
.new {
|
.new {
|
||||||
margin-top: 1.4rem;
|
margin-top: 1.4rem;
|
||||||
}
|
}
|
||||||
|
/* On a phone: the three readings on one line that scrolls, the targets big enough for a finger. */
|
||||||
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { Proposal, Unit } from '../../src/lib/types';
|
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 {
|
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||||
return {
|
return {
|
||||||
@@ -96,3 +96,32 @@ describe('the versions of an article', () => {
|
|||||||
expect(excerpts(['Seule.'])).toEqual(['Seule.']);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user