From c640413a700cc51022e01549b9dfdc079af6e242 Mon Sep 17 00:00:00 2001 From: Yvv Date: Fri, 30 Oct 2026 09:07:39 +0100 Subject: [PATCH 1/3] =?UTF-8?q?v3=20:=20D=C3=A9cisions=20et=20=C2=AB=20?= =?UTF-8?q?=C3=80=20venir=20=C2=BB=20c=C3=B4t=C3=A9=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ld-engine/pending : un module (article) reste à voter tant qu'aucune version n'a été adoptée par un vote (seed a minima non confirmé, ou pas de version en vigueur et au moins une version ouverte) ; les variantes comptent pour un ; « sans ton vote » = aucun vote valide sur aucune version. - overview.rs : GET /api/groups/{slug} ajoute « upcoming » (docs, mandats, protocoles), lu au démarrage dans seeds/upcoming/.json, trilingue, servi seulement à qui ouvre le groupe ; GET /api/groups/{slug}/decisions, une ligne par doc avec toVote et withoutMyVote (si la personne vote). - Retour après connexion par défaut : « / » au lieu de « /groupes ». Co-Authored-By: Claude Opus 5.5 --- crates/ld-engine/src/lib.rs | 1 + crates/ld-engine/src/pending.rs | 95 ++++++++++++ crates/ld-server/src/auth.rs | 14 +- crates/ld-server/src/lib.rs | 9 +- crates/ld-server/src/overview.rs | 222 ++++++++++++++++++++++++++++ crates/ld-server/tests/overview.rs | 224 +++++++++++++++++++++++++++++ seeds/upcoming/monnaie-libre.json | 40 ++++++ 7 files changed, 596 insertions(+), 9 deletions(-) create mode 100644 crates/ld-engine/src/pending.rs create mode 100644 crates/ld-server/src/overview.rs create mode 100644 crates/ld-server/tests/overview.rs create mode 100644 seeds/upcoming/monnaie-libre.json diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 9a8b818..616a534 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod assist; pub mod compose; pub mod edit; pub mod names; +pub mod pending; pub mod threshold; pub use threshold::{FormulaParams, InertiaPreset, Tally}; diff --git a/crates/ld-engine/src/pending.rs b/crates/ld-engine/src/pending.rs new file mode 100644 index 0000000..d3c1b72 --- /dev/null +++ b/crates/ld-engine/src/pending.rs @@ -0,0 +1,95 @@ +//! What is left to vote in a document — the line of « Décisions ». +//! +//! A module is an article (a unit). It stays **to vote** as long as no version +//! of it was adopted by a vote: its version in force is a seed a minima not yet +//! confirmed, or it has no version in force and at least one open version. +//! Variants of an article count once. For a person who votes, a module is +//! **without their vote** when they hold no valid vote (one not « to +//! reconfirm ») on any of its versions. + +/// One article, as one person sees it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Module { + /// The version in force, if any: `Some(true)` when a vote adopted or + /// confirmed it, `Some(false)` for a seed a minima. + pub in_force: Option, + /// Open versions of the article (proposals to replace, add or remove it). + pub open: u32, + /// The person holds a valid vote on one of its versions, in force or open. + pub voted: bool, +} + +impl Module { + pub fn to_vote(&self) -> bool { + match self.in_force { + Some(adopted) => !adopted, + None => self.open > 0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Pending { + /// Modules not yet adopted by a vote. + pub to_vote: u32, + /// Among them, those where the person has no valid vote. + pub without_my_vote: u32, +} + +pub fn pending(modules: &[Module]) -> Pending { + modules.iter().filter(|m| m.to_vote()).fold(Pending::default(), |acc, m| Pending { + to_vote: acc.to_vote + 1, + without_my_vote: acc.without_my_vote + u32::from(!m.voted), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED: Option = Some(false); + const ADOPTED: Option = Some(true); + + fn m(in_force: Option, open: u32, voted: bool) -> Module { + Module { in_force, open, voted } + } + + #[test] + fn a_seed_a_minima_stays_to_vote_until_a_vote_confirms_it() { + assert!(m(SEED, 0, false).to_vote()); + assert!(m(SEED, 2, true).to_vote()); + assert!(!m(ADOPTED, 0, false).to_vote()); + } + + #[test] + fn an_adopted_article_is_no_longer_to_vote_even_with_rivals() { + assert!(!m(ADOPTED, 3, false).to_vote()); + } + + #[test] + fn an_empty_slot_is_to_vote_only_with_an_open_version() { + assert!(m(None, 1, false).to_vote()); + assert!(!m(None, 0, false).to_vote()); + } + + #[test] + fn variants_count_once_and_my_vote_on_any_version_counts() { + // Seed of the certification act: 3 seeds a minima, 30 open articles (two with a variant). + let mut modules = vec![m(SEED, 0, false); 3]; + modules.extend(vec![m(None, 1, false); 28]); + modules.extend(vec![m(None, 2, false); 2]); + assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 33 }); + modules[0].voted = true; + modules[31].voted = true; + assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 31 }); + // Once adopted, an article leaves both counts, voted or not. + modules[1].in_force = ADOPTED; + modules[31].in_force = ADOPTED; + assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 }); + } + + #[test] + fn nothing_to_vote_in_an_empty_document() { + assert_eq!(pending(&[]), Pending::default()); + } +} diff --git a/crates/ld-server/src/auth.rs b/crates/ld-server/src/auth.rs index e226564..6019220 100644 --- a/crates/ld-server/src/auth.rs +++ b/crates/ld-server/src/auth.rs @@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String { { p.to_owned() } - _ => "/groupes".to_owned(), + _ => "/".to_owned(), } } @@ -577,12 +577,12 @@ mod tests { #[test] fn return_paths_stay_local() { assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre"); - assert_eq!(safe_return(Some("//evil.example")), "/groupes"); - assert_eq!(safe_return(Some("https://evil.example")), "/groupes"); - assert_eq!(safe_return(Some("/\\evil")), "/groupes"); - assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes"); - assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes"); - assert_eq!(safe_return(None), "/groupes"); + assert_eq!(safe_return(Some("//evil.example")), "/"); + assert_eq!(safe_return(Some("https://evil.example")), "/"); + assert_eq!(safe_return(Some("/\\evil")), "/"); + assert_eq!(safe_return(Some("/\t/evil.example")), "/"); + assert_eq!(safe_return(Some("/\n/evil.example")), "/"); + assert_eq!(safe_return(None), "/"); } #[tokio::test] diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index c828505..bb8f514 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod config; pub mod docs; pub mod error; +pub mod overview; pub mod proposals; pub mod publish; pub mod seed; @@ -41,6 +42,8 @@ pub struct AppState { pub http: reqwest::Client, /// Wakes the publisher as soon as an adoption is recorded. pub publish_wake: Arc, + /// What is coming in the tools of each group (`seeds/upcoming/`), read at start. + pub upcoming: Arc, } impl AppState { @@ -49,7 +52,8 @@ impl AppState { .timeout(Duration::from_secs(15)) .user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION"))) .build()?; - Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) }) + let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?); + Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming }) } } @@ -103,7 +107,8 @@ pub fn api_router() -> Router { .route("/auth/logout", post(auth::logout)) .route("/me", get(auth::me)) .route("/groups", get(docs::groups)) - .route("/groups/{slug}", get(docs::group)) + .route("/groups/{slug}", get(overview::group)) + .route("/groups/{slug}/decisions", get(overview::decisions)) .route("/docs/{id}", get(docs::document)) .route("/docs/{id}/preview", get(docs::preview)) .route("/docs/{id}/assist", post(proposals::assist)) diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs new file mode 100644 index 0000000..8ef0433 --- /dev/null +++ b/crates/ld-server/src/overview.rs @@ -0,0 +1,222 @@ +//! A group at a glance: what is coming in each tool, and what is left to vote. +//! +//! What is coming is data, not code: `seeds/upcoming/.json`, one file +//! per group, read once at start (titles in fr, en and es). It is served with +//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who +//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one +//! line per document, counted by `ld_engine::pending`. + +use crate::AppState; +use crate::auth::User; +use crate::docs; +use crate::error::AppResult; +use crate::util::{self, LangMap}; +use anyhow::{Context, bail}; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::pending::{Module, pending}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashSet}; + +/// Something announced in a tool of a group, not open yet. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct UpcomingItem { + pub key: String, + pub title: LangMap, +} + +/// What is coming in the tools of one group. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GroupUpcoming { + #[serde(default)] + pub docs: Vec, + #[serde(default)] + pub mandats: Vec, + #[serde(default)] + pub protocoles: Vec, +} + +impl GroupUpcoming { + fn check(&self) -> anyhow::Result<()> { + for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] { + let mut keys = HashSet::new(); + for item in items { + if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) { + bail!("{tool}: empty or repeated key « {} »", item.key); + } + for lang in util::LANGS { + if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) { + bail!("{tool}/{}: no title in {lang}", item.key); + } + } + } + } + Ok(()) + } +} + +/// Every group's announcements, by group slug. +#[derive(Clone, Debug, Default)] +pub struct Upcoming(BTreeMap); + +impl Upcoming { + /// Reads `/upcoming/*.json`; no directory, nothing coming. A file + /// that does not read, or lacks a language, stops the start. + pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result { + let dir = seed_dir.join("upcoming"); + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(e).context(dir.display().to_string()), + }; + let mut out = BTreeMap::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_none_or(|x| x != "json") { + continue; + } + let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + let raw = tokio::fs::read_to_string(&path).await?; + let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?; + parsed.check().with_context(|| path.display().to_string())?; + out.insert(group, parsed); + } + Ok(Self(out)) + } + + pub fn of(&self, group: &str) -> GroupUpcoming { + self.0.get(group).cloned().unwrap_or_default() + } +} + +/// The group, its tools and documents (`docs::group`), plus what is coming. +pub async fn group(State(state): State, user: User, Path(slug): Path) -> AppResult> { + let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?; + let open = view["group"]["open"].as_bool().unwrap_or(false); + let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() }; + view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null); + Ok(Json(view)) +} + +/// « Décisions »: one line per document in progress, with its modules left to vote. +pub async fn decisions( + State(state): State, + User(user): User, + Path(slug): Path, +) -> AppResult> { + let group = docs::load_group(&state, &slug).await?; + docs::require_open(&state, &user, &group).await?; + let can_vote = docs::may_vote(&state, &user, &group).await?; + #[derive(sqlx::FromRow)] + struct DocRow { + id: String, + slug: String, + title: String, + } + let rows = + sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at") + .bind(&slug) + .fetch_all(&state.db) + .await?; + let mut out = Vec::with_capacity(rows.len()); + for d in rows { + let p = pending(&modules(&state, &d.id, &user.id).await?); + out.push(json!({ + "id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title), + "toVote": p.to_vote, + "withoutMyVote": can_vote.then_some(p.without_my_vote), + })); + } + Ok(Json(json!({ "canVote": can_vote, "docs": out }))) +} + +/// The articles of a document as `me` sees them: its version in force (adopted +/// by a vote, or a seed a minima), its open versions, and whether `me` holds a +/// valid vote on one of them. A change of inertia is not a version of the text. +async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { + #[derive(sqlx::FromRow)] + struct VersionRow { + unit_id: String, + in_force: bool, + adopted: bool, + voted: bool, + } + let rows = sqlx::query_as::<_, VersionRow>( + "SELECT u.id AS unit_id, + IFNULL(p.id = u.in_force_id, 0) AS in_force, + (p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted, + EXISTS (SELECT 1 FROM votes v + WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted + FROM units u JOIN proposals p ON p.unit_id = u.id + WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open') + ORDER BY u.position, u.id", + ) + .bind(me) + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let mut out: Vec = Vec::new(); + let mut current: Option = None; + for r in rows { + if current.as_deref() != Some(r.unit_id.as_str()) { + out.push(Module::default()); + current = Some(r.unit_id); + } + if let Some(m) = out.last_mut() { + if r.in_force { + m.in_force = Some(r.adopted); + } else { + m.open += 1; + } + m.voted |= r.voted; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Config; + + async fn load(files: &[(&str, &str)]) -> anyhow::Result { + let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id())); + tokio::fs::create_dir_all(dir.join("upcoming")).await?; + for (name, body) in files { + tokio::fs::write(dir.join("upcoming").join(name), body).await?; + } + let out = Upcoming::load(&dir).await; + let _ = tokio::fs::remove_dir_all(&dir).await; + out + } + + const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#; + + #[tokio::test] + async fn one_file_per_group_and_nothing_elsewhere() { + let up = + load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load"); + assert_eq!(up.of("axiom").mandats.len(), 1); + assert!(up.of("axiom").docs.is_empty()); + assert!(up.of("librodrome").mandats.is_empty()); + let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load"); + assert!(none.of("axiom").docs.is_empty()); + } + + #[tokio::test] + async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() { + let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#; + assert!(load(&[("axiom.json", no_es)]).await.is_err()); + assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err()); + assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err()); + } + + #[tokio::test] + async fn the_seeds_of_the_repository_read() { + let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load"); + assert_eq!(up.of("monnaie-libre").mandats.len(), 2); + } +} diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs new file mode 100644 index 0000000..ec23069 --- /dev/null +++ b/crates/ld-server/tests/overview.rs @@ -0,0 +1,224 @@ +//! A group at a glance: what is coming in each tool, and what is left to vote. + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +struct Client { + router: axum::Router, + cookie: Option, +} + +impl Client { + async fn new() -> Self { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(Config::for_tests(), db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + Self { router: app(state), cookie: None } + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn get(&mut self, uri: &str) -> Value { + let (s, v) = self.call("GET", uri, None).await; + assert_eq!(s, StatusCode::OK, "{uri}: {v}"); + v + } + + async fn login(&mut self, name: &str, admin: bool) { + let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + } + + /// An invitation identity that opens only `groups`. + async fn invited(&mut self, admin: &mut Client, groups: &[&str]) { + let (s, v) = + admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": groups }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); + let (s, v) = self.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + } + + async fn vote(&mut self, proposal: &str, choice: &str) -> Value { + let (s, v) = + self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v + } +} + +fn titles(items: &Value, lang: &str) -> Vec { + items.as_array().expect("items").iter().map(|i| i["title"][lang].as_str().expect("title").to_owned()).collect() +} + +async fn the_doc(c: &mut Client, group: &str) -> Value { + let v = c.get(&format!("/api/groups/{group}")).await; + let id = v["docs"][0]["id"].as_str().expect("doc").to_owned(); + c.get(&format!("/api/docs/{id}")).await +} + +fn version(doc: &Value, code: &str, which: &str) -> String { + let unit = doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit"); + let v = if which == "inForce" { &unit["inForce"] } else { &unit["proposals"][which.parse::().expect("n")] }; + v["id"].as_str().expect("id").to_owned() +} + +async fn decisions(c: &mut Client, group: &str) -> Value { + c.get(&format!("/api/groups/{group}/decisions")).await +} + +#[tokio::test] +async fn what_is_coming_is_served_with_the_group_in_three_languages() { + let mut c = Client::new().await; + c.login("Chiara", false).await; + let g = c.get("/api/groups/monnaie-libre").await; + let up = &g["upcoming"]; + assert_eq!(titles(&up["docs"], "fr"), ["Acte d'engagement forgerons"]); + assert_eq!(titles(&up["mandats"], "fr"), ["Administration forum Duniter", "Administration forum Monnaie libre"]); + assert_eq!(titles(&up["protocoles"], "fr"), ["Runtime upgrade — décision on-chain"]); + for tool in ["docs", "mandats", "protocoles"] { + for lang in ["en", "es"] { + assert!(titles(&up[tool], lang).iter().all(|t| !t.is_empty()), "{tool} {lang}"); + } + } + // The docs in progress are still there, and only Monnaie libre has something coming. + assert_eq!(g["docs"].as_array().expect("docs").len(), 1); + let essai = c.get("/api/groups/essai").await; + for tool in ["docs", "mandats", "protocoles"] { + assert_eq!(essai["upcoming"][tool], json!([]), "{tool}"); + } +} + +#[tokio::test] +async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() { + let mut admin = Client::new().await; + admin.login("Yvv", true).await; + let mut guest = admin.other(); + guest.invited(&mut admin, &["essai"]).await; + let g = guest.get("/api/groups/axiom").await; + assert_eq!(g["group"]["open"], false); + assert_eq!(g["docs"], json!([])); + for tool in ["docs", "mandats", "protocoles"] { + assert_eq!(g["upcoming"][tool], json!([]), "{tool}"); + } + let (s, _) = guest.call("GET", "/api/groups/axiom/decisions", None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (s, _) = guest.other().call("GET", "/api/groups/monnaie-libre/decisions", None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { + let mut c = Client::new().await; + c.login("Chiara", false).await; + let d = decisions(&mut c, "monnaie-libre").await; + assert_eq!(d["canVote"], true); + let docs = d["docs"].as_array().expect("docs"); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0]["slug"], "acte-engagement-certification"); + assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification"); + // 3 seeds a minima + 30 open articles, the two variants counting once. + assert_eq!(docs[0]["toVote"], 33); + assert_eq!(docs[0]["withoutMyVote"], 33); + + let doc = the_doc(&mut c, "monnaie-libre").await; + c.vote(&version(&doc, "E3", "0"), "for").await; + c.vote(&version(&doc, "E3", "1"), "against").await; + c.vote(&version(&doc, "I1", "inForce"), "for").await; + let d = decisions(&mut c, "monnaie-libre").await; + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31))); + // Someone else has not voted yet. + let mut yvv = c.other(); + yvv.login("Yvv", false).await; + assert_eq!(decisions(&mut yvv, "monnaie-libre").await["docs"][0]["withoutMyVote"], 33); +} + +#[tokio::test] +async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_count() { + let mut yvv = Client::new().await; + yvv.login("Yvv", false).await; + let mut chiara = yvv.other(); + chiara.login("Chiara", false).await; + // Dev identities are members of every group: W(essai) = 2, C = 1; two « for » adopt. + let doc = the_doc(&mut yvv, "essai").await; + let e3 = version(&doc, "E3", "0"); + yvv.vote(&e3, "for").await; + let v = chiara.vote(&e3, "for").await; + assert_eq!(v["entered"][0]["code"], "E3"); + let d = decisions(&mut yvv, "essai").await; + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32))); + + // A new article by Chiara: one more module to vote. + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let (s, v) = chiara + .call( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ + "title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true, + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + let added = v["id"].as_str().expect("id").to_owned(); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33); + yvv.vote(&added, "against").await; + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32); + // Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote. + let (s, v) = chiara + .call( + "PATCH", + &format!("/api/proposals/{added}"), + Some(json!({ + "title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours.", + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["edit"], "substantive"); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33); +} + +#[tokio::test] +async fn where_one_does_not_vote_there_is_no_count_of_ones_own() { + let mut admin = Client::new().await; + admin.login("Yvv", true).await; + let mut guest = admin.other(); + guest.invited(&mut admin, &["monnaie-libre", "essai"]).await; + let d = decisions(&mut guest, "monnaie-libre").await; + assert_eq!(d["canVote"], false); + assert_eq!(d["docs"][0]["toVote"], 33); + assert!(d["docs"][0]["withoutMyVote"].is_null()); + // In the test group, the same person votes. + let d = decisions(&mut guest, "essai").await; + assert_eq!(d["canVote"], true); + assert_eq!(d["docs"][0]["withoutMyVote"], 33); +} diff --git a/seeds/upcoming/monnaie-libre.json b/seeds/upcoming/monnaie-libre.json new file mode 100644 index 0000000..9818c1b --- /dev/null +++ b/seeds/upcoming/monnaie-libre.json @@ -0,0 +1,40 @@ +{ + "docs": [ + { + "key": "acte-engagement-forgerons", + "title": { + "fr": "Acte d'engagement forgerons", + "en": "Smith Commitment Act", + "es": "Acta de compromiso de forjadores" + } + } + ], + "mandats": [ + { + "key": "administration-forum-duniter", + "title": { + "fr": "Administration forum Duniter", + "en": "Duniter forum administration", + "es": "Administración del foro Duniter" + } + }, + { + "key": "administration-forum-monnaie-libre", + "title": { + "fr": "Administration forum Monnaie libre", + "en": "Monnaie libre forum administration", + "es": "Administración del foro Monnaie libre" + } + } + ], + "protocoles": [ + { + "key": "runtime-upgrade", + "title": { + "fr": "Runtime upgrade — décision on-chain", + "en": "Runtime upgrade — on-chain decision", + "es": "Runtime upgrade — decisión on-chain" + } + } + ] +} From 81b604e5f221a2c80ad778e7a63c97ee44d4e916 Mon Sep 17 00:00:00 2001 From: Yvv Date: Fri, 30 Oct 2026 11:21:57 +0100 Subject: [PATCH 2/3] =?UTF-8?q?v3=20:=20l'entr=C3=A9e=20=E2=80=94=20accuei?= =?UTF-8?q?l=20avec=20le=20choix=20du=20groupe,=20logo=20=C2=AB=20Cl=C3=A9?= =?UTF-8?q?=20de=20vo=C3=BBte=20=C2=BB,=20quatre=20ambiances,=20menu=20et?= =?UTF-8?q?=20pages=20d'outil=20par=20groupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accueil : le titre, le leitmotiv en trois lignes (contexte et cadrage · processus et modalités · boîte à outils), puis directement le choix du groupe ; sans session, la connexion à sa place. /groupes redirige vers /, « Mes groupes » et le retour après connexion mènent à /. - Logo « Clé de voûte » du DA (signe en currentColor, clé en --cle, libre Literata italique, Decision romain), droit, dans le menu et la barre mobile ; Stamp retiré, le sceau 井 ne bouge pas. Favicon, icônes 16/32/48, apple-touch, 192/512, manifeste. - Quatre ambiances : pierre et calcaire (claires), nuit et ardoise (sombres), --cle par ambiance ; quatre pastilles à la place du soleil / lune ; première visite selon l'appareil (pierre / nuit). Calcaire et ardoise : textes, accent, mousse et avertissement à 4,5:1 au moins. - Page d'un groupe : les quatre piliers, puis « En cours ». - Menu : nombre de docs en cours à côté de « Docs de référence », surligné seulement dedans, seul Docs réagit au survol ; les trois autres éteints mais ouvrent leur page ; hors d'un groupe, le dernier groupe ouvert. - /g/[group]/docs « En cours » + « À venir » ; /mandats et /protocoles du groupe : « À venir » éteints ; /g/[group]/decisions : une ligne par doc, « N modules à voter », « dont N sans ton vote ». Anciennes adresses /decisions, /mandats, /protocoles : vers le groupe courant, sinon /. - e2e : C1, C2, C11, C12, M1 mis à jour ; C13–C17 pour L1–L6 ; CONTROLES.md. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 +- .../chantiers/acte-certification/CONTROLES.md | 14 +- web/e2e/entree.spec.ts | 161 +++++++++++++++ web/e2e/mobile.spec.ts | 9 +- web/e2e/reader.spec.ts | 26 ++- web/src/app.css | 57 +++++- web/src/app.html | 24 ++- web/src/lib/assets/cle-de-voute.svg | 1 + web/src/lib/components/DocCards.svelte | 59 ++++++ web/src/lib/components/GroupChoice.svelte | 144 +++++++++++++ web/src/lib/components/Logo.svelte | 54 +++++ web/src/lib/components/Pillars.svelte | 89 ++++++++ web/src/lib/components/Sidebar.svelte | 191 ++++++++++-------- web/src/lib/components/SoonList.svelte | 49 +++++ web/src/lib/components/Stamp.svelte | 35 ---- web/src/lib/components/ToolSoon.svelte | 62 ++++++ web/src/lib/group.ts | 17 ++ web/src/lib/groups.svelte.ts | 93 +++++++++ web/src/lib/i18n/en.ts | 42 ++-- web/src/lib/i18n/es.ts | 42 ++-- web/src/lib/i18n/fr.ts | 42 ++-- web/src/lib/mood.svelte.ts | 22 +- web/src/routes/+layout.svelte | 13 +- web/src/routes/+page.svelte | 108 ++++------ web/src/routes/connexion/+page.svelte | 2 +- web/src/routes/decisions/+page.svelte | 24 +-- web/src/routes/decisions/+page.ts | 7 + web/src/routes/equipe/+page.svelte | 2 +- web/src/routes/g/[group]/+page.svelte | 89 ++------ .../routes/g/[group]/decisions/+page.svelte | 133 ++++++++++++ web/src/routes/g/[group]/docs/+page.svelte | 60 ++---- web/src/routes/g/[group]/mandats/+page.svelte | 5 + .../routes/g/[group]/protocoles/+page.svelte | 5 + web/src/routes/groupes/+page.svelte | 140 +------------ web/src/routes/groupes/+page.ts | 6 + web/src/routes/invitation/+page.svelte | 2 +- web/src/routes/mandats/+page.svelte | 24 +-- web/src/routes/mandats/+page.ts | 7 + web/src/routes/protocoles/+page.svelte | 24 +-- web/src/routes/protocoles/+page.ts | 7 + web/static/apple-touch-icon.png | Bin 0 -> 3977 bytes web/static/favicon-16.png | Bin 0 -> 410 bytes web/static/favicon-32.png | Bin 0 -> 937 bytes web/static/favicon-48.png | Bin 0 -> 1298 bytes web/static/favicon.svg | 10 +- web/static/icon-192.png | Bin 0 -> 4314 bytes web/static/icon-512.png | Bin 0 -> 12002 bytes web/static/manifest.webmanifest | 16 ++ 48 files changed, 1323 insertions(+), 613 deletions(-) create mode 100644 web/e2e/entree.spec.ts create mode 100644 web/src/lib/assets/cle-de-voute.svg create mode 100644 web/src/lib/components/DocCards.svelte create mode 100644 web/src/lib/components/GroupChoice.svelte create mode 100644 web/src/lib/components/Logo.svelte create mode 100644 web/src/lib/components/Pillars.svelte create mode 100644 web/src/lib/components/SoonList.svelte delete mode 100644 web/src/lib/components/Stamp.svelte create mode 100644 web/src/lib/components/ToolSoon.svelte create mode 100644 web/src/lib/groups.svelte.ts create mode 100644 web/src/routes/decisions/+page.ts create mode 100644 web/src/routes/g/[group]/decisions/+page.svelte create mode 100644 web/src/routes/g/[group]/mandats/+page.svelte create mode 100644 web/src/routes/g/[group]/protocoles/+page.svelte create mode 100644 web/src/routes/groupes/+page.ts create mode 100644 web/src/routes/mandats/+page.ts create mode 100644 web/src/routes/protocoles/+page.ts create mode 100644 web/static/apple-touch-icon.png create mode 100644 web/static/favicon-16.png create mode 100644 web/static/favicon-32.png create mode 100644 web/static/favicon-48.png create mode 100644 web/static/icon-192.png create mode 100644 web/static/icon-512.png create mode 100644 web/static/manifest.webmanifest diff --git a/CLAUDE.md b/CLAUDE.md index 8de392f..8b17ff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,13 +44,15 @@ crates/ # sur la chaîne) · seed.rs · web.rs (sert web/build) tests/api.rs # scénarios bout en bout seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur) + # upcoming/.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage web/src/ lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée - lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp… - routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, - # panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/ - # protocoles (à venir) -web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 + lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Logo, Pillars… + routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) · + # g/[group]/docs · g/[group]/docs/[doc] (lecteur, + # panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats · + # g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent +web/e2e/ # Playwright C1–C17 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ @@ -71,7 +73,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 18 contrôles LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud @@ -94,8 +96,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale `fr-FR` (sinon l'interface part en anglais). -- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit` - (`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` — +- **Design** : sans bordures, arrondis 16/12/20/14, quatre ambiances `pierre`, `calcaire` + (claires), `nuit`, `ardoise` (sombres) (`data-mood`, posé avant le premier rendu, clé `ld:mood`), logo + « Clé de voûte » (`Logo`, jamais incliné, `--cle` par ambiance), sceau hexagramme 井 #48 en SVG (`Seal` — jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté. - **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference` (provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur). diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 6000f93..77b5c91 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -9,8 +9,8 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | # | Contrôle | Geste humain | Attendu | |---|---|---|---| -| C1 | L'accueil | ouvrir `/` sans être connectée | « Rendre possible la décision collective », la phrase d'outils, les quatre sections ; le menu à gauche avec les quatre outils, trois grisés ; « Se connecter avec sa clé Ğ1 » | -| C2 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal | +| C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | +| C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | | C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Préambule, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) | | C4 | Gestes contextuels | survoler le Préambule → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme | | C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles | @@ -21,7 +21,12 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | -| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille | +| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille | +| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis | +| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | +| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | +| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant | +| C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur | ## Au-delà de la liste @@ -30,7 +35,7 @@ Ce que l'automate ne voit pas et que Chiara regarde : - **la lecture** : le texte est-il beau, aéré, sans bruit ? lit-on un article sans effort ? - **la fatigue** : un geste de trop, une étape inutile, un mot qui arrête ; - **le vocabulaire** : un libellé qui parle du système au lieu de parler de toi ou du groupe ; -- **les deux ambiances** (pierre / nuit) et le clavier seul (Tab, Entrée, Échap) ; +- **les quatre ambiances** (pierre, calcaire, nuit, ardoise) et le clavier seul (Tab, Entrée, Échap) ; - **les langues** : une traduction maladroite, une chaîne restée en français. Chaque remarque passe par « Faire un retour » (menu de gauche) : elle arrive datée, avec la @@ -47,3 +52,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts | | 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts | | 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts | +| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts | diff --git a/web/e2e/entree.spec.ts b/web/e2e/entree.spec.ts new file mode 100644 index 0000000..7e983d0 --- /dev/null +++ b/web/e2e/entree.spec.ts @@ -0,0 +1,161 @@ +import { expect, test, type Page } from '@playwright/test'; +import { DOC, person, signIn } from './helpers'; + +const ML = '/g/monnaie-libre'; +const path = (p: string) => (u: URL) => u.pathname === p; + +async function background(page: Page, selector: string) { + return page.locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor); +} + +test('C13 — logo « Clé de voûte » sans tampon, quatre ambiances, icônes', async ({ browser }) => { + // First visit: the device's light or dark setting picks pierre or nuit. + for (const [scheme, expected] of [['light', 'pierre'], ['dark', 'nuit']] as const) { + const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: scheme }); + const page = await ctx.newPage(); + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('data-mood', expected); + await ctx.close(); + } + const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: 'light', viewport: { width: 1360, height: 900 } }); + const page = await ctx.newPage(); + await page.goto('/'); + const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); + const brand = nav.locator('a.brand'); + await expect(brand).toHaveAccessibleName('libreDecision'); + await expect(brand.locator('svg')).toBeVisible(); + await expect(page.locator('.stamp')).toHaveCount(0); + expect(await brand.locator('svg').evaluate((el) => getComputedStyle(el).transform)).toBe('none'); + // The seal stays where it is. + await expect(page.locator('svg.seal')).toBeVisible(); + const moods = nav.getByRole('radiogroup', { name: 'Ambiance' }); + for (const [name, mood, cle] of [ + ['Calcaire', 'calcaire', '#a8661e'], + ['Ardoise', 'ardoise', '#e0a65c'], + ['Nuit', 'nuit', '#6ec1c6'], + ['Pierre', 'pierre', '#16808c'], + ]) { + await moods.getByRole('radio', { name }).click(); + await expect(page.locator('html')).toHaveAttribute('data-mood', mood); + await expect(moods.getByRole('radio', { name })).toHaveAttribute('aria-checked', 'true'); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--cle').trim())).toBe(cle); + } + await moods.getByRole('radio', { name: 'Ardoise' }).click(); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-mood', 'ardoise'); + // Favicon and icons. + for (const href of ['/favicon.svg', '/apple-touch-icon.png', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest']) { + expect((await page.request.get(href)).status(), href).toBe(200); + } + await expect(page.locator('link[rel="icon"][type="image/svg+xml"]')).toHaveAttribute('href', /favicon\.svg$/); + await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveAttribute('href', /apple-touch-icon\.png$/); + await ctx.close(); +}); + +test('C14 — la page d’un groupe : les quatre piliers, puis « En cours »', async ({ page }) => { + await signIn(page, 'Chiara'); + await page.goto(ML); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Monnaie libre'); + const pillars = page.locator('.pillars .pillar'); + await expect(pillars).toHaveCount(4); + for (const name of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) { + await expect(pillars.filter({ hasText: name })).toHaveCount(1); + } + await expect(pillars.filter({ hasText: 'Docs de référence' })).toContainText('ouvert'); + await expect(pillars.filter({ hasText: 'à venir' })).toHaveCount(3); + const current = page.locator('section', { has: page.getByRole('heading', { name: 'En cours' }) }); + await current.getByRole('link', { name: /Acte d'engagement Certification/ }).click(); + await expect(page).toHaveURL(path(DOC)); + await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); +}); + +test('C15 — le menu : le nombre de docs, surligné seulement dedans, seul Docs réagit au survol', async ({ browser }) => { + const page = await person(browser, 'Lina'); + // Nothing remembered yet: the tools lead home. + await page.goto('/'); + const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); + const tool = (name: string) => nav.locator('.tool', { hasText: name }); + await expect(tool('Docs de référence')).toHaveAttribute('href', '/'); + await page.goto(ML); + await expect(tool('Docs de référence').locator('.count')).toHaveText('1'); + await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0); + await expect(nav.locator('.tool.off')).toHaveCount(3); + // Hover: Docs reacts, a tool to come does not. + const docsBefore = await background(page, 'nav .tool.docs'); + await tool('Docs de référence').hover(); + await expect.poll(() => background(page, 'nav .tool.docs')).not.toBe(docsBefore); + const mandatesBefore = await background(page, 'nav .tool.mandats'); + await tool('Mandats').hover(); + await page.waitForTimeout(400); + expect(await background(page, 'nav .tool.mandats')).toBe(mandatesBefore); + // Inside a tool, it alone is highlighted. + await tool('Docs de référence').click(); + await expect(page).toHaveURL(path(`${ML}/docs`)); + await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/); + await page.goto(DOC); + await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/); + // A tool to come opens its page, which shows what is coming, dimmed. + await tool('Mandats').click(); + await expect(page).toHaveURL(path(`${ML}/mandats`)); + await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Mandats/); + // Outside a group, the tools aim at the last group opened. + await page.goto('/'); + await expect(tool('Docs de référence')).toHaveAttribute('href', `${ML}/docs`); + await expect(tool('Protocoles')).toHaveAttribute('href', `${ML}/protocoles`); + await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0); +}); + +test('C16 — Docs, Mandats, Protocoles : « En cours » et « À venir », éteints', async ({ browser }) => { + const page = await person(browser, 'Noé'); + // Old addresses, before any group: home. + await page.goto('/mandats'); + await expect(page).toHaveURL(path('/')); + await page.goto(`${ML}/docs`); + const section = (name: string) => page.locator('section', { has: page.getByRole('heading', { name, exact: true }) }); + await expect(section('En cours').getByRole('link', { name: /Acte d'engagement Certification/ })).toBeVisible(); + const soon = section('À venir'); + await expect(soon.locator('li')).toHaveText(["Acte d'engagement forgerons"]); + await expect(soon.getByRole('link')).toHaveCount(0); + await page.goto(`${ML}/mandats`); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Mandats'); + await expect(section('À venir').locator('li')).toHaveText(['Administration forum Duniter', 'Administration forum Monnaie libre']); + await expect(section('À venir').getByRole('link')).toHaveCount(0); + await page.goto(`${ML}/protocoles`); + await expect(section('À venir').locator('li')).toHaveText(['Runtime upgrade — décision on-chain']); + // Old addresses lead to the tools of the current group. + for (const tool of ['decisions', 'mandats', 'protocoles']) { + await page.goto(`/${tool}`); + await expect(page).toHaveURL(path(`${ML}/${tool}`)); + } + // In English and Spanish, what is coming follows. + await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('button', { name: 'ES' }).click(); + await page.goto(`${ML}/protocoles`); + await expect(page.locator('section.soon li')).toHaveText(['Runtime upgrade — decisión on-chain']); + await page.getByRole('navigation', { name: 'Caja de herramientas' }).getByRole('button', { name: 'FR' }).click(); +}); + +test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans ton vote', async ({ browser }) => { + const page = await person(browser, 'Maëlle'); + await page.goto(`${ML}/decisions`); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Décisions'); + const rows = page.locator('ul.lines > li'); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("Acte d'engagement Certification"); + await expect(rows.first()).toContainText('33 modules à voter'); + await expect(rows.first()).toContainText('dont 33 sans ton vote'); + // A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre » + // counts as much, and leaves the previews of C9 unchanged (the server is shared). + const group = await (await page.request.get('/api/groups/monnaie-libre')).json(); + const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json(); + const e3 = doc.units.find((u: { code: string }) => u.code === 'E3'); + const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, { + data: { choice: 'against' }, + headers: { 'x-requested-with': 'libredecision' }, + }); + expect(r.ok()).toBe(true); + await page.reload(); + await expect(rows.first()).toContainText('33 modules à voter'); + await expect(rows.first()).toContainText('dont 32 sans ton vote'); + await rows.first().getByRole('link').click(); + await expect(page).toHaveURL(path(DOC)); +}); diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 4400d0e..eaea48f 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -1,12 +1,19 @@ import { expect, test } from '@playwright/test'; import { DOC, signIn } from './helpers'; -test('M1 — mobile : menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { +test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { await signIn(page, 'Chiara'); + await page.goto('/'); + const brand = page.locator('header.topbar a.brand'); + await expect(brand).toHaveAccessibleName('libreDecision'); + await expect(brand.locator('svg')).toBeVisible(); + await expect(page.locator('a.root')).toContainText('Monnaie libre'); await page.goto(DOC); await page.getByRole('button', { name: 'Menu' }).click(); const drawer = page.getByRole('dialog', { name: 'Boîte à outils' }); await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible(); + await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1'); + await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4); await drawer.getByRole('button', { name: 'Fermer' }).first().click(); const first = page.locator('section.text article.unit').first(); await first.getByRole('button', { name: 'Le vote' }).click(); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 1acd089..377e50c 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,25 +1,37 @@ import { expect, test } from '@playwright/test'; import { DOC, person, signIn, TEST_DOC } from './helpers'; -test('C1 — l’accueil : l’intro, les quatre sections, le menu complet', async ({ page }) => { +test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective'); - await expect(page.getByText('en fonction du contexte, processus et modalités, boîte à outils')).toBeVisible(); + await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']); const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) { await expect(nav.locator('.tool', { hasText: item })).toBeVisible(); } - await expect(nav.locator('.tool.disabled')).toHaveCount(3); + await expect(nav.locator('.tool.off')).toHaveCount(3); await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible(); + // The four pillars live on the page of a group, no longer here. + await expect(page.locator('.pillars')).toHaveCount(0); + await expect(page.getByText('Monnaie libre', { exact: true })).toHaveCount(0); }); -test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({ page }) => { +test('C2 — l’accueil avec session : le choix du groupe, ceux de la même clé ; pas l’Atelier du canal', async ({ page }) => { await signIn(page, 'Chiara'); - await page.goto('/groupes'); + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective'); + await expect(page.locator('a.root')).toContainText('Monnaie libre'); for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) { await expect(page.getByText(g, { exact: true })).toBeVisible(); } await expect(page.getByText('Atelier du canal')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0); + // The old address and « Mes groupes » lead here. + await page.goto('/groupes'); + await expect(page).toHaveURL((u) => u.pathname === '/'); + await page.goto('/g/monnaie-libre'); + await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Mes groupes' }).click(); + await expect(page).toHaveURL((u) => u.pathname === '/'); }); test('C3 — le texte en vigueur : préambule et deux engagements, seed a minima', async ({ page }) => { @@ -144,7 +156,7 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de const ctx = await browser.newContext({ locale: 'fr-FR' }); const invited = await ctx.newPage(); await invited.goto(`/invitation#${link.split('#')[1]}`); - await expect(invited).toHaveURL(/\/groupes$/); + await expect(invited).toHaveURL((u) => u.pathname === '/'); await invited.goto(DOC); await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click(); const panel = invited.locator('aside.panel'); @@ -172,7 +184,7 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible(); await page.getByLabel('Ton nom').fill(name); await page.getByRole('button', { name: 'Rejoindre' }).click(); - await expect(page).toHaveURL(/\/groupes$/); + await expect(page).toHaveURL((u) => u.pathname === '/'); await ctx.close(); } // A shared browser: the person signed in steps aside, the next one gives a name. diff --git a/web/src/app.css b/web/src/app.css index 429b187..2f1ae94 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,5 +1,8 @@ /* libreDecision v3 — tokens and base. - Two moods: « pierre » (cool stone, light) and « nuit » (well at night, dark). + Four moods, two light and two dark: « pierre » (cool stone) and « nuit » (well at night) + carry the water; « calcaire » (warm limestone) and « ardoise » (dark slate) carry the ochre. + `--water` is the accent of the mood (links, buttons, current place), whatever its hue; + `--cle` is the keystone of the logo (Clé de voûte, DA 2026-10-28). Borderless: depth comes from background and shadow, never from strokes. Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */ @@ -23,12 +26,38 @@ --ochre: #a8661e; --ochre-wash: rgb(168 102 30 / 0.12); --on-water: #f6f8f7; + --cle: #16808c; --shadow: 0 1px 2px rgb(20 40 40 / 0.06), 0 8px 28px rgb(20 40 40 / 0.07); --shadow-strong: 0 2px 6px rgb(20 40 40 / 0.1), 0 18px 48px rgb(20 40 40 / 0.16); --veil: rgb(16 30 31 / 0.28); color-scheme: light; } +/* Calcaire: warm limestone, the ochre. The accent is darker than the keystone + (#a8661e, 3.5:1) so that a link or a label reads at 4.5:1 or more; warnings + turn to brick, away from the accent. */ +[data-mood='calcaire'] { + --bg: #f2ede4; + --surface: #fbf8f2; + --surface-2: #e8e0d2; + --ink: #2a241e; + --ink-soft: #5c5247; + --ink-faint: #857868; + --water: #8a5214; + --water-light: #e6c79c; + --water-wash: rgb(138 82 20 / 0.08); + --moss: #446b38; + --moss-wash: rgb(68 107 56 / 0.12); + --ochre: #a3432b; + --ochre-wash: rgb(163 67 43 / 0.1); + --on-water: #fbf8f2; + --cle: #a8661e; + --shadow: 0 1px 2px rgb(60 42 20 / 0.06), 0 8px 28px rgb(60 42 20 / 0.07); + --shadow-strong: 0 2px 6px rgb(60 42 20 / 0.1), 0 18px 48px rgb(60 42 20 / 0.16); + --veil: rgb(42 32 20 / 0.28); + color-scheme: light; +} + [data-mood='nuit'] { --bg: #0e1819; --surface: #142224; @@ -44,12 +73,36 @@ --ochre: #e0a458; --ochre-wash: rgb(224 164 88 / 0.12); --on-water: #0e1819; + --cle: #6ec1c6; --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 28px rgb(0 0 0 / 0.28); --shadow-strong: 0 2px 6px rgb(0 0 0 / 0.4), 0 18px 48px rgb(0 0 0 / 0.5); --veil: rgb(0 0 0 / 0.5); color-scheme: dark; } +/* Ardoise: dark slate, the ochre; warnings in a light brick. */ +[data-mood='ardoise'] { + --bg: #171b20; + --surface: #1d2228; + --surface-2: #262c34; + --ink: #e4e6e2; + --ink-soft: #a9afb5; + --ink-faint: #7a828b; + --water: #e0a65c; + --water-light: #6b4c26; + --water-wash: rgb(224 166 92 / 0.09); + --moss: #93c08a; + --moss-wash: rgb(147 192 138 / 0.12); + --ochre: #e98d72; + --ochre-wash: rgb(233 141 114 / 0.12); + --on-water: #171b20; + --cle: #e0a65c; + --shadow: 0 1px 2px rgb(0 0 0 / 0.32), 0 8px 28px rgb(0 0 0 / 0.3); + --shadow-strong: 0 2px 6px rgb(0 0 0 / 0.42), 0 18px 48px rgb(0 0 0 / 0.52); + --veil: rgb(0 0 0 / 0.5); + color-scheme: dark; +} + :root { --font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif; @@ -166,7 +219,7 @@ button { .btn.primary { background: var(--water); color: var(--on-water); - box-shadow: 0 6px 18px rgb(31 111 120 / 0.22); + box-shadow: 0 6px 18px color-mix(in srgb, var(--water) 22%, transparent); } .btn.quiet { background: transparent; diff --git a/web/src/app.html b/web/src/app.html index 011dfbb..2d0dc2d 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -4,15 +4,27 @@ + + + + + + libreDecision %sveltekit.head% diff --git a/web/src/lib/assets/cle-de-voute.svg b/web/src/lib/assets/cle-de-voute.svg new file mode 100644 index 0000000..bd98e22 --- /dev/null +++ b/web/src/lib/assets/cle-de-voute.svg @@ -0,0 +1 @@ + diff --git a/web/src/lib/components/DocCards.svelte b/web/src/lib/components/DocCards.svelte new file mode 100644 index 0000000..45c16cf --- /dev/null +++ b/web/src/lib/components/DocCards.svelte @@ -0,0 +1,59 @@ + + +{#if docs.length} + +{:else} +

