Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1593e50a29 | ||
|
|
6bbc282cb1 | ||
|
|
8727315612 | ||
|
|
68115fb9d7 | ||
|
|
1e0433664c | ||
|
|
c223c5c5ac | ||
|
|
1503e579ba | ||
|
|
81b604e5f2 | ||
|
|
edc88111e0 | ||
|
|
a5b92faa96 | ||
|
|
c640413a70 | ||
|
|
f504010c0f | ||
|
|
d400bfc92e |
@@ -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/<groupe>.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–C19 + 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 : 20 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).
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Acte — Mise en ligne M1 (revue du 30/10 : entrée, DA, menu, listes, Raison d'être, versions)
|
||||
Classe : déploie
|
||||
Projet : libreDecision Date : 2026-10-30
|
||||
**État : FIGÉ (2026-10-30, 14 h 50)** sur `8727315` — relecture « 8727315 : ok » inscrite (sous-agent neuf, 30/10 14 h 45)
|
||||
|
||||
## Intention
|
||||
|
||||
Mettre en ligne sur decision.librodrome.org les points L1 à L8 de la revue d'Yvv du 30/10 (tableau
|
||||
du chantier) :
|
||||
- accueil qui ouvre directement sur le choix du groupe, avec le leitmotiv en trois lignes ;
|
||||
- logo « Clé de voûte » et quatre ambiances ;
|
||||
- page de groupe avec les quatre piliers et « En cours » ;
|
||||
- menu avec le nombre de docs, les « À venir » des outils et Décisions, une ligne par doc ;
|
||||
- « Raison d'être » à la place du Préambule ;
|
||||
- une seule ligne par article dans la liste, même avec plusieurs versions.
|
||||
|
||||
Maintenant, parce que ces points ne dépendent d'aucune question ouverte, et qu'il vaut mieux
|
||||
livrer petit (le vote, M2, viendra ensuite).
|
||||
|
||||
## Ce qui part
|
||||
|
||||
Version : `8727315` (branche `chantier/acte-certification`), qui contient la version en
|
||||
production `dc2de09`. Commits emportés (`git log --oneline dc2de09..8727315`) :
|
||||
- les fusions des lots R (`68115fb`) et A (`8727315`) ;
|
||||
- lot R : `a5b92fa` (Raison d'être, amendement de seed gardé), `edc8811` (versions groupées),
|
||||
`1503e57` (ordre stable des variantes), `c223c5c` (contrôles) ;
|
||||
- lot A : `c640413` (Décisions et « À venir » côté serveur), `81b604e` (entrée, logo, ambiances,
|
||||
menu, pages d'outil), `1e04336` (nom accessible du menu) ;
|
||||
- documents : HANDOFF, tableau, note `docs/recherche/vote-g1-chaine.md`.
|
||||
|
||||
Aucune configuration ni aucun secret ne change : `docker/app-compose.yml` et `.woodpecker/`
|
||||
sont identiques à ceux de `dc2de09` (prémisse 4).
|
||||
|
||||
## Ce que l'acte écrit dans la base de production (donnée runtime)
|
||||
|
||||
- La migration `0004` ajoute la table `seed_amendments`. Elle est additive : rien n'est
|
||||
supprimé ni réécrit.
|
||||
- L'amendement « 2026-10-30-i1-raison-d-etre » remplace le Préambule par la Raison d'être,
|
||||
dans Monnaie libre et dans le groupe d'essai, **seulement si l'article est intact** :
|
||||
- texte de départ, en vigueur sans vote, jamais confirmé ;
|
||||
- révision 1, 0 vote, aucune correction ;
|
||||
- aucune traduction écrite par un membre, aucun signalement ;
|
||||
- texte identique au Préambule du seed.
|
||||
|
||||
Sinon, il ne touche à rien et journalise la raison. Il consigne chaque décision dans
|
||||
`seed_amendments` et ajoute à l'historique de l'article la ligne « texte de départ remplacé »
|
||||
avec son diff. Il met une republication en file, pour Monnaie libre seulement.
|
||||
- État de la production lu le 2026-10-30 à 13 h 43, sur une copie de la base :
|
||||
- le Préambule est intact dans les deux groupes : 0 vote, révision 1, traductions du seed,
|
||||
aucun signalement ;
|
||||
- il y a une identité (invitation) et aucun vote en tout ;
|
||||
- deux propositions d'inertie ouvertes, d'Yvv le 29/10, n'en sont pas touchées.
|
||||
- Répétition faite sur cette copie avec le serveur de `8727315` : démarrage sans erreur, puis
|
||||
« seed amendment applied » pour l'essai et pour Monnaie libre. Le titre est devenu « Raison
|
||||
d'être », en révision 2. La file d'envoi contient « texte de départ remplacé : I1 » pour
|
||||
Monnaie libre seulement.
|
||||
|
||||
## Avant — prémisses vivantes
|
||||
|
||||
1. La production sert `dc2de09` :
|
||||
`curl -s https://decision.librodrome.org/api/health` → `"build":"dc2de09…"`.
|
||||
2. `prod/main` et `origin/main` sont à `dc2de09` :
|
||||
`git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
|
||||
→ `dc2de09` deux fois (vérifié le 2026-10-30 à 14 h, avec la santé `"build":"dc2de094"`).
|
||||
3. La relecture de `8727315` est inscrite « ok » dans `~/harness/etat/relectures.json` ; et à
|
||||
la question « une autre relecture de ce code est-elle en cours ? », la réponse est non.
|
||||
4. Rien ne change la configuration :
|
||||
`git diff --stat dc2de09 8727315 -- docker/app-compose.yml .woodpecker/ .env* data/` →
|
||||
vide.
|
||||
5. La boucle est verte sur `8727315` : fait le 2026-10-30 (91 tests Rust, svelte-check
|
||||
0 avertissement, 16 vitest, e2e 20 / 20), et l'image de production se construit
|
||||
(`docker build --target production`, sortie 0, `seeds/upcoming/` présent dans l'image).
|
||||
|
||||
6. Le Préambule est toujours intact en production, sans proposition de texte rivale. Sur une
|
||||
copie fraîche de la base (`docker exec lpk-libredecision-app cat /data/libredecision.db*`
|
||||
vers `/tmp/ld-prod-copie/`, lecture seule), la requête suivante :
|
||||
```sql
|
||||
SELECT d.group_slug, p.kind, p.status, p.origin,
|
||||
(SELECT COUNT(*) FROM votes v WHERE v.proposal_id=p.id),
|
||||
(SELECT group_concat(kind) FROM revisions r WHERE r.proposal_id=p.id)
|
||||
FROM proposals p JOIN units u ON u.id=p.unit_id JOIN documents d ON d.id=u.doc_id
|
||||
WHERE u.code='I1';
|
||||
```
|
||||
doit rendre, comme le 30/10 à 13 h 43 :
|
||||
- `monnaie-libre|text|in_force|seed|0|initial` ;
|
||||
- `monnaie-libre|inertia|open|member|0|initial`, la proposition d'Yvv du 29/10 ;
|
||||
- `essai|text|in_force|seed|0|initial`.
|
||||
|
||||
Toute autre ligne `text` ou `remove` arrête l'acte : la relecture a montré que l'amendement
|
||||
ne regarde pas les propositions rivales.
|
||||
|
||||
## Commandes exactes
|
||||
|
||||
```sh
|
||||
cd /home/yvv/Documents/PROD/DEV/libreDecision
|
||||
git push prod 8727315:main
|
||||
git push origin 8727315:main
|
||||
git push origin chantier/acte-certification
|
||||
```
|
||||
|
||||
Le pipeline Woodpecker (dépôt LPK/decision, `ci.open.us.org`) construit l'image (qui rejoue la
|
||||
boucle), garde la version en service sous `libredecision-app:previous`, déploie, et revient
|
||||
seul à la version précédente si le contrôle d'après déploiement échoue.
|
||||
|
||||
## Effet attendu
|
||||
|
||||
Les visiteurs de decision.librodrome.org voient la nouvelle entrée, le logo, les ambiances, le
|
||||
menu, les pages d'outil, Décisions, la Raison d'être et les versions groupées. Le dépôt des
|
||||
textes reçoit un commit : la Raison d'être en fr, en et es, dans
|
||||
`monnaie-libre/acte-engagement-certification/`.
|
||||
|
||||
Irréversibilité : **partielle**. Le code se remet en arrière. Le remplacement du Préambule
|
||||
reste en base, et le commit du dépôt des textes reste publié.
|
||||
|
||||
## Vérifications après
|
||||
|
||||
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"8727315…"`.
|
||||
- [ ] `curl -s https://decision.librodrome.org/ | grep -c 'favicon'` ≥ 1, et la page répond
|
||||
200.
|
||||
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep "seed amendment"'` → deux
|
||||
lignes « applied » (essai, monnaie-libre).
|
||||
- [ ] Dans `/acces` → Publication : « Dernière publication » datée du jour, sans envoi en
|
||||
attente.
|
||||
- [ ] À l'écran (Yvv) : l'accueil ouvre sur les groupes ; Monnaie libre montre les quatre
|
||||
piliers et l'Acte d'engagement ; la Raison d'être ouvre le texte ; « Connaissance
|
||||
suffisante » tient une ligne avec « 2 versions ».
|
||||
|
||||
## Retour
|
||||
|
||||
- **Le code** : pousser l'ancienne tête rejoue le pipeline avec la version précédente.
|
||||
```sh
|
||||
git push --force-with-lease=main:8727315 prod dc2de09:main
|
||||
```
|
||||
C'est une réécriture de branche : elle demande le mot « déploie » pour ce retour précis.
|
||||
Plus rapide, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
|
||||
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
|
||||
L'ancienne version démarre sur la base migrée (la table ajoutée ne la gêne pas). Elle
|
||||
affiche la clé brute « history.amendment » dans l'historique de I1. Elle mesure les
|
||||
coquilles de I1 contre le Préambule, ce qui a deux effets :
|
||||
- une vraie coquille sur la Raison d'être est refusée ;
|
||||
- n'importe quel membre peut ramener le Préambule par une « coquille », qui serait alors
|
||||
publiée. La relecture l'a prouvé.
|
||||
|
||||
Un retour de code se fait donc au plus court, puis on revient sur une version corrigée.
|
||||
Tant qu'il dure, on surveille l'historique de I1.
|
||||
- **La Raison d'être en base** : **pas de retour automatique**. Revenir au Préambule
|
||||
demanderait un amendement inverse, écrit et relu comme celui-ci.
|
||||
- **Le commit publié** dans le dépôt des textes : **IMPOSSIBLE à retirer sans réécrire
|
||||
l'historique** de ce dépôt (geste « supprime »). On publierait par-dessus.
|
||||
@@ -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};
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<bool> = Some(false);
|
||||
const ADOPTED: Option<bool> = Some(true);
|
||||
|
||||
fn m(in_force: Option<bool>, 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Seed amendments (2026-10-30): a starting text replaced by a newer one of the
|
||||
-- seed, decided once per document and only on an article nobody has touched
|
||||
-- (src/seed_amend.rs). Each decision is kept here: applied, refused (and why),
|
||||
-- or already present. Additive only.
|
||||
|
||||
CREATE TABLE seed_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
proposal_id TEXT, -- the version in force that was examined
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -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]
|
||||
|
||||
@@ -289,9 +289,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
// Same second (the variants of a seed): the order they were written in, not their
|
||||
// random identifiers. The engine ranks rivals on its own; this order is what people see.
|
||||
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
|
||||
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?)
|
||||
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.id"
|
||||
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.rowid"
|
||||
))
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
|
||||
@@ -11,9 +11,11 @@ pub mod auth;
|
||||
pub mod config;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod overview;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
@@ -41,6 +43,8 @@ pub struct AppState {
|
||||
pub http: reqwest::Client,
|
||||
/// Wakes the publisher as soon as an adoption is recorded.
|
||||
pub publish_wake: Arc<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -49,7 +53,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 +108,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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/<group>.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<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub mandats: Vec<UpcomingItem>,
|
||||
#[serde(default)]
|
||||
pub protocoles: Vec<UpcomingItem>,
|
||||
}
|
||||
|
||||
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<String, GroupUpcoming>);
|
||||
|
||||
impl Upcoming {
|
||||
/// Reads `<seed_dir>/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<Self> {
|
||||
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<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<Vec<Module>> {
|
||||
#[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<Module> = Vec::new();
|
||||
let mut current: Option<String> = 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<Upcoming> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,11 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision.
|
||||
/// The text the votes were cast on: its last initial or substantive revision, or the
|
||||
/// starting text a seed amendment put in place (`seed_amend`).
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//!
|
||||
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||
//! instantiated in each group it names, **only if that group does not hold it
|
||||
//! yet**. An existing document is never touched: from its first vote on, it
|
||||
//! is runtime data.
|
||||
//! yet**. An existing document is never instantiated again: from its first
|
||||
//! vote on, it is runtime data. The one way a newer seed text reaches an
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
@@ -64,14 +67,23 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed
|
||||
/// amendments (each decided once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The groups and every seed document missing from its groups; returns the seeds read.
|
||||
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
|
||||
ensure_groups(state).await?;
|
||||
let dir = state.cfg.seed_dir.clone();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||
return Ok(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
@@ -82,13 +94,14 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut seeds = Vec::new();
|
||||
for path in files {
|
||||
load_file(state, &path).await?;
|
||||
seeds.push(load_file(state, &path).await?);
|
||||
}
|
||||
Ok(())
|
||||
Ok(seeds)
|
||||
}
|
||||
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
for inst in &seed.instances {
|
||||
@@ -111,7 +124,7 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
instantiate(state, &seed, inst).await?;
|
||||
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||
}
|
||||
Ok(())
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Seed amendments — a starting text replaced by a newer text of the seed.
|
||||
//!
|
||||
//! A seeded document is never instantiated again: once in the database, it is
|
||||
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
|
||||
//! the Préambule becomes « Raison d'être »), new bases get it from the seed;
|
||||
//! an existing base gets it through an amendment listed here, and **only if
|
||||
//! nobody has touched the article**:
|
||||
//!
|
||||
//! - its version in force is the seed's own, a *seed a minima* (never voted
|
||||
//! into force, never confirmed), at revision 1;
|
||||
//! - nobody ever voted on it, corrected it, edited one of its translations or
|
||||
//! named it in a contradiction signal;
|
||||
//! - its text is exactly the one the amendment replaces (fingerprint).
|
||||
//!
|
||||
//! Each document is decided once, at the first start that knows the
|
||||
//! amendment, and the decision is kept in `seed_amendments`: applied, refused
|
||||
//! (and why — also in the logs), or present (the base was born with the new
|
||||
//! text). Applied, the text and its seed translations are replaced in one
|
||||
//! transaction, the article's history gets a line of its own (kind
|
||||
//! `amendment`, with its diff) and, where the document publishes, the
|
||||
//! publication queue a row.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, SeedVersion};
|
||||
use crate::util;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One starting text to replace, in every document instantiated from a seed.
|
||||
pub struct Amendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article.
|
||||
pub unit: &'static str,
|
||||
/// Fingerprint of the starting text expected in force (source language).
|
||||
pub from: &'static str,
|
||||
/// Fingerprint of the new text: the version in force of the seed today, which
|
||||
/// must hash to it (a seed edited later never reaches a base by this way).
|
||||
pub to: &'static str,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[Amendment] = &[Amendment {
|
||||
id: "2026-10-30-i1-raison-d-etre",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "I1",
|
||||
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
|
||||
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
}];
|
||||
|
||||
/// The fingerprint of a text: the hash translations are pinned to.
|
||||
pub fn fingerprint(title: &str, body: &str) -> String {
|
||||
util::source_hash(title, body)
|
||||
}
|
||||
|
||||
/// What the database says of the version in force of the article.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
pub origin: String,
|
||||
/// Voted into force (`entered_at`).
|
||||
pub entered: bool,
|
||||
/// A seed a minima that later reached its threshold (`confirmed_at`).
|
||||
pub confirmed: bool,
|
||||
pub revision: i64,
|
||||
/// Votes cast on it, whatever their revision.
|
||||
pub votes: i64,
|
||||
/// Kinds of its revisions, in order.
|
||||
pub revisions: Vec<String>,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Contradiction signals naming it, standing or lifted.
|
||||
pub signals: i64,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// The new text is already in force: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: any trace of a person keeps the text.
|
||||
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_text_in_force") };
|
||||
if f.fingerprint == a.to {
|
||||
return Verdict::Present;
|
||||
}
|
||||
let refusal = if f.origin != "seed" {
|
||||
"not_a_seed"
|
||||
} else if f.entered {
|
||||
"voted_into_force"
|
||||
} else if f.confirmed {
|
||||
"confirmed"
|
||||
} else if f.revision != 1 {
|
||||
"revised"
|
||||
} else if f.votes > 0 {
|
||||
"votes"
|
||||
} else if f.revisions != ["initial"] {
|
||||
"corrected"
|
||||
} else if f.member_translations > 0 {
|
||||
"translation_edited"
|
||||
} else if f.signals > 0 {
|
||||
"signalled"
|
||||
} else if f.fingerprint != a.from {
|
||||
"text_differs"
|
||||
} else {
|
||||
return Verdict::Apply;
|
||||
};
|
||||
Verdict::Refuse(refusal)
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Amendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some((seed, target)) = target_of(seeds, a) else { continue };
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(v) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
|
||||
Verdict::Present => {
|
||||
tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there")
|
||||
}
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed document and its version in force for the article, checked against `to`.
|
||||
fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> {
|
||||
let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else {
|
||||
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
|
||||
return None;
|
||||
};
|
||||
let version =
|
||||
seed.units.iter().find(|u| u.code == a.unit).and_then(|u| u.versions.iter().find(|v| v.status == "in_force"));
|
||||
let src = seed.source_lang.as_str();
|
||||
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
|
||||
match (version, text) {
|
||||
(Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)),
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"seed amendment: the seed text is not the one it names, nothing decided"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result<Option<Facts>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
origin: String,
|
||||
entered_at: Option<String>,
|
||||
confirmed_at: Option<String>,
|
||||
revision: i64,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
let Some(p) = sqlx::query_as::<_, Row>(
|
||||
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = 'in_force'",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let revisions: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let member_translations: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let signals: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts {
|
||||
origin: p.origin,
|
||||
entered: p.entered_at.is_some(),
|
||||
confirmed: p.confirmed_at.is_some(),
|
||||
revision: p.revision,
|
||||
votes,
|
||||
revisions,
|
||||
member_translations,
|
||||
signals,
|
||||
fingerprint: fingerprint(&p.title, &p.body),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &Amendment,
|
||||
seed: &SeedDoc,
|
||||
target: &SeedVersion,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let in_force: Option<String> =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
let found = match &in_force {
|
||||
Some(id) => facts(&mut tx, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let mut v = verdict(found.as_ref(), a);
|
||||
let now = util::now();
|
||||
if let (Verdict::Apply, Some(pid)) = (v, &in_force) {
|
||||
let src = seed.source_lang.as_str();
|
||||
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
|
||||
let (title, body) = (pick(&target.title), pick(&target.body));
|
||||
// Written only over the text that was judged, still untouched.
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
||||
AND confirmed_at IS NULL AND revision = 1",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
VALUES (?, 2, 'amendment', ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let hash = fingerprint(&title, &body);
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let (Some(t), Some(b)) = (target.title.get(*lang), target.body.get(*lang)) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)
|
||||
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
||||
source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(b)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
v = Verdict::Refuse("changed");
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(&in_force)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: Amendment = Amendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
unit: "I1",
|
||||
from: "old",
|
||||
to: "new",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
};
|
||||
|
||||
fn intact() -> Facts {
|
||||
Facts {
|
||||
origin: "seed".into(),
|
||||
revision: 1,
|
||||
revisions: vec!["initial".into()],
|
||||
fingerprint: "old".into(),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_starting_text_is_replaced() {
|
||||
assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_text_already_there_is_left_as_it_is() {
|
||||
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
|
||||
assert_eq!(verdict(Some(&f), &A), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_trace_of_a_person_keeps_the_text() {
|
||||
let cases = [
|
||||
(None, "no_text_in_force"),
|
||||
(Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"),
|
||||
(Some(Facts { entered: true, ..intact() }), "voted_into_force"),
|
||||
(Some(Facts { confirmed: true, ..intact() }), "confirmed"),
|
||||
(Some(Facts { revision: 2, ..intact() }), "revised"),
|
||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||
];
|
||||
for (facts, reason) in cases {
|
||||
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ async fn the_seed_a_minima_and_the_open_proposals() {
|
||||
// Every other article is an open proposal at zero votes, standard inertia.
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert!(e3["inForce"].is_null());
|
||||
assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant");
|
||||
// In the order of the seed, whatever their random identifiers.
|
||||
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
|
||||
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
|
||||
assert_eq!(e3["inertia"], "standard");
|
||||
// Translations seeded.
|
||||
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
|
||||
@@ -203,12 +205,13 @@ async fn editing_is_not_voting() {
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may fix the glued word of the preamble in force, without a vote.
|
||||
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let pid = e1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may give the text in force its typographic apostrophes, without a vote.
|
||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
assert!(fixed.contains("m\u{2019}engage"));
|
||||
let (s, v) =
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "typo");
|
||||
// A change of meaning in force is refused: it goes through a proposal.
|
||||
@@ -216,7 +219,7 @@ async fn editing_is_not_voting() {
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
|
||||
Some(json!({ "title": e1["title"], "body": "Tout autre chose." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
@@ -759,7 +762,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||
};
|
||||
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
|
||||
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
|
||||
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(fr.contains("## Raison d'être") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
|
||||
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
|
||||
let json: Value =
|
||||
@@ -862,3 +865,307 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
||||
assert_eq!(pending(&db).await, 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I1 « Raison d'être » (Yvv's review, 2026-10-30): the seed and its amendment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Préambule every base seeded before 2026-10-30 holds (the seed file had not
|
||||
/// changed since 67ddee2, the first commit of the v3).
|
||||
const PREAMBULE: [(&str, &str, &str); 3] = [
|
||||
(
|
||||
"fr",
|
||||
"Préambule",
|
||||
"Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
|
||||
),
|
||||
(
|
||||
"en",
|
||||
"Preamble",
|
||||
"This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
|
||||
),
|
||||
(
|
||||
"es",
|
||||
"Preámbulo",
|
||||
"La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.",
|
||||
),
|
||||
];
|
||||
|
||||
const RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification";
|
||||
|
||||
fn today_seed() -> Value {
|
||||
let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||||
}
|
||||
|
||||
/// The seed as it was before 2026-10-30: the Préambule in force.
|
||||
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let mut seed = today_seed();
|
||||
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
|
||||
for (lang, title, body) in PREAMBULE {
|
||||
i1["versions"][0]["title"][lang] = json!(title);
|
||||
i1["versions"][0]["body"][lang] = json!(body);
|
||||
}
|
||||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
/// A base seeded with the Préambule, as production was: groups and documents, by the
|
||||
/// code of the time (no amendment).
|
||||
async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppState) {
|
||||
cfg.seed_dir = first_seed_dir(tag);
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
seed::documents(&state).await.expect("first seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// The same base, started again with today's seed (and its amendments).
|
||||
async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
|
||||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// Each document's decision on the amendment: (group, outcome, reason, at).
|
||||
async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
|
||||
ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &str, Option<&str>)> {
|
||||
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
|
||||
}
|
||||
|
||||
async fn i1_of(c: &mut Client, group: &str) -> Value {
|
||||
let id = doc_id(c, group).await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
unit(&doc, "I1")["inForce"].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_base_starts_with_the_raison_d_etre() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(1), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
|
||||
}
|
||||
}
|
||||
// Born with the text: nothing to replace, and it is said once.
|
||||
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
|
||||
let a = ld_server::seed_amend::AMENDMENTS.iter().find(|a| a.unit == "I1").expect("I1 amendment");
|
||||
assert_eq!(a.doc, "acte-engagement-certification");
|
||||
let (_, title, body) = PREAMBULE[0];
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold");
|
||||
let seed = today_seed();
|
||||
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
|
||||
let v = &i1["versions"][0];
|
||||
assert_eq!(v["status"], "in_force");
|
||||
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_it() {
|
||||
use std::process::Command;
|
||||
let dir = std::env::temp_dir().join(format!("ld-amend-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let remote = dir.join("textes.git");
|
||||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
branch: "main".into(),
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
});
|
||||
let git = |args: &[&str]| {
|
||||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||||
String::from_utf8(out.stdout).expect("utf8")
|
||||
};
|
||||
let show = |file: &str| git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]);
|
||||
|
||||
// Production as it is: the Préambule, published.
|
||||
let (_, before) = base_with_the_preambule("applied", cfg.clone()).await;
|
||||
ld_server::publish::pass(&before).await.expect("pass");
|
||||
assert!(show("fr.md").contains("## Préambule"));
|
||||
|
||||
// The new version starts: the untouched Préambule becomes the Raison d'être, in both groups.
|
||||
let (mut c, state) = restart(&before, cfg.clone()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(outcomes(&first), [("essai", "applied", None), ("monnaie-libre", "applied", None)]);
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être", "{group}");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE));
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(2), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!(
|
||||
(tr["title"].as_str(), tr["stale"].as_bool(), tr["origin"].as_str()),
|
||||
(Some(title), Some(false), Some("seed")),
|
||||
"{group} {lang}"
|
||||
);
|
||||
}
|
||||
// The article's history says so, with what changed.
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{}", i1["id"].as_str().expect("id")), None).await;
|
||||
let history = d["history"].as_array().expect("history");
|
||||
assert_eq!(history.len(), 2, "{d}");
|
||||
assert_eq!((history[1]["kind"].as_str(), history[1]["author"].as_str()), (Some("amendment"), None));
|
||||
let parts = history[1]["diff"].as_array().expect("diff");
|
||||
let said =
|
||||
|op: &str| parts.iter().filter(|p| p["op"] == op).filter_map(|p| p["text"].as_str()).collect::<String>();
|
||||
assert!(said("removed").contains("Préambule") && said("added").contains("Raison"), "{parts:?}");
|
||||
}
|
||||
// Republished where the document publishes — never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.done_at IS NULL",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(queued, [("monnaie-libre".to_owned(), "texte de départ remplacé : I1".to_owned())]);
|
||||
ld_server::publish::pass(&state).await.expect("pass");
|
||||
let fr = show("fr.md");
|
||||
assert!(fr.contains("## Raison d'être") && !fr.contains("Préambule"), "{fr}");
|
||||
assert!(show("en.md").contains("## Purpose") && show("es.md").contains("## Razón de ser"));
|
||||
assert!(git(&["log", "-1", "--format=%B", "main"]).contains("texte de départ remplacé : I1"));
|
||||
|
||||
// Once only: the next start changes nothing.
|
||||
let (mut c, again) = restart(&state, cfg.clone()).await;
|
||||
assert_eq!(decisions(&again).await, first);
|
||||
c.login("Chiara", false).await;
|
||||
let i1 = i1_of(&mut c, "monnaie-libre").await;
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let revisions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revisions WHERE proposal_id = ?")
|
||||
.bind(&pid)
|
||||
.fetch_one(&again.db)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||||
|
||||
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
|
||||
let body = i1["body"].as_str().expect("body");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": PREAMBULE[0].1, "body": PREAMBULE[0].2 })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Touch {
|
||||
Vote,
|
||||
Typo,
|
||||
Translation,
|
||||
Signal,
|
||||
Text,
|
||||
}
|
||||
for (touch, reason) in [
|
||||
(Touch::Vote, "votes"),
|
||||
(Touch::Typo, "corrected"),
|
||||
(Touch::Translation, "translation_edited"),
|
||||
(Touch::Signal, "signalled"),
|
||||
(Touch::Text, "text_differs"),
|
||||
] {
|
||||
let tag = format!("{touch:?}").to_lowercase();
|
||||
let (mut c, before) = base_with_the_preambule(&tag, Config::for_tests()).await;
|
||||
c.login("Yvv", false).await;
|
||||
// Touched in the test group only: Monnaie libre stays intact.
|
||||
let id = doc_id(&mut c, "essai").await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = match touch {
|
||||
Touch::Vote => {
|
||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||
}
|
||||
Touch::Typo => {
|
||||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||||
.await
|
||||
}
|
||||
Touch::Translation => {
|
||||
let en = &i1["translations"]["en"];
|
||||
let body =
|
||||
en["body"].as_str().expect("en").replace("This commitment act", "The present commitment act");
|
||||
c.call(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{pid}/translations/en"),
|
||||
Some(json!({ "title": en["title"], "body": body })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Signal => {
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
c.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Text => {
|
||||
sqlx::query("UPDATE proposals SET body = body || ' Fin.' WHERE id = ?")
|
||||
.bind(&pid)
|
||||
.execute(&before.db)
|
||||
.await
|
||||
.expect("another text");
|
||||
(StatusCode::OK, Value::Null)
|
||||
}
|
||||
};
|
||||
assert_eq!(s, StatusCode::OK, "{touch:?}: {v}");
|
||||
|
||||
let (mut c, state) = restart(&before, Config::for_tests()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(
|
||||
outcomes(&first),
|
||||
[("essai", "refused", Some(reason)), ("monnaie-libre", "applied", None)],
|
||||
"{touch:?}"
|
||||
);
|
||||
c.login("Yvv", false).await;
|
||||
let i1 = i1_of(&mut c, "essai").await;
|
||||
assert_eq!(i1["title"], "Préambule", "{touch:?}");
|
||||
assert_eq!(i1["revision"], 1, "{touch:?}");
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{pid}"), None).await;
|
||||
assert!(d["history"].as_array().expect("h").iter().all(|h| h["kind"] != "amendment"), "{touch:?}");
|
||||
// Decided once: a later start does not look again.
|
||||
let (_, again) = restart(&state, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, first, "{touch:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Value>) -> (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<String> {
|
||||
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::<usize>().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);
|
||||
}
|
||||
@@ -14,10 +14,13 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||
|
||||
## Ce que voit la personne
|
||||
|
||||
1. **Accueil sobre** : « Rendre possible la décision collective » / « en fonction du contexte,
|
||||
processus et modalités, boîte à outils », les quatre outils en une ligne chacun, puis la
|
||||
connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles
|
||||
(seul Docs de référence est actif).
|
||||
1. **Accueil sobre** (revue du 2026-10-30) : « Rendre possible la décision collective », puis 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). Le pavé des quatre outils
|
||||
accueille dans le groupe choisi, suivi de ce qui y est « En cours ». Le menu complet à gauche :
|
||||
Décisions · Docs de référence (avec le nombre de docs en cours) · Mandats · Protocoles ; seul
|
||||
Docs de référence réagit, les autres s'ouvrent sur leurs « À venir », éteints. Décisions : une
|
||||
ligne par doc en cours, avec le nombre de modules qui restent à voter.
|
||||
2. **Connexion Ğ1** — la même que le forum Monnaie libre (connect.monnaie-libre.fr). En attendant
|
||||
l'inscription du client OAuth2, des **liens d'invitation** à usage unique (une personne),
|
||||
et des **liens d'équipe** réutilisables jusqu'à une date (chaque ouverture crée sa propre
|
||||
@@ -26,14 +29,20 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||
Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à
|
||||
ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
|
||||
groupes sur accord dans trustinWallet.
|
||||
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ le
|
||||
Préambule et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
|
||||
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ la
|
||||
**Raison d'être** (texte d'Yvv du 2026-10-30, qui remplace le Préambule : « Derrière une clé
|
||||
il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé
|
||||
certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
|
||||
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
|
||||
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
|
||||
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL.
|
||||
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
|
||||
vote, inertie standard — rangées par section (introduction, fondamental, technique,
|
||||
conclusion, annexes, formule, inertie, ordonnancement), les variantes signalées.
|
||||
conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un
|
||||
article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur
|
||||
nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses
|
||||
versions, chacune avec son étiquette, son niveau et ses marques, et chacune ouvre son vote.
|
||||
Les filtres gardent un article dès qu'une de ses versions passe.
|
||||
6. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
|
||||
7. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
|
||||
une traduction devenue ancienne (le français a changé depuis) est signalée.
|
||||
@@ -156,6 +165,17 @@ traductions marquées éditoriales) et `document.json`, commit et pousse sur
|
||||
sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture)
|
||||
du dépôt. Le groupe d'essai ne publie jamais.
|
||||
|
||||
### Un texte de départ remplacé
|
||||
|
||||
Un document seedé n'est jamais réinstancié : quand le seed change un texte de départ (le
|
||||
Préambule devenu Raison d'être), un **amendement de seed** le remplace dans une base existante,
|
||||
une seule fois, et seulement sur un article que personne n'a touché (*seed a minima* d'origine,
|
||||
révision 1, aucun vote, aucune correction, aucune traduction modifiée, aucun signalement, texte
|
||||
identique à l'empreinte attendue). Chaque décision est consignée (`seed_amendments`, et dans les
|
||||
journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'historique de
|
||||
l'article (« texte de départ remplacé », avec le diff) et le texte est republié
|
||||
(`crates/ld-server/src/seed_amend.rs`).
|
||||
|
||||
### Accès — trustinWallet
|
||||
|
||||
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
|
||||
|
||||
@@ -9,19 +9,26 @@ 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 |
|
||||
| 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 |
|
||||
| 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 (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
||||
| C4 | Gestes contextuels | survoler la Raison d'être → « 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 |
|
||||
| C6 | Coquille sans vote | groupe d'essai : Préambule → « Le vote » → « Corriger une coquille », remplacer « latransmission » par « la transmission » | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
||||
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
||||
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
|
||||
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
||||
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||
| 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 |
|
||||
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
||||
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
|
||||
|
||||
## Au-delà de la liste
|
||||
|
||||
@@ -30,7 +37,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 +54,5 @@ 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 R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
|
||||
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Tableau — revue d'Yvv du 2026-10-30
|
||||
|
||||
> Tenu par orch (seul rédacteur). État courant seulement ; l'historique est dans git.
|
||||
> Source : le message « REVIEW DECISION » d'Yvv, 2026-10-30 vers 08 h.
|
||||
|
||||
## REPRISE
|
||||
|
||||
Orch seul sur le dépôt (projet à un dépôt : sous-agents en copies de travail, pas de sessions).
|
||||
A et R fusionnés (`8727315`), boucle verte, image construite, répétition sur une copie de la
|
||||
base de production faite ; relecture indépendante en cours ; dossier `actes/2026-10-30-m1-mise-en-ligne.md`.
|
||||
C lancé le 2026-10-30 à 13 h 30. B attend la recherche sur Ğ1Companion et une question (secret
|
||||
du vote et transaction publique). D après C, F après B. Rien n'est en ligne de cette revue.
|
||||
|
||||
## Critère de livraison (liste fermée)
|
||||
|
||||
| # | Ce qui fait « livré » | Lot | Mise en ligne |
|
||||
|---|---|---|---|
|
||||
| L1 | Accueil : le titre et 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. L'écran des quatre piliers disparaît de l'accueil. | A | M1 |
|
||||
| L2 | Logo « Clé de voûte » du DA (sans tampon incliné) dans le menu et la barre mobile ; favicon et icônes ; **quatre ambiances** : pierre, calcaire (claires), nuit, ardoise (sombres). Le sceau 井 ne bouge pas. | A | M1 |
|
||||
| L3 | Page d'un groupe : le pavé des quatre piliers, puis « En cours » (Monnaie libre : Acte d'engagement). | A | M1 |
|
||||
| L4 | Menu : le nombre à côté de « Docs de référence » ; surligné seulement quand on y est ; au survol, seul lui réagit ; les trois autres restent éteints mais s'ouvrent (leurs « À venir »). | A | M1 |
|
||||
| L5 | Docs de référence : « En cours » (Acte d'engagement) et « À venir » (Acte d'engagement forgerons). Mandats, à venir : Administration forum Duniter, Administration forum Monnaie libre. Protocoles, à venir : Runtime upgrade — décision on-chain. | A | M1 |
|
||||
| L6 | Décisions : seulement les docs en cours, **une ligne par doc**, avec le nombre de modules qui restent à voter. | A | M1 |
|
||||
| L7 | Le Préambule devient « Raison d'être », texte d'Yvv, en fr, en, es. | R | M1 |
|
||||
| L8 | Un article qui a plusieurs versions tient **une seule ligne** dans la liste ; ses versions se lisent dedans. | R | M1 |
|
||||
| L9 | On peut voter (voie de la question 1). « Le vote » se déplie sous l'article, sans panneau : ton vote, ceux des autres, la distance au seuil. Voter tout le doc, une section, un article. | B | M2 |
|
||||
| L10 | Inertie à **cinq crans** : 3 par défaut, 4 pour les deux engagements fondamentaux (Unicité, Réciprocité) et pour le titre du doc ; l'inertie de l'inertie hérite de celle de l'article ; les réglages de la formule sont à portée de main ; l'échelle suit la taille de l'électorat et la participation médiane du groupe. | C | M3 |
|
||||
| L11 | En fin de section, un module « ordre des articles » (inertie 2) : une liste où l'on clique pour déplacer. | D | M3 |
|
||||
| L12 | Objections « redit » et « contredit » : la plus récente des deux propositions n'est pas votable tant que son auteur n'a pas répondu (redit : modifier l'article visé ; contredit : reformuler) ; un message à l'auteur de l'objection dans trustinWallet : lever ou réitérer ; sans réponse sous 3 jours, l'objection est levée. | F | M4 |
|
||||
| L13 | Coquilles : celle d'un autre que l'auteur attend sa validation (message), puis passe seule après 3 jours ; l'auteur garde la main tant que le texte n'est pas en vigueur ; sur un texte en vigueur, seule une coquille validée le corrige. | F | M4 |
|
||||
|
||||
**Liste d'attente** (hors livraison, sauf mot d'Yvv) : l'inscription de chaque vote sur la
|
||||
chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichage dans Ğecko.
|
||||
|
||||
## Lots
|
||||
|
||||
| Lot | Contenu | Effort | État | Branche |
|
||||
|---|---|---|---|---|
|
||||
| A | L1–L6 : entrée, DA, menu, listes, Décisions | high | fusionné (`8727315`) | `lot/a-entree` |
|
||||
| R | L7–L8 : Raison d'être (amendement de seed gardé), versions groupées | high | fusionné (`68115fb`) | `lot/r-texte` |
|
||||
| B | L9 : vote déplié, vote groupé, vote par transaction de 0,1 Ğ1 | xhigh | recherche Ğ1Companion en cours ; question Q5 | — |
|
||||
| C | L10 : cinq crans, échelle, réglages | xhigh | lancé 2026-10-30 13 h 30 | `lot/c-inertie` |
|
||||
| D | L11 (+ titre du doc en module, Q4) | high | après C | — |
|
||||
| F | L12–L13 : objections, coquilles validées, messages, délai de 3 jours | xhigh | après B | — |
|
||||
|
||||
## Questions (réponse par défaut appliquée tant qu'Yvv n'a pas tranché)
|
||||
|
||||
| # | Question | Réponse |
|
||||
|---|---|---|
|
||||
| Q1 | Comment voter dans Monnaie libre | **tranché** : une transaction de 0,1 Ğ1 par vote, signée par le portefeuille (Ğ1Companion, Ğecko, plus tard trustinWallet), un seul geste ; une personne, une voix, qui s'exprime autant de fois qu'elle veut, le dernier vote fait foi |
|
||||
| Q2 | Les votes se voient-ils avec les noms ? | **tranché** : totaux seulement |
|
||||
| Q3 | Échelle des cinq crans | **tranché** : calée sur la participation médiane |
|
||||
| Q4 | Le titre d'un article se vote-t-il à part ? | **tranché** : non ; seul le titre du doc devient un module (4) |
|
||||
| Q5 | Secret du vote (Q2) et transaction publique (Q1) : le commentaire porte le choix en clair, ou une empreinte scellée ? | défaut : empreinte scellée (la chaîne montre qui a voté et quand, pas quoi) |
|
||||
|
||||
Défauts posés par orch, sans question (Yvv peut les corriger) :
|
||||
- Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote
|
||||
(propositions ouvertes et textes de départ à 0 vote) ; pour une personne qui vote, « dont N
|
||||
sans ton vote ».
|
||||
- La messagerie de trustinWallet : une boîte « Messages » dans l'application (menu), pas de
|
||||
courriel ; chaque message porte ses gestes (lever / réitérer, valider / refuser).
|
||||
- Une coquille sur un texte sans auteur (texte de départ) : validée par un administrateur, ou
|
||||
seule après 3 jours.
|
||||
- Les quatre ambiances : un sélecteur à quatre pastilles à la place du bouton soleil / lune ; la
|
||||
première visite suit le réglage clair / sombre de l'appareil (pierre / nuit).
|
||||
|
||||
## Actes
|
||||
|
||||
Aucun. Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture
|
||||
indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne
|
||||
remplace le Préambule que s'il est intact (texte de départ, 0 vote, aucune correction), et le
|
||||
dossier d'acte de M1 le dit.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Voter avec sa clé Ğ1 — lier, signer, inscrire sur la chaîne
|
||||
|
||||
> Note d'orch, 2026-10-30, pour la « grande question du vote » de la revue d'Yvv : liaison
|
||||
> avec une transaction de 1 Ğ1 pour une inscription sur la chaîne ? autre moyen ? plugin
|
||||
> obligatoire ? complicité de Ğecko pour l'affichage ?
|
||||
|
||||
## Le constat
|
||||
|
||||
Dans Monnaie libre, seuls les membres de la toile votent : une personne, une voix
|
||||
(CHANTIER.md). Le serveur ne reconnaît un membre que par la connexion Ğ1, qui passe par
|
||||
connect.monnaie-libre.fr. Cette connexion attend l'inscription de libreDecision auprès de
|
||||
poka (`DEMANDE-CONNECT.md`). D'ici là, toute identité vient d'une invitation, et **personne ne
|
||||
peut voter dans Monnaie libre**. C'est ce qu'Yvv a vu : « je ne peux pas voter dans l'ux ».
|
||||
|
||||
## Vérifié le 2026-10-30 sur l'indexeur (indexer.duniter.org)
|
||||
|
||||
- Les commentaires de transaction sont indexés (`TxComment` : `remark`, `authorId`,
|
||||
`blockNumber`, et les transferts qui le portent). Exemple réel : « Bienvenue dans la June ! »,
|
||||
bloc 3396077.
|
||||
- Un compte mène à son identité (`Account.identity`, `linkedIdentity`), donc au statut de
|
||||
membre.
|
||||
- Le serveur lit déjà la chaîne et l'indexeur pour la taille de la toile (`wot.rs`).
|
||||
|
||||
Donc, sans plugin : une transaction commentée, faite depuis Ğecko ou Cesium², se lit côté
|
||||
serveur. On y retrouve le compte qui l'a signée, l'identité de ce compte et son statut de
|
||||
membre.
|
||||
|
||||
## Non vérifié
|
||||
|
||||
- Qu'un portefeuille puisse envoyer un commentaire seul, sans transfert (une « remarque »).
|
||||
Tous les commentaires observés accompagnent un transfert.
|
||||
- Qu'un portefeuille accepte un virement vers soi-même.
|
||||
- Ce que Ğecko affiche d'une remarque sans transfert.
|
||||
|
||||
## Les voies
|
||||
|
||||
| Voie | Ce que fait la personne | Coût | Ce qu'on prouve | Dépend de |
|
||||
|---|---|---|---|---|
|
||||
| 1. Connexion Ğ1 (connect) | signe la connexion dans Ğecko | rien | la clé à chaque session ; les votes restent sur le serveur | poka |
|
||||
| 2. **Liaison par transaction** | une fois : un petit virement commenté d'un code à usage unique, vers un compte désigné | un virement, une fois | la clé, une fois pour toutes ; ensuite on vote ici | un compte destinataire |
|
||||
| 3. Une transaction par vote | un virement commenté par vote | un virement par vote ; un vote groupé = autant de virements | chaque vote, sur la chaîne, visible dans Ğecko | rien de plus, mais lourd |
|
||||
| 4. Bulletins signés, ancrés | signe ses bulletins (portefeuille ou extension) ; le serveur inscrit l'empreinte des bulletins par une remarque | presque rien | chaque vote, vérifiable par tous | la signature de messages par les portefeuilles (travaux de 1000i100 sur sso-connect, extension) |
|
||||
|
||||
## Recommandation
|
||||
|
||||
- **Maintenant : la voie 2.** Elle débloque le vote sans attendre personne, garde une
|
||||
personne, une voix (une identité Ğ1 ne se lie qu'à une identité ici) et marche avec les
|
||||
portefeuilles d'aujourd'hui. Le virement apparaît dans l'historique Ğecko de la personne.
|
||||
- **Quand connect répond : la voie 1** s'ajoute. Une identité déjà liée retrouve ses votes.
|
||||
- **Plus tard : la voie 4**, pour qu'on puisse vérifier chaque vote hors de notre serveur. Les
|
||||
votes sont déjà publiés avec le texte sur le dépôt git.
|
||||
- **La voie 3 est écartée** : un vote groupé (tout un document) coûterait des dizaines de
|
||||
virements.
|
||||
|
||||
## À trancher pour la voie 2
|
||||
|
||||
- Le compte destinataire et le montant (1 Ğ1 ?) : un compte libreDecision qu'Yvv crée dans
|
||||
Ğecko. Seule son adresse publique va dans la configuration du serveur.
|
||||
- La durée de vie du code (proposé : 24 h) et le délai de lecture (l'indexeur suit la chaîne à
|
||||
quelques blocs près).
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21).",
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).",
|
||||
"slug": "acte-engagement-certification",
|
||||
"title": {
|
||||
"fr": "Acte d'engagement Certification",
|
||||
@@ -35,14 +35,14 @@
|
||||
"label": "1.0.0",
|
||||
"status": "in_force",
|
||||
"title": {
|
||||
"fr": "Préambule",
|
||||
"en": "Preamble",
|
||||
"es": "Preámbulo"
|
||||
"fr": "Raison d'être",
|
||||
"en": "Purpose",
|
||||
"es": "Razón de ser"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
|
||||
"en": "This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
|
||||
"es": "La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado."
|
||||
"fr": "*« Derrière une clé il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé certifiante. »*\n\nC'est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur. Nous pouvons nous fier à l'unicité et l'intégrité de tout geste signé par cette clé. C'est l'enjeu de la certification.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.",
|
||||
"en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.",
|
||||
"es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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(tool('Docs de référence')).toHaveAccessibleName('Docs de référence, 1 doc en cours');
|
||||
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));
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
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 }) => {
|
||||
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||||
const units = page.locator('section.text article.unit');
|
||||
await expect(units).toHaveCount(3);
|
||||
await expect(units.nth(0).locator('h2')).toHaveText('Préambule');
|
||||
await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être");
|
||||
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
|
||||
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
|
||||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(32);
|
||||
// One line per article: E3 and E7 hold their two variants inside.
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
|
||||
});
|
||||
|
||||
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
|
||||
@@ -70,19 +83,20 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
|
||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
const preamble = page.locator('section.text article.unit').first();
|
||||
await preamble.hover();
|
||||
await preamble.getByRole('button', { name: 'Le vote' }).click();
|
||||
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||
await unicity.hover();
|
||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replace('latransmission', 'la transmission'));
|
||||
// Typographic apostrophes: the letters and the signs stay, only their style changes.
|
||||
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible();
|
||||
await expect(preamble).toContainText('la transmission de ce document');
|
||||
await expect(unicity).toContainText('Je m’engage sur l’honneur');
|
||||
// A change of meaning is turned away.
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
await body.fill('Tout autre chose, qui change le sens du préambule.');
|
||||
await body.fill('Tout autre chose, qui change le sens de cet engagement.');
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible();
|
||||
});
|
||||
@@ -144,15 +158,15 @@ 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();
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||
const panel = invited.locator('aside.panel');
|
||||
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||
// In the test group, the same person votes.
|
||||
await invited.goto(TEST_DOC);
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -172,7 +186,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.
|
||||
@@ -187,3 +201,44 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
|
||||
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
||||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('C18 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
const first = page.locator('section.text article.unit').first();
|
||||
await expect(first.locator('h2')).toHaveText("Raison d'être");
|
||||
// The first sentence in italics, then two paragraphs.
|
||||
const paragraphs = first.locator('.prose p');
|
||||
await expect(paragraphs).toHaveCount(3);
|
||||
await expect(paragraphs.nth(0).locator('em')).toContainText("Derrière une clé il n'y a qu'un seul être humain vivant");
|
||||
await expect(paragraphs.nth(1)).toContainText("C'est le seul et unique propos de la certification");
|
||||
await expect(paragraphs.nth(2)).toContainText('la première monnaie libre');
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
await expect(first.locator('h2')).toHaveText('Purpose');
|
||||
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
|
||||
await expect(first.locator('h2')).toHaveText('Razón de ser');
|
||||
});
|
||||
|
||||
test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
||||
await expect(e3).toHaveCount(1);
|
||||
await expect(e3).toContainText('2 versions');
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||
// Unfolded with the keyboard: its two versions, each with its label.
|
||||
await e3.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'true');
|
||||
const versions = page.locator(`#${await e3.getAttribute('aria-controls')}`).getByRole('button');
|
||||
await expect(versions).toHaveCount(2);
|
||||
await expect(versions.nth(0)).toContainText('variante 1.0.0');
|
||||
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
|
||||
// A version opens its vote, as any proposal.
|
||||
await versions.nth(1).click();
|
||||
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
await expect(page).toHaveURL(/version=/);
|
||||
await e3.click();
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(versions).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,15 +4,27 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#e9eeec" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon-48.png" type="image/png" sizes="48x48" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon-16.png" type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<title>libreDecision</title>
|
||||
<script>
|
||||
// Mood before first paint: no flash.
|
||||
try {
|
||||
const m = localStorage.getItem('ld:mood');
|
||||
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre';
|
||||
} catch { document.documentElement.dataset.mood = 'pierre'; }
|
||||
// Mood before first paint: no flash. Four moods, two light (pierre, calcaire) and two dark
|
||||
// (nuit, ardoise); a first visit follows the device's light or dark setting.
|
||||
(() => {
|
||||
const bg = { pierre: '#e9eeec', calcaire: '#f2ede4', nuit: '#0e1819', ardoise: '#171b20' };
|
||||
let mood = 'pierre';
|
||||
try {
|
||||
const saved = localStorage.getItem('ld:mood');
|
||||
mood = Object.hasOwn(bg, saved ?? '') ? saved : matchMedia('(prefers-color-scheme: dark)').matches ? 'nuit' : 'pierre';
|
||||
} catch {}
|
||||
document.documentElement.dataset.mood = mood;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg[mood]);
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1,45 +1,126 @@
|
||||
<script lang="ts">
|
||||
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
|
||||
// single version opens its vote; one with several shows the best of them and
|
||||
// unfolds the others, each with its label, its water level and its marks — and
|
||||
// each opens its own vote.
|
||||
import { plain } from '$lib/markdown';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import { excerpts, type Article } from '$lib/versions';
|
||||
import Icon from './Icon.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
articles,
|
||||
selected,
|
||||
onopen,
|
||||
}: { items: { unit: Unit; proposal: Proposal }[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
const variants = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
|
||||
// Unfolded by hand, or because the version open in the panel is inside.
|
||||
let unfolded = $state<Record<string, boolean>>({});
|
||||
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
|
||||
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
|
||||
|
||||
/** The title of the article: its best text version, or what the best proposal is. */
|
||||
function headline(a: Article): string {
|
||||
const text = a.versions.find((p) => p.kind === 'text');
|
||||
const best = a.versions[0];
|
||||
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
|
||||
}
|
||||
|
||||
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
|
||||
function labelOf(p: Proposal, a: Article): string {
|
||||
if (p.kind !== 'text') return titleOf(p, a.unit);
|
||||
if (p.label) return t('deep.variant', { label: p.label });
|
||||
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
||||
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
|
||||
return t('deep.version', { n: arrival + 1 });
|
||||
}
|
||||
|
||||
/** The excerpt of each text version, from where it parts ways with its siblings. */
|
||||
function toldApart(a: Article): Map<string, string> {
|
||||
const textual = a.versions.filter((p) => p.kind === 'text');
|
||||
const told = excerpts(textual.map((p) => plain(p.body)));
|
||||
return new Map(textual.map((p, k) => [p.id, told[k] ?? plain(p.body)]));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet marks(p: Proposal)}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
<ul class="deep">
|
||||
{#each items as { unit, proposal: p }, i (p.id)}
|
||||
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section}
|
||||
{#each articles as a, i (a.unit.id)}
|
||||
{@const unit = a.unit}
|
||||
{@const best = a.versions[0]}
|
||||
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
|
||||
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(p, unit)}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if}
|
||||
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
{#if best && a.versions.length === 1}
|
||||
<li class="article">
|
||||
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(best, unit)}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{@render marks(best)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</button>
|
||||
</li>
|
||||
{:else if best}
|
||||
{@const open = isOpen(a)}
|
||||
{@const title = headline(a)}
|
||||
{@const told = toldApart(a)}
|
||||
<li class="article" class:open>
|
||||
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}>
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{title}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
||||
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chevron"><Icon name="chevron" size={18} /></span>
|
||||
</button>
|
||||
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
||||
{#each a.versions as p (p.id)}
|
||||
<li>
|
||||
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{@render marks(p)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{:else}
|
||||
<li class="faint none">{t('deep.none')}</li>
|
||||
{/each}
|
||||
@@ -53,21 +134,28 @@
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.article {
|
||||
margin: 0 -1rem;
|
||||
border-radius: var(--r-card);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.article.open {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0 -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
border: 0;
|
||||
border-radius: var(--r-card);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease, transform 0.12s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.row:hover,
|
||||
.article:not(.open) .row:hover,
|
||||
.row.current {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -98,6 +186,53 @@
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.count {
|
||||
color: var(--water);
|
||||
}
|
||||
.chevron {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
color: var(--ink-faint);
|
||||
transition: transform 0.2s var(--ease);
|
||||
}
|
||||
.open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.versions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.6rem 0.7rem 1.6rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.versions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.version {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.version:hover,
|
||||
.version.current {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.label {
|
||||
font-weight: 750;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.other {
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.section {
|
||||
margin: 1.6rem 0 0.3rem;
|
||||
color: var(--ink-soft);
|
||||
@@ -108,4 +243,9 @@
|
||||
.none {
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.versions {
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
// The documents in progress of a group, each a way into its reader.
|
||||
import type { GroupView } from '$lib/group';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import type { LangMap } from '$lib/types';
|
||||
|
||||
let { group, docs, summary = true }: { group: string; docs: GroupView['docs']; summary?: boolean } = $props();
|
||||
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||
</script>
|
||||
|
||||
{#if docs.length}
|
||||
<ul class="docs">
|
||||
{#each docs as d (d.id)}
|
||||
<li>
|
||||
<a class="doc card" href="/g/{group}/docs/{d.slug}">
|
||||
<span class="title">{pick(d.title)}</span>
|
||||
{#if summary}<span class="summary">{pick(d.summary)}</span>{/if}
|
||||
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">{t('group.current.none')}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.docs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.doc {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 1.35rem 1.5rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.doc:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary {
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
// The choice of a group: Monnaie libre first and large, then the groups that use the same
|
||||
// key, then the test groups; a group one does not open shows its lock.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import type { GroupSummary } from '$lib/types';
|
||||
|
||||
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
|
||||
onMount(() => void data.load());
|
||||
|
||||
const all = $derived(data.value?.groups ?? []);
|
||||
const root = $derived(all.find((g) => g.slug === 'monnaie-libre'));
|
||||
const sameKey = $derived(all.filter((g) => g.parent === 'monnaie-libre'));
|
||||
const others = $derived(all.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
|
||||
</script>
|
||||
|
||||
<section class="choice" aria-labelledby="groups-title">
|
||||
<h2 class="eyebrow" id="groups-title">{t('groups.title')}</h2>
|
||||
|
||||
{#if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
{#if root}
|
||||
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
|
||||
<span class="eyebrow">Ğ1</span>
|
||||
<span class="name">{root.name}</span>
|
||||
{#if root.open}<span class="go" aria-hidden="true"><Icon name="chevron" size={20} /></span>{/if}
|
||||
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if sameKey.length}
|
||||
<h3 class="eyebrow sub">{t('groups.same.key')}</h3>
|
||||
<ul class="list">
|
||||
{#each sameKey as g (g.slug)}
|
||||
<li>
|
||||
{#if g.open}
|
||||
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
|
||||
{:else}
|
||||
<div class="row card locked" title={t('groups.locked.long')}>
|
||||
<span class="name">{g.name}</span>
|
||||
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if others.length}
|
||||
<h3 class="eyebrow sub">{t('groups.test')}</h3>
|
||||
<ul class="list">
|
||||
{#each others as g (g.slug)}
|
||||
<li>
|
||||
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
|
||||
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if !all.length}<p class="muted">{t('groups.empty')}</p>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.choice {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.choice > .eyebrow {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.root {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 1.6rem 1.6rem 1.7rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
a.root[href]:hover,
|
||||
a.row[href]:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.root .name {
|
||||
font-family: var(--font-text);
|
||||
font-size: clamp(1.7rem, 1.4rem + 1.2vw, 2.2rem);
|
||||
font-weight: 500;
|
||||
}
|
||||
.go {
|
||||
position: absolute;
|
||||
right: 1.4rem;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
color: var(--water);
|
||||
}
|
||||
.sub {
|
||||
margin-top: 1rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.row .name {
|
||||
font-weight: 750;
|
||||
}
|
||||
.locked {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.lock {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
// The logotype « Clé de voûte » (DA, validated by Yvv on 2026-10-28): the sign — stones in
|
||||
// the ink of the mood, the keystone in `--cle` — then *libre* in Literata italic, the colour
|
||||
// of the stones, and **Decision** in roman, the colour of the keystone. Never tilted.
|
||||
// Proportions of the DA's lockup (prod_cle.py), for a font size S: the stones run from 0.98 S
|
||||
// above the baseline to 0.06 S below it, then a gap of 0.3 S. The SVG keeps the DA's 3-unit
|
||||
// margin around 59 units of stone height, hence the sizes below.
|
||||
import sign from '$lib/assets/cle-de-voute.svg?raw';
|
||||
|
||||
let { size = 1.45 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<span class="logo" style:--s="{size}rem">
|
||||
<span class="sign" aria-hidden="true">{@html sign}</span><span class="word"><span class="libre">libre</span><span class="decision">Decision</span></span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
display: inline-block;
|
||||
font-size: var(--s);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sign {
|
||||
display: inline-block;
|
||||
color: var(--ink);
|
||||
/* 1.04 S of stones, plus the 3-unit margin above and below (65 / 59); the bottom of the
|
||||
stones 0.06 S under the baseline. */
|
||||
height: calc(1.04em * 65 / 59);
|
||||
margin-right: calc(0.3em - 1.04em * 3 / 59);
|
||||
vertical-align: calc(-0.06em - 1.04em * 3 / 59);
|
||||
}
|
||||
.sign :global(svg) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
.word {
|
||||
font-family: var(--font-text);
|
||||
font-variation-settings: 'opsz' 72;
|
||||
}
|
||||
.libre {
|
||||
font-style: italic;
|
||||
font-weight: 420;
|
||||
letter-spacing: -0.004em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.decision {
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.006em;
|
||||
margin-left: 0.02em;
|
||||
color: var(--cle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// The four pillars of a group: name, one sentence, « ouvert » or « à venir ». Each leads to its
|
||||
// tool; the open one answers the pointer, the others stay dim, like in the menu.
|
||||
import { TOOLS, type Tool } from '$lib/group';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
|
||||
let { group, tools }: { group: string; tools: { key: string; active: boolean }[] } = $props();
|
||||
|
||||
const NAMES: Record<Tool, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
||||
const TEXTS: Record<Tool, Key> = {
|
||||
decisions: 'pillar.decisions',
|
||||
docs: 'pillar.docs',
|
||||
mandats: 'pillar.mandates',
|
||||
protocoles: 'pillar.protocols',
|
||||
};
|
||||
const active = $derived(new Set(tools.filter((x) => x.active).map((x) => x.key)));
|
||||
</script>
|
||||
|
||||
<ul class="pillars">
|
||||
{#each TOOLS as tool (tool)}
|
||||
{@const open = active.has(tool)}
|
||||
<li>
|
||||
<a class="pillar" class:open href="/g/{group}/{tool}">
|
||||
<span class="head">
|
||||
<h3>{t(NAMES[tool])}</h3>
|
||||
<span class="tag" class:water={open}>{open ? t('pillar.open') : t('pillar.soon')}</span>
|
||||
</span>
|
||||
<span class="text">{t(TEXTS[tool])}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.pillars {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--r-card);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
li {
|
||||
display: grid;
|
||||
}
|
||||
.pillar {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
align-content: start;
|
||||
padding: 1.4rem 1.5rem 1.5rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.pillar.open {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.pillar.open:hover {
|
||||
background: color-mix(in srgb, var(--water) 5%, var(--surface));
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.open h3 {
|
||||
color: var(--ink);
|
||||
}
|
||||
.text {
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.pillars {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { mood } from '$lib/mood.svelte';
|
||||
import { TOOLS, type Tool } from '$lib/group';
|
||||
import { groups, toolHref } from '$lib/groups.svelte';
|
||||
import { i18n, LANGS, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { MOODS, mood } from '$lib/mood.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import Stamp from './Stamp.svelte';
|
||||
import Logo from './Logo.svelte';
|
||||
|
||||
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
|
||||
|
||||
const group = $derived(page.params.group ?? null);
|
||||
const docsHref = $derived(
|
||||
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
|
||||
);
|
||||
const LABELS: Record<Tool, Key> = {
|
||||
decisions: 'nav.decisions',
|
||||
docs: 'nav.docs',
|
||||
mandats: 'nav.mandates',
|
||||
protocoles: 'nav.protocols',
|
||||
};
|
||||
const path = $derived(page.url.pathname);
|
||||
const tools = $derived([
|
||||
{ key: 'decisions', label: t('nav.decisions'), href: null },
|
||||
{ key: 'docs', label: t('nav.docs'), href: docsHref },
|
||||
{ key: 'mandats', label: t('nav.mandates'), href: null },
|
||||
{ key: 'protocoles', label: t('nav.protocols'), href: null },
|
||||
]);
|
||||
/** The group of the page, if any. */
|
||||
const here = $derived(page.params.group ?? null);
|
||||
/** Whose tools the menu shows: the group of the page, or else the last one opened. */
|
||||
const target = $derived(here ?? (session.me ? groups.last : null));
|
||||
$effect(() => {
|
||||
if (session.me && target) groups.ensure(target);
|
||||
});
|
||||
const view = $derived(target ? groups.views[target] : undefined);
|
||||
/** Documents in progress in that group, next to « Docs de référence ». */
|
||||
const count = $derived(view?.group.open ? view.docs.length : null);
|
||||
/** A tool is highlighted only when one is in it. */
|
||||
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
|
||||
|
||||
async function signOut() {
|
||||
await session.logout();
|
||||
groups.reset();
|
||||
onnavigate?.();
|
||||
await goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label={t('nav.label')}>
|
||||
<a class="brand" href="/" onclick={() => onnavigate?.()}>
|
||||
<Stamp />
|
||||
<span class="word">libre<b>Decision</b></span>
|
||||
</a>
|
||||
<a class="brand" href="/" onclick={() => onnavigate?.()}><Logo /></a>
|
||||
|
||||
{#if session.me}
|
||||
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
|
||||
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
|
||||
<span class="eyebrow">{t('nav.groups')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ul class="tools">
|
||||
{#each tools as tool (tool.key)}
|
||||
{#each TOOLS as tool (tool)}
|
||||
{@const on = inTool(tool)}
|
||||
{@const counted = tool === 'docs' && count !== null}
|
||||
<li>
|
||||
{#if tool.href}
|
||||
<a
|
||||
href={tool.href}
|
||||
class="tool"
|
||||
class:current={path.includes('/docs')}
|
||||
aria-current={path.includes('/docs') ? 'page' : undefined}
|
||||
onclick={() => onnavigate?.()}>{tool.label}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
|
||||
{tool.label}<span class="soon">{t('nav.soon')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href={toolHref(tool, target)}
|
||||
class="tool {tool}"
|
||||
class:off={tool !== 'docs'}
|
||||
class:current={on}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
aria-label={counted ? i18n.plural('nav.docs.count', count ?? 0, { label: t(LABELS[tool]) }) : undefined}
|
||||
onclick={() => onnavigate?.()}
|
||||
>
|
||||
<span>{t(LABELS[tool])}</span>
|
||||
{#if counted}<span class="count" aria-hidden="true">{count}</span>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -76,15 +85,21 @@
|
||||
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
onclick={() => mood.toggle()}
|
||||
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
||||
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
||||
>
|
||||
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
|
||||
</button>
|
||||
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
|
||||
{#each MOODS as m (m)}
|
||||
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="swatch"
|
||||
data-mood={m}
|
||||
aria-checked={mood.current === m}
|
||||
aria-label={t(`mood.${m}`)}
|
||||
title={t(`mood.${m}`)}
|
||||
onclick={() => mood.set(m)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if session.me}
|
||||
@@ -109,17 +124,8 @@
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.word {
|
||||
font-size: 1.08rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.word b {
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
.groups {
|
||||
padding: 0 0.6rem;
|
||||
@@ -135,35 +141,47 @@
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
/* The four tools look alike; only « Docs de référence » is lit and answers the pointer.
|
||||
The three others stay dim, without any hover effect, and still lead to their page. */
|
||||
.tool {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--r-input);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
a.tool:hover {
|
||||
.tool.off {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.tool.docs:hover,
|
||||
.tool.current {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
a.tool.current {
|
||||
background: var(--water-wash);
|
||||
.tool.docs.current {
|
||||
color: var(--water);
|
||||
}
|
||||
.tool.disabled {
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
.tool.off.current {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.soon {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-faint);
|
||||
.count {
|
||||
min-width: 1.6rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
.tool.current .count {
|
||||
background: var(--surface);
|
||||
color: var(--water);
|
||||
}
|
||||
.foot {
|
||||
margin-top: auto;
|
||||
@@ -197,6 +215,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
.langs {
|
||||
@@ -209,7 +228,7 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--r-pill);
|
||||
min-width: 2.25rem;
|
||||
min-width: 2.05rem;
|
||||
min-height: 2rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
@@ -220,19 +239,33 @@
|
||||
color: var(--water);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-icon);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
.moods {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--water-wash);
|
||||
color: var(--ink);
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
/* var(--bg), var(--ink) and var(--cle) are those of the swatch's own mood. */
|
||||
background: var(--bg);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent);
|
||||
}
|
||||
.swatch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 32%;
|
||||
border-radius: 50%;
|
||||
background: var(--cle);
|
||||
}
|
||||
.swatch[aria-checked='true'] {
|
||||
/* currentColor is the ink of the page, inherited. */
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.who {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
// « À venir »: what is announced in a tool of the group — dim, and not a way in yet.
|
||||
import type { UpcomingItem } from '$lib/group';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import type { LangMap } from '$lib/types';
|
||||
|
||||
let { items }: { items: UpcomingItem[] } = $props();
|
||||
const id = $props.id();
|
||||
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||
</script>
|
||||
|
||||
<section class="soon" aria-labelledby="{id}-soon">
|
||||
<h2 class="eyebrow" id="{id}-soon">{t('group.upcoming')}</h2>
|
||||
{#if items.length}
|
||||
<ul>
|
||||
{#each items as item (item.key)}
|
||||
<li>{pick(item.title)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="faint">{t('group.upcoming.none')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.soon {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
h2 {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
li {
|
||||
padding: 1.05rem 1.5rem;
|
||||
border-radius: var(--r-card);
|
||||
background: color-mix(in srgb, var(--surface) 45%, transparent);
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.2rem;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
|
||||
let { size = 2.2 }: { size?: number } = $props();
|
||||
const lines: boolean[] = [false, true, true, false, true, false];
|
||||
</script>
|
||||
|
||||
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
|
||||
<svg viewBox="0 0 112 105">
|
||||
{#each lines as yang, i (i)}
|
||||
{@const y = 91 - i * 17}
|
||||
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.stamp {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: var(--s);
|
||||
height: var(--s);
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--water-wash);
|
||||
transform: rotate(-10deg);
|
||||
transition: transform 0.2s var(--ease);
|
||||
}
|
||||
svg {
|
||||
width: 56%;
|
||||
fill: var(--water);
|
||||
}
|
||||
:global(a:hover) > .stamp {
|
||||
transform: rotate(-2deg) scale(1.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// The page of a tool not open yet in a group (Mandats, Protocoles): what it is for, and what is
|
||||
// announced there, dim.
|
||||
import { page } from '$app/state';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import SoonList from '$lib/components/SoonList.svelte';
|
||||
import { groups } from '$lib/groups.svelte';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
|
||||
let { tool }: { tool: 'mandats' | 'protocoles' } = $props();
|
||||
const NAMES: Record<typeof tool, Key> = { mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
||||
const TEXTS: Record<typeof tool, Key> = { mandats: 'pillar.mandates', protocoles: 'pillar.protocols' };
|
||||
|
||||
const slug = $derived(page.params.group ?? '');
|
||||
const data = new Remote(() => groups.load(slug));
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void data.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
{@const g = data.value.group}
|
||||
<div class="page">
|
||||
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
|
||||
<h1>{t(NAMES[tool])}</h1>
|
||||
{#if !g.open}
|
||||
<p class="muted">{t('groups.locked.long')}</p>
|
||||
{:else}
|
||||
<p class="lead">{t(TEXTS[tool])}</p>
|
||||
<SoonList items={data.value.upcoming[tool]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.back {
|
||||
text-decoration: none;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-faint);
|
||||
max-width: 44ch;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@
|
||||
unit: { id: string; code: string; inertia: Inertia };
|
||||
inForce: { id: string; title: string; body: string } | null;
|
||||
diff: DiffPart[] | null;
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
signals: {
|
||||
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
||||
other: { id: string; title: string; status: string; code: string | null } | null;
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { LangMap } from './types';
|
||||
|
||||
/** The tools of a group, in the order of the menu. */
|
||||
export const TOOLS = ['decisions', 'docs', 'mandats', 'protocoles'] as const;
|
||||
export type Tool = (typeof TOOLS)[number];
|
||||
|
||||
/** Announced in a tool, not open yet (`seeds/upcoming/<group>.json` on the server). */
|
||||
export interface UpcomingItem {
|
||||
key: string;
|
||||
title: LangMap;
|
||||
}
|
||||
|
||||
export interface GroupView {
|
||||
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
|
||||
tools: { key: string; active: boolean }[];
|
||||
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
|
||||
upcoming: { docs: UpcomingItem[]; mandats: UpcomingItem[]; protocoles: UpcomingItem[] };
|
||||
}
|
||||
|
||||
/** « Décisions »: one line per document in progress. */
|
||||
export interface DecisionsView {
|
||||
canVote: boolean;
|
||||
docs: { id: string; slug: string; title: LangMap; toVote: number; withoutMyVote: number | null }[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// The groups as the menu and the pages share them: the last group opened on this
|
||||
// device (outside a group, the tools of the menu aim at it), and the views
|
||||
// already read (the menu counts the documents of the current group).
|
||||
import { api, ApiError } from './api';
|
||||
import type { GroupView, Tool } from './group';
|
||||
|
||||
const KEY = 'ld:group';
|
||||
|
||||
/** The last group opened, read straight from storage (usable before any mount). */
|
||||
export function lastGroup(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a tool leads: its page in the group, or home when no group is known. */
|
||||
export function toolHref(tool: Tool, group: string | null): string {
|
||||
return group ? `/g/${group}/${tool}` : '/';
|
||||
}
|
||||
|
||||
class Groups {
|
||||
last = $state<string | null>(null);
|
||||
views = $state<Record<string, GroupView>>({});
|
||||
private asked = new Set<string>();
|
||||
private running = new Map<string, Promise<GroupView>>();
|
||||
|
||||
init() {
|
||||
this.last = lastGroup();
|
||||
}
|
||||
|
||||
/** Read a group (a read already under way is shared); one that opens becomes the last group. */
|
||||
load(slug: string): Promise<GroupView> {
|
||||
const running = this.running.get(slug);
|
||||
if (running) return running;
|
||||
this.asked.add(slug);
|
||||
const p: Promise<GroupView> = this.fetch(slug).finally(() => {
|
||||
if (this.running.get(slug) === p) this.running.delete(slug);
|
||||
});
|
||||
this.running.set(slug, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private async fetch(slug: string): Promise<GroupView> {
|
||||
try {
|
||||
const v = await api.get<GroupView>(`/groups/${slug}`);
|
||||
this.views[slug] = v;
|
||||
if (v.group.open) this.remember(slug);
|
||||
else if (this.last === slug) this.forget();
|
||||
return v;
|
||||
} catch (e) {
|
||||
// A group that no longer shows or opens is no longer the last group; a network hiccup keeps it.
|
||||
if (this.last === slug && e instanceof ApiError && (e.code === 'not_found' || e.code === 'forbidden')) this.forget();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a group once, for the menu; errors stay silent there. */
|
||||
ensure(slug: string) {
|
||||
if (this.views[slug] || this.asked.has(slug)) return;
|
||||
this.load(slug).catch(() => {});
|
||||
}
|
||||
|
||||
remember(slug: string) {
|
||||
this.last = slug;
|
||||
try {
|
||||
localStorage.setItem(KEY, slug);
|
||||
} catch {
|
||||
/* no storage: the menu simply leads home */
|
||||
}
|
||||
}
|
||||
|
||||
/** On sign-out, or when the last group no longer opens. */
|
||||
forget() {
|
||||
this.last = null;
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* no storage */
|
||||
}
|
||||
}
|
||||
|
||||
/** On sign-out: nothing read under the previous identity is kept. */
|
||||
reset() {
|
||||
this.forget();
|
||||
this.views = {};
|
||||
this.asked.clear();
|
||||
this.running.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const groups = new Groups();
|
||||
@@ -10,30 +10,29 @@ export const en: Messages = {
|
||||
'nav.docs': 'Reference documents',
|
||||
'nav.mandates': 'Mandates',
|
||||
'nav.protocols': 'Protocols',
|
||||
'nav.soon': 'coming soon',
|
||||
'nav.groups': 'My groups',
|
||||
'nav.docs.count.one': '{label}, {n} document in progress',
|
||||
'nav.docs.count.other': '{label}, {n} documents in progress',
|
||||
'nav.access': 'Access',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Give feedback',
|
||||
'nav.signin': 'Sign in',
|
||||
'nav.signout': 'Sign out',
|
||||
'nav.mood.toNight': 'Night mood',
|
||||
'nav.mood.toStone': 'Stone mood',
|
||||
'nav.mood': 'Mood',
|
||||
'mood.pierre': 'Stone',
|
||||
'mood.calcaire': 'Limestone',
|
||||
'mood.nuit': 'Night',
|
||||
'mood.ardoise': 'Slate',
|
||||
'nav.lang': 'Language',
|
||||
'nav.menu': 'Menu',
|
||||
'nav.close': 'Close',
|
||||
|
||||
'home.title': 'Making collective decisions possible',
|
||||
'home.subtitle': 'depending on the context, processes and methods, a toolbox',
|
||||
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
|
||||
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
|
||||
'home.mandates': 'Create and revoke any kind of mandate.',
|
||||
'home.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
|
||||
'home.open': 'open',
|
||||
'home.soon': 'coming soon',
|
||||
'home.leitmotiv.context': 'context and framing',
|
||||
'home.leitmotiv.process': 'processes and methods',
|
||||
'home.leitmotiv.tools': 'a toolbox',
|
||||
'home.signin': 'Sign in with your Ğ1 key',
|
||||
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
|
||||
'home.continue': 'Open my groups',
|
||||
|
||||
'signin.title': 'Sign in',
|
||||
'signin.g1': 'Sign in with Ğ1',
|
||||
@@ -77,8 +76,23 @@ export const en: Messages = {
|
||||
'groups.empty': 'No group is visible yet.',
|
||||
|
||||
'group.tools': 'Tools',
|
||||
'group.tool.soon': 'This tool is coming later.',
|
||||
'group.docs.empty': 'No reference documents in this group yet.',
|
||||
'group.current': 'In progress',
|
||||
'group.current.none': 'No document in progress in this group yet.',
|
||||
'group.upcoming': 'Coming up',
|
||||
'group.upcoming.none': 'Nothing is announced here yet.',
|
||||
|
||||
'pillar.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
|
||||
'pillar.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
|
||||
'pillar.mandates': 'Create and revoke any kind of mandate.',
|
||||
'pillar.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
|
||||
'pillar.open': 'open',
|
||||
'pillar.soon': 'coming soon',
|
||||
|
||||
'decisions.note': 'A module is an article of the document. It remains to be voted on until one of its versions has been adopted by a vote.',
|
||||
'decisions.toVote.one': '{n} module to vote on',
|
||||
'decisions.toVote.other': '{n} modules to vote on',
|
||||
'decisions.withoutMyVote.one': 'of which {n} without your vote',
|
||||
'decisions.withoutMyVote.other': 'of which {n} without your vote',
|
||||
|
||||
'docs.title': 'Reference documents',
|
||||
'docs.inForce.one': '{n} article in force',
|
||||
@@ -130,6 +144,10 @@ export const en: Messages = {
|
||||
'deep.filter.mine': 'Mine',
|
||||
'deep.none': 'No proposals in this filter.',
|
||||
'deep.variant': 'variant {label}',
|
||||
'deep.versions.one': '{n} version',
|
||||
'deep.versions.other': '{n} versions',
|
||||
'deep.version': 'version {n}',
|
||||
'deep.lead': 'leading: {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Fundamental commitments',
|
||||
'section.technique': 'Technical commitments',
|
||||
@@ -208,6 +226,7 @@ export const en: Messages = {
|
||||
'history.initial': 'initial text',
|
||||
'history.typo': 'typo fixed',
|
||||
'history.substantive': 'substantive change (votes to reconfirm)',
|
||||
'history.amendment': 'starting text replaced',
|
||||
'history.by': 'by {name}',
|
||||
|
||||
'typo.title': 'Fix a typo',
|
||||
@@ -331,8 +350,6 @@ export const en: Messages = {
|
||||
'access.publication.queued': 'Publication started again.',
|
||||
'access.denied': 'Access management is for trustinWallet administrators only.',
|
||||
|
||||
'soon.title': 'Coming soon',
|
||||
'soon.body': 'This tool is coming later. Tools open one at a time: reference documents first.',
|
||||
|
||||
'error.unauthenticated': 'Sign in to continue.',
|
||||
'error.forbidden': "You don't have access to this page.",
|
||||
|
||||
@@ -10,30 +10,29 @@ export const es: Messages = {
|
||||
'nav.docs': 'Documentos de referencia',
|
||||
'nav.mandates': 'Mandatos',
|
||||
'nav.protocols': 'Protocolos',
|
||||
'nav.soon': 'próximamente',
|
||||
'nav.groups': 'Mis grupos',
|
||||
'nav.docs.count.one': '{label}, {n} documento en curso',
|
||||
'nav.docs.count.other': '{label}, {n} documentos en curso',
|
||||
'nav.access': 'Accesos',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Dejar un comentario',
|
||||
'nav.signin': 'Iniciar sesión',
|
||||
'nav.signout': 'Cerrar sesión',
|
||||
'nav.mood.toNight': 'Ambiente noche',
|
||||
'nav.mood.toStone': 'Ambiente piedra',
|
||||
'nav.mood': 'Ambiente',
|
||||
'mood.pierre': 'Piedra',
|
||||
'mood.calcaire': 'Caliza',
|
||||
'mood.nuit': 'Noche',
|
||||
'mood.ardoise': 'Pizarra',
|
||||
'nav.lang': 'Idioma',
|
||||
'nav.menu': 'Menú',
|
||||
'nav.close': 'Cerrar',
|
||||
|
||||
'home.title': 'Hacer posible la decisión colectiva',
|
||||
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas',
|
||||
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
|
||||
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
|
||||
'home.mandates': 'Crear y revocar todo tipo de mandato.',
|
||||
'home.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
|
||||
'home.open': 'abierto',
|
||||
'home.soon': 'próximamente',
|
||||
'home.leitmotiv.context': 'contexto y encuadre',
|
||||
'home.leitmotiv.process': 'procesos y modalidades',
|
||||
'home.leitmotiv.tools': 'caja de herramientas',
|
||||
'home.signin': 'Iniciar sesión con tu clave Ğ1',
|
||||
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
|
||||
'home.continue': 'Abrir mis grupos',
|
||||
|
||||
'signin.title': 'Iniciar sesión',
|
||||
'signin.g1': 'Iniciar sesión con Ğ1',
|
||||
@@ -77,8 +76,23 @@ export const es: Messages = {
|
||||
'groups.empty': 'Por ahora no hay ningún grupo visible.',
|
||||
|
||||
'group.tools': 'Herramientas',
|
||||
'group.tool.soon': 'Esta herramienta llegará más adelante.',
|
||||
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.',
|
||||
'group.current': 'En curso',
|
||||
'group.current.none': 'Por ahora no hay ningún documento en curso en este grupo.',
|
||||
'group.upcoming': 'Próximamente',
|
||||
'group.upcoming.none': 'Por ahora no hay nada anunciado aquí.',
|
||||
|
||||
'pillar.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
|
||||
'pillar.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
|
||||
'pillar.mandates': 'Crear y revocar todo tipo de mandato.',
|
||||
'pillar.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
|
||||
'pillar.open': 'abierto',
|
||||
'pillar.soon': 'próximamente',
|
||||
|
||||
'decisions.note': 'Un módulo es un artículo del documento. Queda por votar mientras ninguna de sus versiones haya sido adoptada por votación.',
|
||||
'decisions.toVote.one': '{n} módulo por votar',
|
||||
'decisions.toVote.other': '{n} módulos por votar',
|
||||
'decisions.withoutMyVote.one': 'de ellos, {n} sin tu voto',
|
||||
'decisions.withoutMyVote.other': 'de ellos, {n} sin tu voto',
|
||||
|
||||
'docs.title': 'Documentos de referencia',
|
||||
'docs.inForce.one': '{n} artículo en vigor',
|
||||
@@ -130,6 +144,10 @@ export const es: Messages = {
|
||||
'deep.filter.mine': 'Las mías',
|
||||
'deep.none': 'Ninguna propuesta en este filtro.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'deep.versions.one': '{n} versión',
|
||||
'deep.versions.other': '{n} versiones',
|
||||
'deep.version': 'versión {n}',
|
||||
'deep.lead': 'en cabeza: {label}',
|
||||
'section.introduction': 'Introducción',
|
||||
'section.fondamental': 'Compromisos fundamentales',
|
||||
'section.technique': 'Compromisos técnicos',
|
||||
@@ -208,6 +226,7 @@ export const es: Messages = {
|
||||
'history.initial': 'texto inicial',
|
||||
'history.typo': 'errata corregida',
|
||||
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
||||
'history.amendment': 'texto de partida sustituido',
|
||||
'history.by': 'por {name}',
|
||||
|
||||
'typo.title': 'Corregir una errata',
|
||||
@@ -331,8 +350,6 @@ export const es: Messages = {
|
||||
'access.publication.queued': 'Publicación relanzada.',
|
||||
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
|
||||
|
||||
'soon.title': 'Próximamente',
|
||||
'soon.body': 'Esta herramienta llegará más adelante. Las herramientas se abren una a una: primero, los documentos de referencia.',
|
||||
|
||||
'error.unauthenticated': 'Inicia sesión para continuar.',
|
||||
'error.forbidden': 'No tienes acceso a esta página.',
|
||||
|
||||
@@ -13,30 +13,29 @@ export const fr = {
|
||||
'nav.docs': 'Docs de référence',
|
||||
'nav.mandates': 'Mandats',
|
||||
'nav.protocols': 'Protocoles',
|
||||
'nav.soon': 'à venir',
|
||||
'nav.groups': 'Mes groupes',
|
||||
'nav.docs.count.one': '{label}, {n} doc en cours',
|
||||
'nav.docs.count.other': '{label}, {n} docs en cours',
|
||||
'nav.access': 'Accès',
|
||||
'nav.access.hint': 'trustinWallet',
|
||||
'nav.feedback': 'Faire un retour',
|
||||
'nav.signin': 'Se connecter',
|
||||
'nav.signout': 'Se déconnecter',
|
||||
'nav.mood.toNight': 'Ambiance nuit',
|
||||
'nav.mood.toStone': 'Ambiance pierre',
|
||||
'nav.mood': 'Ambiance',
|
||||
'mood.pierre': 'Pierre',
|
||||
'mood.calcaire': 'Calcaire',
|
||||
'mood.nuit': 'Nuit',
|
||||
'mood.ardoise': 'Ardoise',
|
||||
'nav.lang': 'Langue',
|
||||
'nav.menu': 'Menu',
|
||||
'nav.close': 'Fermer',
|
||||
|
||||
'home.title': 'Rendre possible la décision collective',
|
||||
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils',
|
||||
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
|
||||
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
|
||||
'home.mandates': 'Créer et révoquer tout type de mandat.',
|
||||
'home.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
|
||||
'home.open': 'ouvert',
|
||||
'home.soon': 'à venir',
|
||||
'home.leitmotiv.context': 'contexte et cadrage',
|
||||
'home.leitmotiv.process': 'processus et modalités',
|
||||
'home.leitmotiv.tools': 'boîte à outils',
|
||||
'home.signin': 'Se connecter avec sa clé Ğ1',
|
||||
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
|
||||
'home.continue': 'Ouvrir mes groupes',
|
||||
|
||||
'signin.title': 'Se connecter',
|
||||
'signin.g1': 'Se connecter avec Ğ1',
|
||||
@@ -80,8 +79,23 @@ export const fr = {
|
||||
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
|
||||
|
||||
'group.tools': 'Outils',
|
||||
'group.tool.soon': 'Cet outil arrive plus tard.',
|
||||
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.",
|
||||
'group.current': 'En cours',
|
||||
'group.current.none': "Aucun doc en cours dans ce groupe pour l'instant.",
|
||||
'group.upcoming': 'À venir',
|
||||
'group.upcoming.none': "Rien n'est annoncé ici pour l'instant.",
|
||||
|
||||
'pillar.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
|
||||
'pillar.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
|
||||
'pillar.mandates': 'Créer et révoquer tout type de mandat.',
|
||||
'pillar.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
|
||||
'pillar.open': 'ouvert',
|
||||
'pillar.soon': 'à venir',
|
||||
|
||||
'decisions.note': "Un module est un article du doc. Il reste à voter tant qu'aucune de ses versions n'a été adoptée par un vote.",
|
||||
'decisions.toVote.one': '{n} module à voter',
|
||||
'decisions.toVote.other': '{n} modules à voter',
|
||||
'decisions.withoutMyVote.one': 'dont {n} sans ton vote',
|
||||
'decisions.withoutMyVote.other': 'dont {n} sans ton vote',
|
||||
|
||||
'docs.title': 'Docs de référence',
|
||||
'docs.inForce.one': '{n} article en vigueur',
|
||||
@@ -133,6 +147,10 @@ export const fr = {
|
||||
'deep.filter.mine': 'Les miennes',
|
||||
'deep.none': 'Aucune proposition dans ce filtre.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'deep.versions.one': '{n} version',
|
||||
'deep.versions.other': '{n} versions',
|
||||
'deep.version': 'version {n}',
|
||||
'deep.lead': 'en tête : {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Engagements fondamentaux',
|
||||
'section.technique': 'Engagements techniques',
|
||||
@@ -211,6 +229,7 @@ export const fr = {
|
||||
'history.initial': 'texte initial',
|
||||
'history.typo': 'coquille corrigée',
|
||||
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
||||
'history.amendment': 'texte de départ remplacé',
|
||||
'history.by': 'par {name}',
|
||||
|
||||
'typo.title': 'Corriger une coquille',
|
||||
@@ -334,8 +353,6 @@ export const fr = {
|
||||
'access.publication.queued': 'Publication relancée.',
|
||||
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
|
||||
|
||||
'soon.title': 'À venir',
|
||||
'soon.body': "Cet outil arrive plus tard. Les chantiers s'ouvrent un par un : d'abord les docs de référence.",
|
||||
|
||||
'error.unauthenticated': 'Connecte-toi pour continuer.',
|
||||
'error.forbidden': "Tu n'as pas accès à cette page.",
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
|
||||
export type Mood = 'pierre' | 'nuit';
|
||||
// Four moods, two light and two dark: « pierre » and « nuit » (the water),
|
||||
// « calcaire » and « ardoise » (the ochre). Set before the first paint by
|
||||
// app.html; a first visit follows the device's light or dark setting.
|
||||
export const MOODS = ['pierre', 'calcaire', 'nuit', 'ardoise'] as const;
|
||||
export type Mood = (typeof MOODS)[number];
|
||||
|
||||
const isMood = (m: string | undefined): m is Mood => MOODS.includes(m as Mood);
|
||||
|
||||
class MoodState {
|
||||
current = $state<Mood>('pierre');
|
||||
|
||||
init() {
|
||||
const m = document.documentElement.dataset.mood;
|
||||
this.current = m === 'nuit' ? 'nuit' : 'pierre';
|
||||
this.current = isMood(m) ? m : 'pierre';
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
|
||||
document.documentElement.dataset.mood = this.current;
|
||||
set(m: Mood) {
|
||||
this.current = m;
|
||||
const root = document.documentElement;
|
||||
root.dataset.mood = m;
|
||||
const bg = getComputedStyle(root).getPropertyValue('--bg').trim();
|
||||
if (bg) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
|
||||
try {
|
||||
localStorage.setItem('ld:mood', this.current);
|
||||
localStorage.setItem('ld:mood', m);
|
||||
} catch {
|
||||
/* private mode: the mood just won't persist */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The versions of an article, for the list of open proposals (Yvv, 2026-10-30:
|
||||
// « Quand un article a plusieurs versions, faut pas ajouter un article dans la
|
||||
// liste »): one line per article, its best version first, the others inside.
|
||||
|
||||
import type { Proposal, Unit } from './types';
|
||||
|
||||
export type Filter = 'all' | 'wellrated' | 'mine';
|
||||
|
||||
export interface Article {
|
||||
unit: Unit;
|
||||
/** The open proposals of the article, best first. */
|
||||
versions: Proposal[];
|
||||
/** The best version is strictly ahead of the next one (not a tie). */
|
||||
leads: boolean;
|
||||
}
|
||||
|
||||
/** How far a version has risen toward its threshold, from 0 to 1: its water level. */
|
||||
export function level(p: Proposal): number {
|
||||
const required = p.evaluation?.required ?? 0;
|
||||
return required > 0 ? Math.min(1, p.tally.for / required) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
|
||||
* against. A blocked version comes after the others: it cannot enter into force.
|
||||
*/
|
||||
export function compare(a: Proposal, b: Proposal): number {
|
||||
return (
|
||||
Number(a.blocked) - Number(b.blocked) ||
|
||||
level(b) - level(a) ||
|
||||
b.tally.for - a.tally.for ||
|
||||
a.tally.against - b.tally.against
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain excerpts of sibling versions, each starting a few words before the versions
|
||||
* part ways — two variants often share a long opening, and a one-line excerpt would
|
||||
* show only that.
|
||||
*/
|
||||
export function excerpts(texts: string[]): string[] {
|
||||
const LEAD = 3;
|
||||
const words = texts.map((t) => t.split(/\s+/).filter(Boolean));
|
||||
const [first] = words;
|
||||
if (!first || words.length < 2) return texts;
|
||||
let common = 0;
|
||||
while (common < first.length && words.every((w) => w[common] === first[common])) common++;
|
||||
const start = common - LEAD;
|
||||
// A short common start reads well as it is.
|
||||
if (start < 4) return texts;
|
||||
return words.map((w) => `… ${w.slice(start).join(' ')}`);
|
||||
}
|
||||
|
||||
export function passes(p: Proposal, filter: Filter): boolean {
|
||||
if (filter === 'wellrated') return !!p.evaluation?.wellRated;
|
||||
if (filter === 'mine') return p.mine;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One article per unit, in the order of the document; an article is kept when at
|
||||
* least one of its versions passes the filter, and then shows all of them.
|
||||
*/
|
||||
export function articles(items: { unit: Unit; proposal: Proposal }[], filter: Filter = 'all'): Article[] {
|
||||
const byUnit = new Map<string, { unit: Unit; versions: Proposal[] }>();
|
||||
for (const { unit, proposal } of items) {
|
||||
const entry = byUnit.get(unit.id) ?? { unit, versions: [] };
|
||||
entry.versions.push(proposal);
|
||||
byUnit.set(unit.id, entry);
|
||||
}
|
||||
return [...byUnit.values()]
|
||||
.filter(({ versions }) => versions.some((p) => passes(p, filter)))
|
||||
.map(({ unit, versions }) => {
|
||||
// Array.prototype.sort is stable: equal versions keep the order of the document.
|
||||
const ranked = [...versions].sort(compare);
|
||||
const [best, next] = ranked;
|
||||
return { unit, versions: ranked, leads: !!best && !!next && compare(best, next) < 0 };
|
||||
});
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import Seal from '$lib/components/Seal.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import Stamp from '$lib/components/Stamp.svelte';
|
||||
import Toasts from '$lib/components/Toasts.svelte';
|
||||
import { groups } from '$lib/groups.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { mood } from '$lib/mood.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
@@ -19,6 +20,7 @@
|
||||
onMount(() => {
|
||||
i18n.init();
|
||||
mood.init();
|
||||
groups.init();
|
||||
void session.load();
|
||||
});
|
||||
|
||||
@@ -39,7 +41,7 @@
|
||||
</aside>
|
||||
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><Stamp size={2} /><span>libre<b>Decision</b></span></a>
|
||||
<a class="brand" href="/"><Logo size={1.2} /></a>
|
||||
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
|
||||
</button>
|
||||
@@ -151,13 +153,8 @@
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--ink);
|
||||
padding: 0.15rem 0.1rem;
|
||||
text-decoration: none;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
.brand b {
|
||||
font-weight: 800;
|
||||
}
|
||||
.menu {
|
||||
display: grid;
|
||||
|
||||
@@ -1,48 +1,36 @@
|
||||
<script lang="ts">
|
||||
import GroupChoice from '$lib/components/GroupChoice.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
|
||||
const sections = $derived([
|
||||
{ name: t('nav.decisions'), text: t('home.decisions'), open: false },
|
||||
{ name: t('nav.docs'), text: t('home.docs'), open: true },
|
||||
{ name: t('nav.mandates'), text: t('home.mandates'), open: false },
|
||||
{ name: t('nav.protocols'), text: t('home.protocols'), open: false },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="home">
|
||||
<header class="intro">
|
||||
<h1>{t('home.title')}</h1>
|
||||
<p class="sub">{t('home.subtitle')}</p>
|
||||
<ul class="leitmotiv">
|
||||
<li>{t('home.leitmotiv.context')}</li>
|
||||
<li>{t('home.leitmotiv.process')}</li>
|
||||
<li>{t('home.leitmotiv.tools')}</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<ul class="sections">
|
||||
{#each sections as s (s.name)}
|
||||
<li class="section" class:open={s.open}>
|
||||
<div class="head">
|
||||
<h2>{s.name}</h2>
|
||||
<span class="tag" class:water={s.open}>{s.open ? t('home.open') : t('home.soon')}</span>
|
||||
</div>
|
||||
<p>{s.text}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="enter">
|
||||
{#if session.me}
|
||||
<a class="btn primary" href="/groupes">{t('home.continue')}</a>
|
||||
{:else}
|
||||
{#if session.me}
|
||||
<GroupChoice />
|
||||
{:else}
|
||||
<div class="enter">
|
||||
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
|
||||
<p class="faint">{t('home.signin.note')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.home {
|
||||
max-width: 50rem;
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(3rem, 10vh, 7rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
|
||||
padding: clamp(3rem, 10vh, 6.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
|
||||
display: grid;
|
||||
gap: clamp(2.4rem, 6vh, 3.6rem);
|
||||
}
|
||||
.intro h1 {
|
||||
font-family: var(--font-text);
|
||||
@@ -54,56 +42,35 @@
|
||||
max-width: 15ch;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 1.1rem;
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
font-size: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
|
||||
color: var(--ink-soft);
|
||||
max-width: 30ch;
|
||||
}
|
||||
.sections {
|
||||
/* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */
|
||||
.leitmotiv {
|
||||
list-style: none;
|
||||
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0;
|
||||
margin: 1.4rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--r-card);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: 1.4rem 1.5rem 1.5rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
align-content: start;
|
||||
}
|
||||
.section:not(.open) {
|
||||
gap: 0.2rem;
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem);
|
||||
line-height: 1.45;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.head {
|
||||
.leitmotiv li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
.section:not(.open) h2 {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.section p {
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.55;
|
||||
.leitmotiv li::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 0.62em;
|
||||
height: 0.34em;
|
||||
border-radius: 0.1em;
|
||||
background: var(--cle);
|
||||
opacity: 0.75;
|
||||
translate: 0 -0.12em;
|
||||
}
|
||||
.enter {
|
||||
margin-top: 2.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -113,9 +80,4 @@
|
||||
font-size: 0.9rem;
|
||||
max-width: 34ch;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Me } from '$lib/types';
|
||||
|
||||
const back = $derived(page.url.searchParams.get('return') ?? '/groupes');
|
||||
const back = $derived(page.url.searchParams.get('return') ?? '/');
|
||||
const error = $derived(page.url.searchParams.get('erreur'));
|
||||
let devName = $state('');
|
||||
let devAdmin = $state(false);
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('soon.title')}</h1>
|
||||
<p class="muted">{t('soon.body')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Redirects, see +page.ts. -->
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Old address: the tool now lives in each group — the current one, or home.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||
|
||||
export function load() {
|
||||
redirect(307, toolHref('decisions', lastGroup()));
|
||||
}
|
||||
@@ -58,7 +58,7 @@
|
||||
try {
|
||||
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
|
||||
session.me = r.me;
|
||||
await goto('/groupes', { replaceState: true });
|
||||
await goto('/', { replaceState: true });
|
||||
} catch (err) {
|
||||
const code = err instanceof ApiError ? err.code : 'internal';
|
||||
if (code === 'invitation_invalid') phase = 'invalid';
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import DocCards from '$lib/components/DocCards.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import Pillars from '$lib/components/Pillars.svelte';
|
||||
import { groups } from '$lib/groups.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import type { GroupView } from '$lib/group';
|
||||
|
||||
const slug = $derived(page.params.group ?? '');
|
||||
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
|
||||
const data = new Remote(() => groups.load(slug));
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void data.load();
|
||||
});
|
||||
const labels: Record<string, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
||||
const texts: Record<string, Key> = { decisions: 'home.decisions', docs: 'home.docs', mandats: 'home.mandates', protocoles: 'home.protocols' };
|
||||
</script>
|
||||
|
||||
{#if data.error}
|
||||
@@ -21,36 +20,24 @@
|
||||
{:else if data.value}
|
||||
{@const g = data.value.group}
|
||||
<div class="page">
|
||||
<a class="eyebrow back" href="/groupes">{t('nav.groups')}</a>
|
||||
<a class="eyebrow back" href="/">{t('nav.groups')}</a>
|
||||
<h1>{g.name}</h1>
|
||||
{#if !g.open}
|
||||
<p class="muted">{t('groups.locked.long')}</p>
|
||||
{:else}
|
||||
<h2 class="eyebrow sub">{t('group.tools')}</h2>
|
||||
<ul class="tools">
|
||||
{#each data.value.tools as tool (tool.key)}
|
||||
<li>
|
||||
{#if tool.active}
|
||||
<a class="tool card" href="/g/{g.slug}/docs">
|
||||
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}</span>
|
||||
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="tool off" aria-disabled="true">
|
||||
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}<span class="tag">{t('home.soon')}</span></span>
|
||||
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<h2 class="visually-hidden">{t('group.tools')}</h2>
|
||||
<Pillars group={g.slug} tools={data.value.tools} />
|
||||
<section class="current" aria-labelledby="current-title">
|
||||
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
|
||||
<DocCards group={g.slug} docs={data.value.docs} summary={false} />
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 46rem;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
@@ -63,56 +50,14 @@
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 1.6rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.tools {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.current {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 2.2rem;
|
||||
}
|
||||
.tool {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 1.3rem 1.4rem;
|
||||
height: 100%;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
a.tool {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
a.tool:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.tool.off {
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.name {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.text {
|
||||
font-size: 0.93rem;
|
||||
line-height: 1.5;
|
||||
.current h2 {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.off .text {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tools {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
// « Décisions »: the documents in progress, one line each, with what is left to vote.
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { DecisionsView } from '$lib/group';
|
||||
import { groups } from '$lib/groups.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import type { LangMap } from '$lib/types';
|
||||
|
||||
const slug = $derived(page.params.group ?? '');
|
||||
const data = new Remote(async () => {
|
||||
const view = await groups.load(slug);
|
||||
const decisions = view.group.open ? await api.get<DecisionsView>(`/groups/${slug}/decisions`) : null;
|
||||
return { view, decisions };
|
||||
});
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void data.load();
|
||||
});
|
||||
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||
</script>
|
||||
|
||||
{#if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
{@const g = data.value.view.group}
|
||||
{@const decisions = data.value.decisions}
|
||||
<div class="page">
|
||||
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
|
||||
<h1>{t('nav.decisions')}</h1>
|
||||
{#if !decisions}
|
||||
<p class="muted">{t('groups.locked.long')}</p>
|
||||
{:else}
|
||||
<p class="lead">{t('decisions.note')}</p>
|
||||
{#if decisions.docs.length}
|
||||
<ul class="lines">
|
||||
{#each decisions.docs as d (d.id)}
|
||||
<li>
|
||||
<a class="line card" href="/g/{g.slug}/docs/{d.slug}">
|
||||
<span class="title">{pick(d.title)}</span>
|
||||
<span class="counts">
|
||||
<span class="to-vote">{i18n.plural('decisions.toVote', d.toVote)}</span>
|
||||
{#if d.withoutMyVote !== null && d.toVote > 0}
|
||||
<span class="mine">{i18n.plural('decisions.withoutMyVote', d.withoutMyVote)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="go" aria-hidden="true"><Icon name="chevron" size={16} /></span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">{t('group.current.none')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.back {
|
||||
text-decoration: none;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
max-width: 52ch;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem 1.4rem;
|
||||
padding: 1.15rem 3rem 1.15rem 1.5rem;
|
||||
position: relative;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.line:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.title {
|
||||
flex: 1 1 16rem;
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem 0.6rem;
|
||||
}
|
||||
.to-vote {
|
||||
font-weight: 800;
|
||||
color: var(--water);
|
||||
}
|
||||
.mine {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.go {
|
||||
position: absolute;
|
||||
right: 1.1rem;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import DocCards from '$lib/components/DocCards.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import SoonList from '$lib/components/SoonList.svelte';
|
||||
import { groups } from '$lib/groups.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import type { GroupView } from '$lib/group';
|
||||
import type { LangMap } from '$lib/types';
|
||||
|
||||
const slug = $derived(page.params.group ?? '');
|
||||
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
|
||||
const data = new Remote(() => groups.load(slug));
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void data.load();
|
||||
});
|
||||
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||
</script>
|
||||
|
||||
{#if data.error}
|
||||
@@ -25,27 +24,19 @@
|
||||
<h1>{t('docs.title')}</h1>
|
||||
{#if !g.open}
|
||||
<p class="muted">{t('groups.locked.long')}</p>
|
||||
{:else if !data.value.docs.length}
|
||||
<p class="muted">{t('group.docs.empty')}</p>
|
||||
{:else}
|
||||
<ul class="docs">
|
||||
{#each data.value.docs as d (d.id)}
|
||||
<li>
|
||||
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}">
|
||||
<span class="title">{pick(d.title)}</span>
|
||||
<span class="summary">{pick(d.summary)}</span>
|
||||
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<section class="current" aria-labelledby="current-title">
|
||||
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
|
||||
<DocCards group={g.slug} docs={data.value.docs} />
|
||||
</section>
|
||||
<SoonList items={data.value.upcoming.docs} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 46rem;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
@@ -60,35 +51,12 @@
|
||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.docs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.current {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
.doc {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1.6rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.doc:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary {
|
||||
.current h2 {
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
|
||||
const doc = new DocState();
|
||||
const group = $derived(page.params.group ?? '');
|
||||
@@ -42,17 +43,14 @@
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
let filter = $state<'all' | 'wellrated' | 'mine'>('all');
|
||||
let filter = $state<Filter>('all');
|
||||
const view = $derived(doc.view);
|
||||
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
|
||||
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
||||
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
||||
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
|
||||
const deep = $derived(
|
||||
doc.open.filter(({ proposal: p }) =>
|
||||
filter === 'wellrated' ? p.evaluation?.wellRated : filter === 'mine' ? p.mine : true,
|
||||
),
|
||||
);
|
||||
// One line per article; an article stays when one of its versions passes the filter.
|
||||
const deep = $derived(articles(doc.open, filter));
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
@@ -153,7 +151,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<DeepList items={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ToolSoon from '$lib/components/ToolSoon.svelte';
|
||||
</script>
|
||||
|
||||
<ToolSoon tool="mandats" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ToolSoon from '$lib/components/ToolSoon.svelte';
|
||||
</script>
|
||||
|
||||
<ToolSoon tool="protocoles" />
|
||||
@@ -1,139 +1 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import type { GroupSummary } from '$lib/types';
|
||||
|
||||
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
|
||||
onMount(() => void data.load());
|
||||
|
||||
const groups = $derived(data.value?.groups ?? []);
|
||||
const root = $derived(groups.find((g) => g.slug === 'monnaie-libre'));
|
||||
const sameKey = $derived(groups.filter((g) => g.parent === 'monnaie-libre'));
|
||||
const others = $derived(groups.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
|
||||
</script>
|
||||
|
||||
{#if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
<div class="page">
|
||||
<h1>{t('groups.title')}</h1>
|
||||
|
||||
{#if root}
|
||||
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
|
||||
<span class="eyebrow">Ğ1</span>
|
||||
<span class="name">{root.name}</span>
|
||||
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if sameKey.length}
|
||||
<h2 class="eyebrow sub">{t('groups.same.key')}</h2>
|
||||
<ul class="list">
|
||||
{#each sameKey as g (g.slug)}
|
||||
<li>
|
||||
{#if g.open}
|
||||
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
|
||||
{:else}
|
||||
<div class="row card locked" title={t('groups.locked.long')}>
|
||||
<span class="name">{g.name}</span>
|
||||
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if others.length}
|
||||
<h2 class="eyebrow sub">{t('groups.test')}</h2>
|
||||
<ul class="list">
|
||||
{#each others as g (g.slug)}
|
||||
<li>
|
||||
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
|
||||
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if !groups.length}<p class="muted">{t('groups.empty')}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(1.9rem, 1.5rem + 1.5vw, 2.5rem);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 1.6rem 1.6rem 1.7rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
a.root:hover,
|
||||
a.row:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.root .name {
|
||||
font-family: var(--font-text);
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 1.4rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.row .name {
|
||||
font-weight: 750;
|
||||
}
|
||||
.locked {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.lock {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Redirects, see +page.ts. -->
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Old address: the choice of a group is on the home page.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(307, '/');
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
try {
|
||||
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
|
||||
session.me = r.me;
|
||||
await goto('/groupes', { replaceState: true });
|
||||
await goto('/', { replaceState: true });
|
||||
} catch {
|
||||
state = 'invalid';
|
||||
}
|
||||
|
||||
@@ -1,23 +1 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('soon.title')}</h1>
|
||||
<p class="muted">{t('soon.body')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Redirects, see +page.ts. -->
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Old address: the tool now lives in each group — the current one, or home.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||
|
||||
export function load() {
|
||||
redirect(307, toolHref('mandats', lastGroup()));
|
||||
}
|
||||
@@ -1,23 +1 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('soon.title')}</h1>
|
||||
<p class="muted">{t('soon.body')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
</style>
|
||||
<!-- Redirects, see +page.ts. -->
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Old address: the tool now lives in each group — the current one, or home.
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||
|
||||
export function load() {
|
||||
redirect(307, toolHref('protocoles', lastGroup()));
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 937 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "libreDecision",
|
||||
"short_name": "libreDecision",
|
||||
"description": "Rendre possible la décision collective",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#e9eeec",
|
||||
"theme_color": "#e9eeec",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Proposal, Unit } from '../../src/lib/types';
|
||||
import { articles, excerpts, level } from '../../src/lib/versions';
|
||||
|
||||
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||
return {
|
||||
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
||||
votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(id: string, proposals: Proposal[]): Unit {
|
||||
return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals };
|
||||
}
|
||||
|
||||
/** The reader's list: every open proposal, unit by unit, in document order. */
|
||||
function items(...units: Unit[]) {
|
||||
return units.flatMap((u) => u.proposals.map((proposal) => ({ unit: u, proposal })));
|
||||
}
|
||||
|
||||
const tally = (f: number, against = 0) => ({ tally: { for: f, against } });
|
||||
|
||||
function at<T>(xs: readonly T[], i: number): T {
|
||||
const x = xs[i];
|
||||
if (x === undefined) throw new Error(`nothing at ${i}`);
|
||||
return x;
|
||||
}
|
||||
|
||||
describe('the versions of an article', () => {
|
||||
it('one line per article, in document order, its versions inside', () => {
|
||||
const e2 = unit('E2', [prop('a', 'E2')]);
|
||||
const e3 = unit('E3', [prop('b1', 'E3', { label: '1.0.0' }), prop('b2', 'E3', { label: '0.4.0-draft' })]);
|
||||
const e4 = unit('E4', [prop('c', 'E4')]);
|
||||
const list = articles(items(e2, e3, e4));
|
||||
expect(list.map((a) => a.unit.id)).toEqual(['E2', 'E3', 'E4']);
|
||||
expect(at(list, 1).versions.map((p) => p.id)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
it('the best version first: the highest water, then more for, then fewer against', () => {
|
||||
const u = unit('E3', [
|
||||
prop('low', 'E3', tally(3)),
|
||||
prop('high', 'E3', tally(20, 4)),
|
||||
prop('same', 'E3', tally(20, 1)),
|
||||
]);
|
||||
const a = at(articles(items(u)), 0);
|
||||
expect(a.versions.map((p) => p.id)).toEqual(['same', 'high', 'low']);
|
||||
expect(a.leads).toBe(true);
|
||||
});
|
||||
|
||||
it('equal versions keep their order, and none is said to lead', () => {
|
||||
const u = unit('E3', [prop('first', 'E3'), prop('second', 'E3')]);
|
||||
const a = at(articles(items(u)), 0);
|
||||
expect(a.versions.map((p) => p.id)).toEqual(['first', 'second']);
|
||||
expect(a.leads).toBe(false);
|
||||
});
|
||||
|
||||
it('a blocked version never comes first: it cannot enter into force', () => {
|
||||
const u = unit('E3', [prop('blocked', 'E3', { ...tally(40), blocked: true }), prop('free', 'E3', tally(2))]);
|
||||
expect(at(at(articles(items(u)), 0).versions, 0).id).toBe('free');
|
||||
});
|
||||
|
||||
it('a filter keeps an article when one of its versions passes, with all its versions', () => {
|
||||
const wellRated = { cast: 9, required: 50, reached: false, missing: 41, requiredRatio: 1, wellRated: true };
|
||||
const e3 = unit('E3', [prop('theirs', 'E3'), prop('mine', 'E3', { mine: true, evaluation: wellRated })]);
|
||||
const e4 = unit('E4', [prop('other', 'E4')]);
|
||||
for (const filter of ['mine', 'wellrated'] as const) {
|
||||
const list = articles(items(e3, e4), filter);
|
||||
expect(list.map((a) => a.unit.id), filter).toEqual(['E3']);
|
||||
expect(at(list, 0).versions).toHaveLength(2);
|
||||
}
|
||||
expect(articles(items(e3, e4), 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('the water level is the share of the threshold reached', () => {
|
||||
expect(level(prop('x', 'E1', tally(25)))).toBe(0.5);
|
||||
expect(level(prop('x', 'E1', tally(80)))).toBe(1);
|
||||
expect(level(prop('x', 'E1', { evaluation: null }))).toBe(0);
|
||||
});
|
||||
|
||||
it('sibling versions are told apart where they part ways', () => {
|
||||
const a = 'Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas avoir vu.';
|
||||
const b = 'Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter.';
|
||||
expect(excerpts([a, b])).toEqual([
|
||||
'… gère cette clé publique. Connaître suffisamment ne signifie pas avoir vu.',
|
||||
'… gère cette clé publique : je sais la recontacter.',
|
||||
]);
|
||||
// A short common start reads well as it is.
|
||||
expect(excerpts(['Je certifie une personne.', 'Je certifie deux personnes.'])).toEqual([
|
||||
'Je certifie une personne.',
|
||||
'Je certifie deux personnes.',
|
||||
]);
|
||||
expect(excerpts(['Seule.'])).toEqual(['Seule.']);
|
||||
});
|
||||
});
|
||||