Fusion du lot G : sur une version retenue par une objection, « contre » et retrait oui, « pour » non (Yvv, 01/11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-02 01:29:35 +01:00
co-authored by Claude Opus 5.5
23 changed files with 478 additions and 116 deletions
+3 -2
View File
@@ -117,8 +117,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; - **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend
son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée
change le texte ; une objection rend la dernière arrivée non votable jusqu'à sa levée (jamais un change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en
texte en vigueur ; ADR 0010) ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ; vigueur ; ADR 0010) : elle n'entre pas en vigueur, et on peut y retirer son vote ou passer à
« contre », jamais ajouter un « pour » (Yvv, 01/11/2026 : « Retirer ou "contre" ») ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test** dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv). (chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
+26
View File
@@ -17,6 +17,10 @@
//! is changed by a proposal. The target is fixed when the objection is raised; //! is changed by a proposal. The target is fixed when the objection is raised;
//! - the votes already cast on it stay, but it cannot enter into force //! - the votes already cast on it stay, but it cannot enter into force
//! (`votable` and the adoption: a non-votable version is blocked); //! (`votable` and the adoption: a non-votable version is blocked);
//! - while it is held, one may still **vote against it or take one's vote
//! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou
//! "contre" »): it can only lose votes, and once lifted it enters with the
//! votes still there;
//! - its author **answers**: a substantive revision of it, or a proposal linked //! - its author **answers**: a substantive revision of it, or a proposal linked
//! to the objection (on the article it repeats, for a « redit »; a reworded //! to the objection (on the article it repeats, for a « redit »; a reworded
//! version, for a version without author). A substantive revision of **the //! version, for a version without author). A substantive revision of **the
@@ -31,6 +35,7 @@
//! lift too (never reiterate: the objection is not theirs); withdrawing either //! lift too (never reiterate: the objection is not theirs); withdrawing either
//! version ends it. //! version ends it.
use crate::ballot::Choice;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::Ordering; use std::cmp::Ordering;
@@ -296,6 +301,16 @@ pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
in_force || !held.contains(&id) in_force || !held.contains(&id)
} }
/// May this choice be cast on a version? `held` = a standing objection holds it
/// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant
/// qu'une objection retient une version, que permet-on ? »: « Retirer ou
/// "contre" ». On a held version one may take one's vote back or vote against,
/// never add a *for*: it can only lose votes while it is held, and once lifted it
/// enters with the votes still there. A version not held takes any choice.
pub fn may_cast(held: bool, choice: Choice) -> bool {
!held || choice != Choice::For
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -357,6 +372,17 @@ mod tests {
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable"); assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
} }
#[test]
fn a_held_version_takes_against_and_withdrawal_never_for() {
// Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held.
assert!(!may_cast(true, Choice::For), "never a new *for* while held");
assert!(may_cast(true, Choice::Against), "against, yes");
assert!(may_cast(true, Choice::None), "taking one's vote back, yes");
for c in [Choice::For, Choice::Against, Choice::None] {
assert!(may_cast(false, c), "a version not held takes any choice: {c:?}");
}
}
#[test] #[test]
fn the_full_cycle_answer_reiterate_answer_lift() { fn the_full_cycle_answer_reiterate_answer_lift() {
let start = Phase::Author { cycle: 1 }; let start = Phase::Author { cycle: 1 };
+12 -6
View File
@@ -90,7 +90,7 @@ pub struct BallotBody {
author: Option<String>, author: Option<String>,
} }
/// A line checked against the document: a votable version, its revision now. /// A line checked against the document: a version open to its choice, its revision now.
pub(crate) struct Cast { pub(crate) struct Cast {
pub proposal_id: String, pub proposal_id: String,
pub revision: i64, pub revision: i64,
@@ -105,7 +105,9 @@ fn parse_choice(raw: Option<&str>) -> AppResult<Choice> {
} }
/// The lines of a vote, read against the document: at least one, each version /// The lines of a vote, read against the document: at least one, each version
/// once, each one of this document and votable. /// once, each one of this document and open to its choice — a version held back
/// by an objection takes against or a withdrawal, never a *for* (`DocState::admits`).
/// One line refused refuses them all.
fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> { fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if lines.is_empty() || lines.len() > MAX_LINES { if lines.is_empty() || lines.len() > MAX_LINES {
return Err(AppError::Invalid("lines")); return Err(AppError::Invalid("lines"));
@@ -120,11 +122,13 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if !matches!(p.status.as_str(), "open" | "in_force") { if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable")); return Err(AppError::Conflict("not_votable"));
} }
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010). let choice = parse_choice(l.choice.as_deref())?;
if !st.votable(p) { // Held back by an objection: no new *for*, on the server or on the chain
// (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
if !st.admits(p, choice) {
return Err(AppError::Conflict("objection")); return Err(AppError::Conflict("objection"));
} }
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? }); out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice });
} }
Ok(out) Ok(out)
} }
@@ -600,7 +604,9 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str)
} }
/// The ballot counts: written for the identity of the signer, line by line, /// The ballot counts: written for the identity of the signer, line by line,
/// where no more recent ballot of that identity decided already. /// where no more recent ballot of that identity decided already. A ballot sealed
/// before an objection counts as it was signed — a *for* too, as a vote already
/// cast —, and the version held back still cannot enter (ADR 0010).
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> { async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
let voter = format!("idty:{index}"); let voter = format!("idty:{index}");
let position = found.comment.position; let position = found.comment.position;
+12 -2
View File
@@ -12,6 +12,7 @@ use crate::participation;
use crate::util::{self, LangMap}; use crate::util::{self, LangMap};
use axum::Json; use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use ld_engine::ballot::Choice;
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::inertia::{InertiaLevel, Participation, Scale}; use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally}; use ld_engine::threshold::{Evaluation, Tally};
@@ -265,7 +266,8 @@ pub struct DocState {
pub module_kinds: HashMap<String, ModuleKind>, pub module_kinds: HashMap<String, ModuleKind>,
pub proposals: Vec<ProposalRow>, pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>, pub tallies: HashMap<String, Tally>,
/// Versions held back by a standing objection: not votable, never entering into force. /// Versions held back by a standing objection: against or a withdrawal only, never
/// a new *for* (`admits`), and never entering into force.
pub blocked: HashSet<String>, pub blocked: HashSet<String>,
/// Standing objections touching each version, either side. /// Standing objections touching each version, either side.
pub signal_counts: HashMap<String, i64>, pub signal_counts: HashMap<String, i64>,
@@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
} }
// A standing objection holds back the latest arrival of its pair (never a version // A standing objection holds back the latest arrival of its pair (never a version
// in force): not votable, and it cannot enter into force (ADR 0010). // in force): no new *for*, and it cannot enter into force (ADR 0010).
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
let (electorate, electorate_source) = electorate(state, &group).await?; let (electorate, electorate_source) = electorate(state, &group).await?;
let measure = participation::stored(state, &group.slug).await?; let measure = participation::stored(state, &group.slug).await?;
@@ -414,6 +416,14 @@ impl DocState {
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id) matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
} }
/// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the
/// chain? Open or in force; held back by an objection, against or a withdrawal
/// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01).
pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool {
matches!(p.status.as_str(), "open" | "in_force")
&& ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice)
}
/// The module a unit is, if it is one. /// The module a unit is, if it is one.
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> { pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
self.module_kinds.get(unit_id).copied() self.module_kinds.get(unit_id).copied()
+3 -2
View File
@@ -1,8 +1,9 @@
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010). //! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
//! //!
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of //! The rules are the engine's (`ld_engine::objection`): the latest arrival of
//! the pair, never a version in force, is no longer votable while the objection //! the pair, never a version in force, takes no new *for* while the objection
//! stands; its author answers (a substantive revision, or a proposal linked to //! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and
//! cannot enter into force; its author answers (a substantive revision, or a proposal linked to
//! the objection — a revision of the other side answers too); the objector is //! the objection — a revision of the other side answers too); the objector is
//! then asked, by a message, to lift or reiterate; without an answer within 3 //! then asked, by a message, to lift or reiterate; without an answer within 3
//! days the objection is lifted (`due`, run by the sweeper and at start). //! days the objection is lifted (`due`, run by the sweeper and at start).
+2 -1
View File
@@ -138,7 +138,8 @@ pub async fn decisions(
/// or a seed a minima), the open versions, and whether `me` holds a valid vote /// or a seed a minima), the open versions, and whether `me` holds a valid vote
/// on one of them. A change of inertia is not a version of the text; the order /// on one of them. A change of inertia is not a version of the text; the order
/// of a section of fewer than two articles has nothing to decide; a version held /// of a section of fewer than two articles has nothing to decide; a version held
/// by an objection is nobody's to vote while it is held (ADR 0010). /// by an objection is not counted as one to vote while it is held: it takes no
/// new *for*, only against or a withdrawal (ADR 0010).
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> { async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct VersionRow { struct VersionRow {
+8 -7
View File
@@ -648,19 +648,20 @@ pub async fn vote(
if !matches!(p.status.as_str(), "open" | "in_force") { if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable")); return Err(AppError::Conflict("not_votable"));
} }
// Held back by an objection: its votes stay, no new one (ADR 0010).
if !st.votable(&p) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
let choice = match b.choice.as_deref() { let choice = match b.choice.as_deref() {
Some("for") => Choice::For, Some("for") => Choice::For,
Some("against") => Choice::Against, Some("against") => Choice::Against,
None => Choice::None, None => Choice::None,
Some(_) => return Err(AppError::Invalid("choice")), Some(_) => return Err(AppError::Invalid("choice")),
}; };
// Held back by an objection: its votes stay; against or a withdrawal, never a
// new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
if !st.admits(&p, choice) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
ballots::write_server_votes(&state, &user.id, &[cast]).await?; ballots::write_server_votes(&state, &user.id, &[cast]).await?;
let entered = adoption::run(&state, &st.doc.id).await?; let entered = adoption::run(&state, &st.doc.id).await?;
+46
View File
@@ -776,6 +776,52 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
} }
/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a
/// ballot against it or a withdrawal, never a *for* — alone or among free lines.
#[tokio::test]
async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let uri = format!("/api/docs/{doc_id}/ballots");
let refused = (StatusCode::CONFLICT, Some("objection"));
for lines in [
json!([{ "proposalId": e5, "choice": "for" }]),
json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]),
] {
let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await;
assert_eq!((s, v["error"].as_str()), refused, "{lines}");
}
// Against the held one, for a free one: sealed, then confirmed by the chain.
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await;
indexer.pay(200, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1)));
assert_eq!(find(&d, &e5)["myVote"], "against");
// A withdrawal: the last ballot stands.
let w = c.ballot(&doc, &[(&e5, None)], None).await;
indexer.pay(201, comment_of(&w), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&w).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e5), (0, 0));
assert!(find(&d, &e5)["myVote"].is_null());
assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// After the independent review of 74f96be (lot B2): its probes, kept as tests // After the independent review of 74f96be (lot B2): its probes, kept as tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+113 -1
View File
@@ -142,7 +142,8 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable"); assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
assert_eq!(find(&d, &e1_id)["objections"], 1); assert_eq!(find(&d, &e1_id)["objections"], 1);
// Nobody votes on it any more: neither one vote nor a grouped one. // Nobody votes *for* it any more: neither one vote nor a grouped one (against and a
// withdrawal still pass: `a_held_version_takes_against_and_withdrawal_never_for`).
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await; let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(one, (StatusCode::CONFLICT, "objection".into())); assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
let many = yvv let many = yvv
@@ -476,6 +477,117 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters"); assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
} }
/// Yvv, 2026-11-01, « pendant qu'une objection retient une version, que permet-on ? » —
/// « Retirer ou "contre" »: on a held version, against and a withdrawal pass, by one vote
/// or a grouped one; a *for* never does, alone or among free lines (all or none).
#[tokio::test]
async fn a_held_version_takes_against_and_withdrawal_never_for() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut zoe = chiara.other();
zoe.login("Zoé", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
zoe.object(&b, &e1, "redit", REPEATS).await;
// Yvv voted for it before the objection (written as it would have been then).
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at)
VALUES (?, 'dev:yvv', 'for', 1, '2026-10-31T00:00:00Z')",
)
.bind(&b)
.execute(&state.db)
.await
.expect("vote");
let tally = |d: &Value| (find(d, &b)["tally"]["for"].as_u64(), find(d, &b)["tally"]["against"].as_u64());
let vote = |c: Option<&str>| Some(json!({ "choice": c }));
let uri = format!("/api/proposals/{b}/vote");
let refused = (StatusCode::CONFLICT, "objection".to_owned());
// One vote: never for — not even the one he already holds.
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
assert_eq!(chiara.err("POST", &uri, vote(Some("for"))).await, refused);
// Against: his for becomes against.
assert_eq!(yvv.ok("POST", &uri, vote(Some("against"))).await["entered"], json!([]));
let d = yvv.doc("essai").await;
assert_eq!(tally(&d), (Some(0), Some(1)));
assert_eq!(find(&d, &b)["myVote"], "against");
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
// And back to for: still not.
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
// A withdrawal.
yvv.ok("POST", &uri, vote(None)).await;
let d = yvv.doc("essai").await;
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
// Grouped: a for on the held version refuses the whole vote, the free line too.
let votes = format!("/api/docs/{doc_id}/votes");
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "for" }] });
assert_eq!(chiara.err("POST", &votes, Some(mixed)).await, refused);
let d = chiara.doc("essai").await;
assert!(find(&d, &e2)["myVote"].is_null(), "all or none: nothing written");
// Against the held one, for the free one: both written.
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "against" }] });
assert_eq!(chiara.ok("POST", &votes, Some(mixed)).await["count"], 2);
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &e2)["myVote"].as_str(), find(&d, &b)["myVote"].as_str()), (Some("for"), Some("against")));
assert_eq!(tally(&d), (Some(0), Some(1)));
// A grouped withdrawal on the held one.
chiara.ok("POST", &votes, Some(json!({ "lines": [{ "proposalId": b, "choice": null }] }))).await;
let d = chiara.doc("essai").await;
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
}
/// While held, a version can only lose votes; lifted, it enters with the votes still
/// there — not with those it had when the objection was raised.
#[tokio::test]
async fn lifted_a_version_enters_only_with_the_votes_still_there() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut zoe = chiara.other();
zoe.login("Zoé", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = zoe.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
// Every member of the group for it, as before the objection: enough to enter.
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
.fetch_all(&state.db)
.await
.expect("members");
assert_eq!(members.len(), 3);
for m in &members {
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
.bind(&b)
.bind(m)
.execute(&state.db)
.await
.expect("vote");
}
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
// While it is held, each one takes back or turns against.
let uri = format!("/api/proposals/{b}/vote");
chiara.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
yvv.ok("POST", &uri, Some(json!({ "choice": null }))).await;
zoe.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
let lifted = zoe.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(lifted["entered"], json!([]), "lifted: no for left, it does not enter");
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &b)["status"].as_str(), find(&d, &b)["votable"].as_bool()), (Some("open"), Some(true)));
assert_eq!(find(&d, &b)["tally"], json!({ "for": 0, "against": 2 }));
}
/// Withdrawing the other side frees the held version at once: with its votes, it /// Withdrawing the other side frees the held version at once: with its votes, it
/// enters then, not at the next pass of the sweeper (relecture of cd7e683). /// enters then, not at the next pass of the sweeper (relecture of cd7e683).
#[tokio::test] #[tokio::test]
@@ -4,7 +4,8 @@
> [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille > [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille
> ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la > ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la
> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch. > coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch.
> Lot F, 2026-10-31. > Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version
> retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ».
**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à **Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à
qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte
@@ -29,9 +30,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 :
- Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis - Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis
l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur, l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur,
c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite. c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite.
- Tant que l'objection tient, la version retenue **ne se vote plus** (vote direct, vote groupé, - Tant que l'objection tient, la version retenue **ne prend plus de « pour »** et **n'entre pas
bulletin de la chaîne : `409 objection`) et **n'entre pas en vigueur**. Les votes déjà faits en vigueur**, quelles que soient ses voix. Yvv, 2026-11-01, à la question « pendant qu'une
restent ; un bulletin scellé avant l'objection et confirmé après compte, sans la faire entrer. objection retient une version, que permet-on ? » : **« Retirer ou "contre" »**. Chacun peut
**retirer son vote** ou **passer à « contre »** ; personne ne peut **ajouter un « pour »** (vote
direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le
vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle
n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant
l'objection et confirmé après compte tel qu'il est signé, sans la faire entrer. Règle :
`ld_engine::objection::may_cast`, appliquée par `DocState::admits`.
- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`). - Une personne n'a qu'une objection debout sur une même paire (`objection_exists`).
- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé** - **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé**
(une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ; (une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ;
@@ -45,14 +52,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 :
message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui). message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui).
- Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de - Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de
trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection. trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection.
Une levée relance l'adoption : la version libérée entre en vigueur si ses voix suffisent. Une levée relance l'adoption : la version libérée entre en vigueur si les voix encore là
suffisent.
- Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`. - Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`.
| Qui | Quoi | Effet | Échéance | | Qui | Quoi | Effet | Échéance |
|---|---|---|---| |---|---|---|---|
| tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | | tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; message à son auteur | — |
| l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle | | l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle |
| la personne qui objecte | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | | la personne qui objecte | lever | la version reprend des « pour », et entre en vigueur si les voix encore là suffisent | — |
| la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur | | la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur |
| personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — | | personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — |
| un administrateur | lever | levée « par un administrateur » | — | | un administrateur | lever | levée « par un administrateur » | — |
@@ -119,6 +127,8 @@ corrections des autres) et `messages`, et sur les révisions la correction d'ori
validée. validée.
**Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la **Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la
dernière arrivée, c'est à elle de s'expliquer. Une personne qui réitère sans fin garde une dernière arrivée, c'est à elle de s'expliquer. Pendant ce temps, ceux qui la soutiennent ne
peuvent pas la renforcer : un nouveau « pour » attend la levée, alors qu'un « contre » ou un
retrait compte tout de suite. Une personne qui réitère sans fin garde une
version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois
jours à paraître, et sur un texte du seed elle attend un administrateur. jours à paraître, et sur un texte du seed elle attend un administrateur.
+10 -7
View File
@@ -231,20 +231,23 @@ la laisse entrer) ; écriture : `crates/ld-server/src/proposals.rs` et `crates/l
### Deux articles qui se répètent ou se contredisent ### Deux articles qui se répètent ou se contredisent
Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le
répète) ou elle le **contredit**, motif à l'appui. **Une objection rend la dernière arrivée des répète) ou elle le **contredit**, motif à l'appui. **Une objection retient la dernière arrivée
deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière des deux versions jusqu'à sa levée** — jamais un texte en vigueur : si la dernière arrivée est
arrivée est en vigueur, c'est l'autre qui est retenue. Les votes déjà faits restent ; la version en vigueur, c'est l'autre qui est retenue. La version retenue n'entre pas en vigueur, quelles que
retenue ne reçoit plus de vote (ni sous l'article, ni dans le panier, ni par la chaîne) et soient ses voix. Pendant l'objection, **on peut retirer son vote ou passer à « contre », jamais
n'entre pas en vigueur. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite, ajouter un « pour »** (Yvv, 01/11/2026 : « Retirer ou "contre" ») — sous l'article, dans le
panier comme par la chaîne : « Tout pour » la saute et le dit, « Tout contre » la prend. Elle ne
peut que perdre des voix tant qu'elle est retenue ; à la levée, elle n'entre qu'avec les voix
encore là. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite,
il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait
l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous
**3 jours**, l'objection est levée. **3 jours**, l'objection est levée.
| Qui | Quoi | Effet | Échéance | | Qui | Quoi | Effet | Échéance |
|---|---|---|---| |---|---|---|---|
| tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | | tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; message à son auteur | — |
| l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle | | l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle |
| la personne qui a fait l'objection | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | | la personne qui a fait l'objection | lever | la version reprend des « pour », et entre en vigueur si les voix encore là suffisent | — |
| la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur | | la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur |
| personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — | | personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — |
| un administrateur de trustinWallet | lever | levée « par un administrateur » | — | | un administrateur de trustinWallet | lever | levée « par un administrateur » | — |
@@ -40,7 +40,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| 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 : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles · <section> » (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px |
| C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » | | 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 » |
| 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 : les « pour » demandés au-delà de C, à 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 : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » |
| C28 | Objection « redit » | 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 ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera recharge | « Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée. » ; chez Oscar, « « … » redit « Unicité du compte » », « cette version ne se vote plus », « Lever l'objection » ; chez Vera, « Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent. », « Elle redit « Unicité du compte ». », pas de « Pour », la ligne marquée « non votable : objection » ; 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 | | 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 |
| C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » | | C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » |
| 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 » |
@@ -81,3 +81,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts | | 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
| 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | 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) |
+46 -8
View File
@@ -37,11 +37,11 @@ async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` }); await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason); await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
await panel.getByRole('button', { name: "Envoyer l'objection" }).click(); await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible(); await expect(page.getByText("Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.")).toBeVisible();
return panel; return panel;
} }
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => { test('C28 — « redit » : plus de « pour » sur la dernière arrivée (contre ou retrait), l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
const aude = await person(browser, 'Aude'); const aude = await person(browser, 'Aude');
const oscar = await person(browser, 'Oscar'); const oscar = await person(browser, 'Oscar');
const vera = await person(browser, 'Vera'); const vera = await person(browser, 'Vera');
@@ -51,19 +51,57 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
// Oscar: it repeats « Unicité du compte ». // Oscar: it repeats « Unicité du compte ».
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.'); const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`); await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible(); await expect(panel.getByText('personne ne peut plus voter pour cette version')).toBeVisible();
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible(); await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
await shots(oscar, 'f1-objection-vue-par-qui-objecte'); await shots(oscar, 'f1-objection-vue-par-qui-objecte');
// Vera, who votes: held back, said so, nothing to press; the list marks it. // Vera, who votes (Yvv, 2026-11-01: « Retirer ou "contre" »): held back, said so;
// « Contre » and the withdrawal, never « Pour »; the list marks it.
await vera.goto(`${TEST_DOC}?vote=${id}`); await vera.goto(`${TEST_DOC}?vote=${id}`);
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` }); const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible(); await expect(
fold.getByText("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à."),
).toBeVisible();
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
const choice = fold.getByRole('group', { name: 'Ton vote', exact: true });
await expect(choice.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection'); await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('retenue par une objection');
await fold.scrollIntoViewIfNeeded(); await fold.scrollIntoViewIfNeeded();
await shots(vera, 'f2-objection-vue-par-qui-vote'); await shots(vera, 'f2-objection-vue-par-qui-vote');
// « Contre », then the withdrawal: the version can only lose votes; « Pour » never comes back.
await choice.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(vera.getByText('Vote enregistré.')).toBeVisible();
await expect(fold.getByText('Ton vote : contre')).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 1 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await choice.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(vera.getByText('Vote retiré.')).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 0 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the basket: « Contre » or the withdrawal, never « Pour »; « Tout pour » skips it
// and says so, « Tout contre » takes it.
await vera.goto(`${TEST_DOC}?onglet=panier`);
const basket = vera.locator('aside.panel');
await expect(basket.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
const pick = basket.getByRole('group', { name: `Ton choix pour « ${title} »` });
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(pick.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
const heldRow = basket.locator('.version.held').filter({ has: vera.getByRole('group', { name: `Ton choix pour « ${title} »` }) });
await expect(heldRow).toContainText('une objection la retient : contre ou retrait, pas de « pour »');
const whole = basket.getByRole('group', { name: 'Tout le document', exact: true });
await whole.getByRole('button', { name: 'Tout pour' }).click();
await expect(basket.getByText(/^Tu as tout mis pour, sauf \d+ versions? qu'une objection retient : sur elles?, tu peux voter contre ou retirer ton vote\.$/)).toBeVisible();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'false');
await shots(vera, 'g1-panier-tout-pour-la-saute');
await whole.getByRole('button', { name: 'Tout contre' }).click();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'true');
await heldRow.scrollIntoViewIfNeeded();
await shots(vera, 'g2-panier-tout-contre-la-prend');
await whole.getByRole('button', { name: 'Vider' }).click();
await expect(basket.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Aude: a message, and the way to answer — modify the article repeated. // Aude: a message, and the way to answer — modify the article repeated.
await aude.goto(TEST_DOC); await aude.goto(TEST_DOC);
@@ -99,8 +137,8 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
await expect(answered.locator('.btn')).toHaveCount(0); await expect(answered.locator('.btn')).toHaveCount(0);
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible(); await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
// Vera: it is voted on again. // Vera: it is voted on again, « Pour » too.
await vera.reload(); await vera.goto(`${TEST_DOC}?vote=${id}`);
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
}); });
+45 -9
View File
@@ -4,6 +4,10 @@
// whole document — but never an article whose versions compete: there, one // whole document — but never an article whose versions compete: there, one
// chooses one's version. Then one gesture sends it all: one write on the // chooses one's version. Then one gesture sends it all: one write on the
// server, or one transaction on the Ğ1 chain. // server, or one transaction on the Ğ1 chain.
//
// A version held back by an objection (ADR 0010) takes against or a withdrawal,
// never a « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour »
// skips it, « tout contre » takes it.
import type { Choice, Modules, Proposal, Unit } from './types'; import type { Choice, Modules, Proposal, Unit } from './types';
@@ -17,7 +21,10 @@ export interface Slot {
versions: Proposal[]; versions: Proposal[];
/** Open changes of its inertia: another question, never filled by a grouped gesture. */ /** Open changes of its inertia: another question, never filled by a grouped gesture. */
inertia: Proposal[]; inertia: Proposal[];
/** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */ /**
* Versions held back by an objection (ADR 0010): apart from those in competition,
* against or a withdrawal only until it is lifted — never a « for ».
*/
held: Proposal[]; held: Proposal[];
} }
@@ -68,24 +75,55 @@ export function sections(units: Unit[], modules: Modules = { title: null, orders
return out; return out;
} }
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */ /**
* « Tout pour » / « tout contre » over some articles: only those with a single version.
* A version held back by an objection: « tout contre » takes it, « tout pour » skips it.
*/
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks { export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
const next = { ...picks }; const next = { ...picks };
for (const s of slots) { for (const s of slots) {
const [only] = s.versions; const [only] = s.versions;
if (only && s.versions.length === 1) next[only.id] = choice; if (only && s.versions.length === 1) next[only.id] = choice;
if (choice === 'against') for (const p of s.held) next[p.id] = choice;
} }
return next; return next;
} }
/** The versions held back by an objection among these articles: those « tout pour » skips. */
export function heldIn(slots: Slot[]): Proposal[] {
return slots.flatMap((s) => s.held);
}
/** Articles of several versions where no version is chosen yet: the interface shows them. */ /** Articles of several versions where no version is chosen yet: the interface shows them. */
export function waiting(slots: Slot[], picks: Picks): Slot[] { export function waiting(slots: Slot[], picks: Picks): Slot[] {
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks)); return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
} }
/**
* The versions one may vote now, and how: `free`, any choice; `held`, held back by an
* objection — against or a withdrawal only.
*/
export type Votable = Map<string, 'free' | 'held'>;
/** The versions one may vote now: those a choice of the basket is sent for. */ /** The versions one may vote now: those a choice of the basket is sent for. */
export function votableIds(all: Section[]): Set<string> { export function votableIds(all: Section[]): Votable {
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); const out: Votable = new Map();
for (const x of all.flatMap((s) => s.slots)) {
for (const p of [...x.versions, ...x.inertia]) out.set(p.id, 'free');
for (const p of x.held) out.set(p.id, 'held');
}
return out;
}
/** May this choice be sent for this version now? Never a « for » on a held one. */
export function admits(known: Votable, id: string, choice: Choice): boolean {
const way = known.get(id);
return way === 'free' || (way === 'held' && choice !== 'for');
}
/** The choices of the basket that may be sent now: a version gone, or a « for » on a held one, is left out. */
export function kept(picks: Picks, known: Votable): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id, c]) => admits(known, id, c)));
} }
/** Empties the basket of these articles only. */ /** Empties the basket of these articles only. */
@@ -94,11 +132,9 @@ export function clearScope(slots: Slot[], picks: Picks): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id))); return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
} }
/** What is sent: one line per version still in the document; withdrawing is a null choice. */ /** What is sent: one line per choice that may be sent now (`kept`); withdrawing is a null choice. */
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] { export function lines(picks: Picks, known: Votable): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(picks) return Object.entries(kept(picks, known)).map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
.filter(([id]) => known.has(id))
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
} }
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } { export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
+41 -8
View File
@@ -3,9 +3,11 @@
// section entière, ou un article avec son titre, ou séparément »): choices // section entière, ou un article avec son titre, ou séparément »): choices
// article by article, « tout pour » / « tout contre » for a section or the whole // article by article, « tout pour » / « tout contre » for a section or the whole
// document — never for an article whose versions compete —, then one gesture. // document — never for an article whose versions compete —, then one gesture.
// A version held back by an objection is shown, with nothing to choose. // A version held back by an objection takes against or a withdrawal, never a
// « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour » skips it
// and says so, « tout contre » takes it.
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; import { clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
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 { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels'; import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
@@ -38,12 +40,14 @@
const slots = $derived(all.flatMap((s) => s.slots)); const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(votableIds(all)); const known = $derived(votableIds(all));
const toSend = $derived(lines(basket.picks, known)); const toSend = $derived(lines(basket.picks, known));
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id))))); const tally = $derived(counts(kept(basket.picks, known)));
const left = $derived(waiting(slots, basket.picks)); const left = $derived(waiting(slots, basket.picks));
const open = $derived(!!access?.open); const open = $derived(!!access?.open);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let list: HTMLElement | undefined = $state(); let list: HTMLElement | undefined = $state();
/** After « tout pour »: where, and how many versions held back by an objection it left aside. */
let skipped = $state<{ key: string | null; n: number } | null>(null);
onMount(() => { onMount(() => {
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' }); if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
@@ -69,6 +73,14 @@
const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? [])); const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? []));
/** « Tout pour » / « tout contre » / « vider » over a scope; « tout pour » says what it skipped. */
function gesture(key: string | null, what: 'for' | 'against' | 'clear') {
const within = scope(key);
basket.set(what === 'clear' ? clearScope(within, basket.picks) : fill(within, what, basket.picks));
const n = what === 'for' ? heldIn(within).length : 0;
skipped = n ? { key, n } : null;
}
async function send() { async function send() {
error = ''; error = '';
if (!toSend.length) return; if (!toSend.length) return;
@@ -91,12 +103,18 @@
{#snippet bulk(key: string | null, label: string)} {#snippet bulk(key: string | null, label: string)}
<div class="bulk" role="group" aria-label={label}> <div class="bulk" role="group" aria-label={label}>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button> <button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'for')}>{t('basket.for')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button> <button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'against')}>{t('basket.against')}</button>
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button> <button class="btn quiet small" type="button" onclick={() => gesture(key, 'clear')}>{t('basket.clear')}</button>
</div> </div>
{/snippet} {/snippet}
{#snippet skippedNote(key: string | null)}
{#if skipped && skipped.key === key}
<p class="note" role="status">{i18n.plural('basket.held.skipped', skipped.n)}</p>
{/if}
{/snippet}
<section class="basket" aria-labelledby="basket-title"> <section class="basket" aria-labelledby="basket-title">
<h3 id="basket-title">{t('basket.title')}</h3> <h3 id="basket-title">{t('basket.title')}</h3>
<p class="muted">{t('basket.intro')}</p> <p class="muted">{t('basket.intro')}</p>
@@ -108,6 +126,7 @@
<span class="eyebrow">{t('basket.doc')}</span> <span class="eyebrow">{t('basket.doc')}</span>
{@render bulk('*', t('basket.doc'))} {@render bulk('*', t('basket.doc'))}
</div> </div>
{@render skippedNote('*')}
{#if left.length} {#if left.length}
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p> <p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
{/if} {/if}
@@ -121,6 +140,7 @@
<h4 class="eyebrow">{label}</h4> <h4 class="eyebrow">{label}</h4>
{@render bulk(s.key, label)} {@render bulk(s.key, label)}
</header> </header>
{@render skippedNote(s.key)}
{/if} {/if}
<ul> <ul>
{#each s.slots as slot (slot.unit.id)} {#each s.slots as slot (slot.unit.id)}
@@ -152,9 +172,20 @@
<div class="version held"> <div class="version held">
<span class="about"> <span class="about">
<span class="label">{labelOf(v, slot)}</span> <span class="label">{labelOf(v, slot)}</span>
<span class="faint small">{t('tally.short', { for: v.tally.for, against: v.tally.against })}</span> <span class="faint small">
{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}
</span>
<span class="held-note">{t('basket.held')}</span>
</span> </span>
<span class="held-note">{t('basket.held')}</span> <ChoicePicker
held
value={basket.picks[v.id]}
voted={!!v.myVote}
label={t('basket.pick', { title: slot.versions.length || slot.held.length > 1 ? `${title} — ${labelOf(v, slot)}` : title })}
disabled={!open}
onpick={(c) => basket.pick(v.id, c)}
/>
</div> </div>
{/each} {/each}
</li> </li>
@@ -297,6 +328,8 @@
color: var(--ink-soft); color: var(--ink-soft);
} }
.held-note { .held-note {
justify-self: start;
margin-top: 0.2rem;
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
border-radius: var(--r-pill); border-radius: var(--r-pill);
background: var(--ochre-wash); background: var(--ochre-wash);
+15 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
// One choice of the basket: for, against, or withdraw one's vote (when there is // One choice of the basket: for, against, or withdraw one's vote (when there is
// one). Pressing the chosen one again takes the version out of the basket. // one). Pressing the chosen one again takes the version out of the basket. A
// version held back by an objection offers no « for » (Yvv, 2026-11-01).
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import type { Choice } from '$lib/types'; import type { Choice } from '$lib/types';
@@ -8,13 +9,24 @@
value, value,
voted, voted,
label, label,
held = false,
disabled = false, disabled = false,
onpick, onpick,
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props(); }: {
value: Choice | undefined;
voted: boolean;
label: string;
/** Held back by an objection: against or a withdrawal only. */
held?: boolean;
disabled?: boolean;
onpick: (c: Choice) => void;
} = $props();
</script> </script>
<span class="picker" role="group" aria-label={label}> <span class="picker" role="group" aria-label={label}>
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button> {#if !held}
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
{/if}
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button> <button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
{#if voted || value === 'none'} {#if voted || value === 'none'}
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button> <button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
+6 -4
View File
@@ -4,7 +4,8 @@
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals — // toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
// 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). The // objection says so, and tells each one what is theirs to do (ADR 0010); it
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The
// rest of a version (typos, translations, objections, history) lives in the // rest of a version (typos, translations, objections, history) lives in the
// panel, tab « Détails ». // panel, tab « Détails ».
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -200,13 +201,14 @@
{#if !live} {#if !live}
<p class="muted">{t('vote.closed')}</p> <p class="muted">{t('vote.closed')}</p>
{:else if p.blocked}
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
{:else if access && !access.open} {:else 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>
{:else if access} {:else if access}
<div class="choice" role="group" aria-label={t('vote.yours')}> <div class="choice" role="group" aria-label={t('vote.yours')}>
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button> <!-- Held back by an objection: against or a withdrawal, never a « for » (Yvv, 2026-11-01). -->
{#if !p.blocked}
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
{/if}
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button> <button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if} {#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
</div> </div>
+12 -10
View File
@@ -142,7 +142,7 @@ export const en: Messages = {
'deep.addition': 'new article', 'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”', 'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia to {level}', 'deep.inertia': 'inertia to {level}',
'deep.held': 'not open to vote: objection', 'deep.held': 'held back by an objection',
'deep.mine': 'your proposal', 'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming', 'deep.stale': 'needs reconfirming',
'deep.wellrated': 'well-rated', 'deep.wellrated': 'well-rated',
@@ -231,7 +231,7 @@ export const en: Messages = {
'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}',
'vote.private': 'Nobody sees your vote: only the totals are visible.', 'vote.private': 'Nobody sees your vote: only the totals are visible.',
'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.', 'vote.held': 'An objection is holding this version back: until it is lifted, nobody can vote for it any more. You can vote against it or take your vote back; once lifted, it enters into force only with the votes still there.',
'vote.held.redit': 'It repeats “{other}”.', 'vote.held.redit': 'It repeats “{other}”.',
'vote.held.contredit': 'It contradicts “{other}”.', 'vote.held.contredit': 'It contradicts “{other}”.',
'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', 'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
@@ -289,7 +289,9 @@ export const en: Messages = {
'basket.sent.other': '{n} votes saved.', 'basket.sent.other': '{n} votes saved.',
'basket.section': 'Vote on this section', 'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}', 'basket.mine': 'your vote: {choice}',
'basket.held': 'not open to vote: an objection holds it back', 'basket.held': 'an objection holds it back: against or withdraw, no “for”',
'basket.held.skipped.one': 'You put everything for, except {n} version an objection holds back: on it, you can vote against or take your vote back.',
'basket.held.skipped.other': 'You put everything for, except {n} versions an objection holds back: on them, you can vote against or take your vote back.',
'sign.title': 'Vote with your Ğ1 wallet', 'sign.title': 'Vote with your Ğ1 wallet',
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
@@ -443,12 +445,12 @@ export const en: Messages = {
'objection.reason.redit': 'How these two texts say the same thing', 'objection.reason.redit': 'How these two texts say the same thing',
'objection.reason.contredit': 'How these two texts contradict each other', 'objection.reason.contredit': 'How these two texts contradict each other',
'objection.send': 'Send the objection', 'objection.send': 'Send the objection',
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.', 'objection.sent': 'Objection saved: until it is lifted, nobody can vote for the later of the two versions any more.',
'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.', 'objection.note': 'An objection holds back the later of the two versions until it is lifted — never a text in force: nobody can vote for it any more, anyone can vote against it or take their vote back. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.',
'objection.says.redit': '“{held}” repeats “{other}”', 'objection.says.redit': '“{held}” repeats “{other}”',
'objection.says.contredit': '“{held}” contradicts “{other}”', 'objection.says.contredit': '“{held}” contradicts “{other}”',
'objection.by': 'objection by {name}', 'objection.by': 'objection by {name}',
'objection.held.here': 'this version cannot be voted on', 'objection.held.here': 'nobody can vote for this version any more',
'objection.phase.author': "waiting for the author's answer", 'objection.phase.author': "waiting for the author's answer",
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself', 'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
'objection.answer.revision': 'answer: the held text was reworded', 'objection.answer.revision': 'answer: the held text was reworded',
@@ -477,9 +479,9 @@ export const en: Messages = {
'messages.open': 'See it in the text', 'messages.open': 'See it in the text',
'messages.topic.objection': 'Objection', 'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Typo', 'messages.topic.typo': 'Typo',
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.', 'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.', 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.', 'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. Answer again.',
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.', 'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.', 'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.', 'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
@@ -638,7 +640,7 @@ export const en: Messages = {
'error.not_a_title': 'This module is not the title of the document.', 'error.not_a_title': 'This module is not the title of the document.',
'error.same_title': 'This is already the title in force.', 'error.same_title': 'This is already the title in force.',
'error.section': 'This section does not exist in this document.', 'error.section': 'This section does not exist in this document.',
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.', 'error.objection': 'An objection is holding this version back: until it is lifted, you can vote against it or take your vote back, not vote for it.',
'error.kind': 'Say whether this text repeats the other or contradicts it.', 'error.kind': 'Say whether this text repeats the other or contradicts it.',
'error.other': 'Choose another article, in force or proposed.', 'error.other': 'Choose another article, in force or proposed.',
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.', 'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
+12 -10
View File
@@ -142,7 +142,7 @@ export const es: Messages = {
'deep.addition': 'artículo nuevo', 'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»', 'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia a {level}', 'deep.inertia': 'inercia a {level}',
'deep.held': 'no votable: objeción', 'deep.held': 'retenida por una objeción',
'deep.mine': 'tu propuesta', 'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar', 'deep.stale': 'por reconfirmar',
'deep.wellrated': 'bien valorada', 'deep.wellrated': 'bien valorada',
@@ -231,7 +231,7 @@ export const es: Messages = {
'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}',
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.', 'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.', 'vote.held': 'Una objeción retiene esta versión: hasta que se levante, nadie puede votar a favor. Puedes votar en contra o retirar tu voto; al levantarse, solo entra en vigor con los votos que sigan ahí.',
'vote.held.redit': 'Repite «{other}».', 'vote.held.redit': 'Repite «{other}».',
'vote.held.contredit': 'Contradice «{other}».', 'vote.held.contredit': 'Contradice «{other}».',
'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', 'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
@@ -289,7 +289,9 @@ export const es: Messages = {
'basket.sent.other': '{n} votos registrados.', 'basket.sent.other': '{n} votos registrados.',
'basket.section': 'Votar esta sección', 'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}', 'basket.mine': 'tu voto: {choice}',
'basket.held': 'no votable: una objeción la retiene', 'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»',
'basket.held.skipped.one': 'Lo pusiste todo a favor, salvo {n} versión que una objeción retiene: en ella, puedes votar en contra o retirar tu voto.',
'basket.held.skipped.other': 'Lo pusiste todo a favor, salvo {n} versiones que una objeción retiene: en ellas, puedes votar en contra o retirar tu voto.',
'sign.title': 'Votar con tu monedero Ğ1', 'sign.title': 'Votar con tu monedero Ğ1',
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
@@ -443,12 +445,12 @@ export const es: Messages = {
'objection.reason.redit': 'En qué dicen lo mismo estos dos textos', 'objection.reason.redit': 'En qué dicen lo mismo estos dos textos',
'objection.reason.contredit': 'En qué se contradicen estos dos textos', 'objection.reason.contredit': 'En qué se contradicen estos dos textos',
'objection.send': 'Enviar la objeción', 'objection.send': 'Enviar la objeción',
'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.', 'objection.sent': 'Objeción registrada: hasta que se levante, nadie puede votar a favor de la más reciente de las dos versiones.',
'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.', 'objection.note': 'Una objeción retiene la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor: nadie puede votar a favor, cada cual puede votar en contra o retirar su voto. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.',
'objection.says.redit': '«{held}» repite «{other}»', 'objection.says.redit': '«{held}» repite «{other}»',
'objection.says.contredit': '«{held}» contradice «{other}»', 'objection.says.contredit': '«{held}» contradice «{other}»',
'objection.by': 'objeción de {name}', 'objection.by': 'objeción de {name}',
'objection.held.here': 'esta versión no se vota', 'objection.held.here': 'nadie puede votar a favor de esta versión',
'objection.phase.author': 'en espera de la respuesta del autor', 'objection.phase.author': 'en espera de la respuesta del autor',
'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola', 'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
'objection.answer.revision': 'respuesta: el texto retenido se reformuló', 'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
@@ -477,9 +479,9 @@ export const es: Messages = {
'messages.open': 'Verlo en el texto', 'messages.open': 'Verlo en el texto',
'messages.topic.objection': 'Objeción', 'messages.topic.objection': 'Objeción',
'messages.topic.typo': 'Errata', 'messages.topic.typo': 'Errata',
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.', 'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.', 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.', 'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». Responde de nuevo.',
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.', 'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».', 'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.', 'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
@@ -638,7 +640,7 @@ export const es: Messages = {
'error.not_a_title': 'Este módulo no es el título del documento.', 'error.not_a_title': 'Este módulo no es el título del documento.',
'error.same_title': 'Ya es el título en vigor.', 'error.same_title': 'Ya es el título en vigor.',
'error.section': 'Esta sección no existe en este documento.', 'error.section': 'Esta sección no existe en este documento.',
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.', 'error.objection': 'Una objeción retiene esta versión: hasta que se levante, puedes votar en contra o retirar tu voto, no votar a favor.',
'error.kind': 'Di si este texto repite el otro o lo contradice.', 'error.kind': 'Di si este texto repite el otro o lo contradice.',
'error.other': 'Elige otro artículo, en vigor o propuesto.', 'error.other': 'Elige otro artículo, en vigor o propuesto.',
'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.', 'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.',
+12 -10
View File
@@ -145,7 +145,7 @@ export const fr = {
'deep.addition': 'nouvel article', 'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »', 'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie vers {level}', 'deep.inertia': 'inertie vers {level}',
'deep.held': 'non votable : objection', 'deep.held': 'retenue par une objection',
'deep.mine': 'ta proposition', 'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer', 'deep.stale': 'à reconfirmer',
'deep.wellrated': 'bien cotée', 'deep.wellrated': 'bien cotée',
@@ -234,7 +234,7 @@ export const fr = {
'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}',
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.', 'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.", 'vote.held': "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à.",
'vote.held.redit': 'Elle redit « {other} ».', 'vote.held.redit': 'Elle redit « {other} ».',
'vote.held.contredit': 'Elle contredit « {other} ».', 'vote.held.contredit': 'Elle contredit « {other} ».',
'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", 'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
@@ -292,7 +292,9 @@ export const fr = {
'basket.sent.other': '{n} votes enregistrés.', 'basket.sent.other': '{n} votes enregistrés.',
'basket.section': 'Voter cette section', 'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}', 'basket.mine': 'ton vote : {choice}',
'basket.held': 'non votable : une objection la retient', 'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »',
'basket.held.skipped.one': "Tu as tout mis pour, sauf {n} version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote.",
'basket.held.skipped.other': "Tu as tout mis pour, sauf {n} versions qu'une objection retient : sur elles, tu peux voter contre ou retirer ton vote.",
'sign.title': 'Voter avec ton portefeuille Ğ1', 'sign.title': 'Voter avec ton portefeuille Ğ1',
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
@@ -446,12 +448,12 @@ export const fr = {
'objection.reason.redit': 'En quoi ces deux textes disent la même chose', 'objection.reason.redit': 'En quoi ces deux textes disent la même chose',
'objection.reason.contredit': 'En quoi ces deux textes se contredisent', 'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
'objection.send': "Envoyer l'objection", 'objection.send': "Envoyer l'objection",
'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.", 'objection.sent': "Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.",
'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.", 'objection.note': "Une objection retient la dernière arrivée des deux versions jusqu'à sa levée — jamais un texte en vigueur : personne ne peut plus voter pour elle, chacun peut voter contre ou retirer son vote. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.",
'objection.says.redit': '« {held} » redit « {other} »', 'objection.says.redit': '« {held} » redit « {other} »',
'objection.says.contredit': '« {held} » contredit « {other} »', 'objection.says.contredit': '« {held} » contredit « {other} »',
'objection.by': 'objection de {name}', 'objection.by': 'objection de {name}',
'objection.held.here': 'cette version ne se vote plus', 'objection.held.here': 'personne ne peut plus voter pour cette version',
'objection.phase.author': "en attente d'une réponse de l'auteur", 'objection.phase.author': "en attente d'une réponse de l'auteur",
'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même", 'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même",
'objection.answer.revision': 'réponse : le texte retenu a été reformulé', 'objection.answer.revision': 'réponse : le texte retenu a été reformulé',
@@ -480,9 +482,9 @@ export const fr = {
'messages.open': 'Voir dans le texte', 'messages.open': 'Voir dans le texte',
'messages.topic.objection': 'Objection', 'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Coquille', 'messages.topic.typo': 'Coquille',
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.", 'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.", 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.', 'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». Réponds à nouveau.',
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.', 'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».', 'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.', 'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
@@ -641,7 +643,7 @@ export const fr = {
'error.not_a_title': "Ce module n'est pas le titre du document.", 'error.not_a_title': "Ce module n'est pas le titre du document.",
'error.same_title': "C'est déjà le titre en vigueur.", 'error.same_title': "C'est déjà le titre en vigueur.",
'error.section': "Cette section n'existe pas dans ce document.", 'error.section': "Cette section n'existe pas dans ce document.",
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.", 'error.objection': "Une objection retient cette version : jusqu'à sa levée, tu peux voter contre ou retirer ton vote, pas voter pour.",
'error.kind': "Dis si ce texte redit l'autre ou le contredit.", 'error.kind': "Dis si ce texte redit l'autre ou le contredit.",
'error.other': 'Choisis un autre article, en vigueur ou proposé.', 'error.other': 'Choisis un autre article, en vigueur ou proposé.',
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.', 'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
+9 -6
View File
@@ -1,15 +1,18 @@
// The basket of the reader: the choices not sent yet, kept for the tab (a reload // The basket of the reader: the choices not sent yet, kept for the tab (a reload
// or a look at another article does not lose them), one basket per document. // or a look at another article does not lose them), one basket per document.
import type { Picks } from './basket'; import { kept, type Picks, type Votable } from './basket';
import type { Choice } from './types'; import type { Choice } from './types';
export class BasketState { export class BasketState {
picks = $state<Picks>({}); picks = $state<Picks>({});
#key = ''; #key = '';
/** Takes back the basket of this document, keeping only the versions still open to vote. */ /**
load(docId: string, known: Set<string>) { * Takes back the basket of this document, keeping only the choices still open: a
* version gone leaves it, and so does a « for » on a version an objection holds back.
*/
load(docId: string, known: Votable) {
const key = `ld:basket:${docId}`; const key = `ld:basket:${docId}`;
if (key === this.#key) { if (key === this.#key) {
this.#keep(known); this.#keep(known);
@@ -27,9 +30,9 @@ export class BasketState {
this.#keep(known); this.#keep(known);
} }
#keep(known: Set<string>) { #keep(known: Votable) {
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id))); const still = kept(this.picks, known);
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept); if (Object.keys(still).length !== Object.keys(this.picks).length) this.set(still);
} }
set(picks: Picks) { set(picks: Picks) {
@@ -29,7 +29,7 @@
void doc.load(group, slug); void doc.load(group, slug);
}); });
// The basket of this document, kept for the tab; versions no longer open to vote // The basket of this document, kept for the tab; versions no longer open to vote
// (or held back by an objection) leave it. // leave it, and so does a « for » on a version held back by an objection.
$effect(() => { $effect(() => {
const v = doc.view; const v = doc.view;
if (!v) return; if (!v) return;
+24 -10
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket'; import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types'; import type { Choice, Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal { function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return { return {
@@ -84,7 +84,7 @@ describe('the basket of a vote', () => {
}); });
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => { it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']); const known = votableIds(sections([I1, E2, E4]));
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([ expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' }, { proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null }, { proposalId: 'e2', choice: null },
@@ -93,8 +93,9 @@ describe('the basket of a vote', () => {
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 }); expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
}); });
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => { it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => {
// E5 has the text in force and a rival held back; E6 is a new article, held back. // Yvv, 2026-11-01: « Retirer ou "contre" ». E5 has the text in force and a rival
// held back; E6 is a new article, held back.
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [ const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
prop('e5r', 'E5', { blocked: true, votable: false }), prop('e5r', 'E5', { blocked: true, votable: false }),
]); ]);
@@ -103,15 +104,28 @@ describe('the basket of a vote', () => {
const [slotE5, slotE6] = s[0]?.slots ?? []; const [slotE5, slotE6] = s[0]?.slots ?? [];
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']); expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']); expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
// A held article stays in sight, with nothing to choose. // A held article stays in sight, apart from the versions in competition.
expect(slotE6?.versions).toEqual([]); expect(slotE6?.versions).toEqual([]);
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']); expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
const all = s.flatMap((x) => x.slots); const all = s.flatMap((x) => x.slots);
// The held rival does not compete now: the text in force is the only choice. // The held rival does not compete now: the text in force is the only version to choose.
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(waiting(all, {})).toEqual([]); expect(waiting(all, {})).toEqual([]);
// A choice kept from before the objection is not sent. // « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them.
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]); expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(heldIn(all).map((p) => p.id)).toEqual(['e5r', 'e6']);
expect(fill(all, 'against', {})).toEqual({ e5: 'against', e5r: 'against', e6: 'against' });
// What may be chosen: anything on a free version; against or withdrawing on a held one.
const known = votableIds(s);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5', c as Choice))).toEqual([true, true, true]);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5r', c as Choice))).toEqual([false, true, true]);
expect(admits(known, 'gone', 'against')).toBe(false);
// A « for » kept from before the objection is neither kept nor sent; against and withdrawing are.
expect(kept({ e5: 'for', e5r: 'for', e6: 'against', gone: 'none' }, known)).toEqual({ e5: 'for', e6: 'against' });
expect(lines({ e5: 'for', e5r: 'for', e6: 'none' }, known)).toEqual([
{ proposalId: 'e5', choice: 'for' },
{ proposalId: 'e6', choice: null },
]);
expect(lines({ e5r: 'against' }, known)).toEqual([{ proposalId: 'e5r', choice: 'against' }]);
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
}); });
}); });