{t('group.current.none')}

+{/if} + + diff --git a/web/src/lib/components/GroupChoice.svelte b/web/src/lib/components/GroupChoice.svelte new file mode 100644 index 0000000..d456555 --- /dev/null +++ b/web/src/lib/components/GroupChoice.svelte @@ -0,0 +1,144 @@ + + +
+

{t('groups.title')}

+ + {#if data.error} + data.load()} /> + {:else if data.value} + {#if root} + + Ğ1 + {root.name} + {#if root.open}{/if} + {#if !root.open}{t('groups.locked')}{/if} + + {/if} + + {#if sameKey.length} +

{t('groups.same.key')}

+
    + {#each sameKey as g (g.slug)} +
  • + {#if g.open} + {g.name} + {:else} +
    + {g.name} + {t('groups.locked')} +
    + {/if} +
  • + {/each} +
+ {#if sameKey.some((g) => !g.open)}

{t('groups.locked.long')}

{/if} + {/if} + + {#if others.length} +

{t('groups.test')}

+ + {/if} + + {#if !all.length}

{t('groups.empty')}

{/if} + {/if} +
+ + diff --git a/web/src/lib/components/Logo.svelte b/web/src/lib/components/Logo.svelte new file mode 100644 index 0000000..f277a40 --- /dev/null +++ b/web/src/lib/components/Logo.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/lib/components/Pillars.svelte b/web/src/lib/components/Pillars.svelte new file mode 100644 index 0000000..807ff48 --- /dev/null +++ b/web/src/lib/components/Pillars.svelte @@ -0,0 +1,89 @@ + + + + + diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 7099f5b..3d1bc91 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -1,61 +1,70 @@