Compare commits

..
13 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 1593e50a29 Acte M1 figé sur 8727315 (relecture ok), prémisse sur le Préambule ; CHANTIER : le nouvel accueil
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:55:17 +01:00
YvvandClaude Opus 5.5 6bbc282cb1 Dossier d'acte M1 (préparé) et tableau : réponses d'Yvv, lots A et R fusionnés, C lancé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:18:24 +01:00
YvvandClaude Opus 5.5 8727315612 Fusion du lot A : accueil sur le choix du groupe, logo Clé de voûte, quatre ambiances, menu, À venir, Décisions
Contrôles du lot R renumérotés C18 (Raison d'être) et C19 (versions d'un article).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:24:36 +01:00
YvvandClaude Opus 5.5 68115fb9d7 Fusion du lot R : Raison d'être (amendement de seed gardé), versions groupées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:26 +01:00
YvvandClaude Opus 5.5 1e0433664c v3 : menu — nom accessible de « Docs de référence » avec son nombre de docs
Le lien porte « Docs de référence, 1 doc en cours » (fr, en, es) au lieu
d'un texte masqué collé au libellé ; contrôlé dans C15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:09:01 +01:00
YvvandClaude Opus 5.5 c223c5c5ac CONTROLES : passage du lot R, C1–C14 et M1, 15 / 15 verts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:40:37 +01:00
YvvandClaude Opus 5.5 1503e579ba v3 : les versions d'un même instant gardent l'ordre où elles ont été écrites
Les variantes d'un seed partagent la même seconde : la liste les ordonnait par leur
identifiant aléatoire, donc différemment d'une base à l'autre (E3 : 0.4.0-draft avant
1.0.0 une fois sur deux, vu par C14). Dernier départage : l'ordre d'insertion (rowid).
Le moteur classe les rivales lui-même ; seul l'affichage change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:22:23 +01:00
YvvandClaude Opus 5.5 81b604e5f2 v3 : l'entrée — accueil avec le choix du groupe, logo « Clé de voûte », quatre ambiances, menu et pages d'outil par groupe
- Accueil : le titre, le leitmotiv en trois lignes (contexte et cadrage ·
  processus et modalités · boîte à outils), puis directement le choix du
  groupe ; sans session, la connexion à sa place. /groupes redirige vers /,
  « Mes groupes » et le retour après connexion mènent à /.
- Logo « Clé de voûte » du DA (signe en currentColor, clé en --cle, libre
  Literata italique, Decision romain), droit, dans le menu et la barre
  mobile ; Stamp retiré, le sceau 井 ne bouge pas. Favicon, icônes 16/32/48,
  apple-touch, 192/512, manifeste.
- Quatre ambiances : pierre et calcaire (claires), nuit et ardoise
  (sombres), --cle par ambiance ; quatre pastilles à la place du soleil /
  lune ; première visite selon l'appareil (pierre / nuit). Calcaire et
  ardoise : textes, accent, mousse et avertissement à 4,5:1 au moins.
- Page d'un groupe : les quatre piliers, puis « En cours ».
- Menu : nombre de docs en cours à côté de « Docs de référence », surligné
  seulement dedans, seul Docs réagit au survol ; les trois autres éteints
  mais ouvrent leur page ; hors d'un groupe, le dernier groupe ouvert.
- /g/[group]/docs « En cours » + « À venir » ; /mandats et /protocoles du
  groupe : « À venir » éteints ; /g/[group]/decisions : une ligne par doc,
  « N modules à voter », « dont N sans ton vote ». Anciennes adresses
  /decisions, /mandats, /protocoles : vers le groupe courant, sinon /.
- e2e : C1, C2, C11, C12, M1 mis à jour ; C13–C17 pour L1–L6 ; CONTROLES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:21:57 +01:00
YvvandClaude Opus 5.5 edc88111e0 v3 : les versions d'un article tiennent une seule ligne (L8)
- « Propositions en cours » : une ligne par article. Un article à plusieurs versions montre
  leur nombre et la meilleure en tête (niveau d'eau, pour / contre, « en tête » quand elle
  devance vraiment) ; la ligne se déplie (bouton à aria-expanded, clavier) sur ses versions,
  chacune avec son étiquette (variante, auteur), son niveau, ses marques, et un extrait qui
  part de là où les versions divergent ; un clic ouvre son vote comme avant.
- Règle pure dans versions.ts (vitest) : ordre du document, meilleure d'abord (niveau d'eau,
  puis pour, puis contre ; une version bloquée jamais en tête), un article reste dans un
  filtre dès qu'une de ses versions passe.
- e2e : C3 compte 30 lignes, C14 déplie « Connaissance suffisante » au clavier ; C11 passe
  par un article à une seule version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:42:27 +01:00
YvvandClaude Opus 5.5 a5b92faa96 v3 : la Raison d'être remplace le Préambule (L7), amendement de seed gardé
- seed : I1 devient « Raison d'être », texte d'Yvv (fr), traduit en « Purpose » et
  « Razón de ser » ; apostrophes droites comme le reste du seed, insécables avant « ; » et
  dans les guillemets.
- seed_amend.rs : un amendement de seed remplace un texte de départ dans une base existante,
  décidé une fois par document et seulement si personne n'a touché l'article (seed a minima
  d'origine, révision 1, aucun vote, aucune correction, aucune traduction de membre, aucun
  signalement, empreinte attendue). Décision consignée (migration 0004, table
  seed_amendments), ligne d'historique « texte de départ remplacé » avec son diff,
  republication mise en file là où le document publie.
- Une coquille se mesure désormais aussi contre le texte posé par l'amendement.
- Tests : règle pure (3), scénarios d'API (base neuve, amendement appliqué et republié une
  seule fois, cinq refus), e2e C13 ; C3, C6 et l'API suivent le nouveau texte (C6 : les
  apostrophes typographiques d'Unicité du compte, « latransmission » ayant disparu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:47:17 +01:00
YvvandClaude Opus 5.5 c640413a70 v3 : Décisions et « À venir » côté serveur
- ld-engine/pending : un module (article) reste à voter tant qu'aucune
  version n'a été adoptée par un vote (seed a minima non confirmé, ou pas
  de version en vigueur et au moins une version ouverte) ; les variantes
  comptent pour un ; « sans ton vote » = aucun vote valide sur aucune version.
- overview.rs : GET /api/groups/{slug} ajoute « upcoming » (docs, mandats,
  protocoles), lu au démarrage dans seeds/upcoming/<groupe>.json, trilingue,
  servi seulement à qui ouvre le groupe ; GET /api/groups/{slug}/decisions,
  une ligne par doc avec toVote et withoutMyVote (si la personne vote).
- Retour après connexion par défaut : « / » au lieu de « /groupes ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:07:39 +01:00
YvvandClaude Opus 5.5 f504010c0f Note : voter avec sa clé Ğ1 (liaison par transaction, signature, chaîne)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:19:58 +01:00
YvvandClaude Opus 5.5 d400bfc92e Tableau de la revue d'Yvv du 2026-10-30 : critère de livraison, lots, questions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:15:42 +01:00
71 changed files with 3412 additions and 704 deletions
+11 -8
View File
@@ -44,13 +44,15 @@ crates/
# sur la chaîne) · seed.rs · web.rs (sert web/build) # sur la chaîne) · seed.rs · web.rs (sert web/build)
tests/api.rs # scénarios bout en bout tests/api.rs # scénarios bout en bout
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur) 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/ web/src/
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp… lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Logo, Pillars…
routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/ # g/[group]/docs · g/[group]/docs/[doc] (lecteur,
# protocoles (à venir) # panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats ·
web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 # 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) docker/ # app.Dockerfile · app-compose.yml (sonic)
.woodpecker/ # app.yml · maquette.yml .woodpecker/ # app.yml · maquette.yml
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ 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 ```bash
export PATH=$HOME/.cargo/bin:$PATH export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 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 LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
cd web && npm run dev # :3002 à chaud cd web && npm run dev # :3002 à chaud
@@ -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 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 masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
`fr-FR` (sinon l'interface part en anglais). `fr-FR` (sinon l'interface part en anglais).
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit` - **Design** : sans bordures, arrondis 16/12/20/14, quatre ambiances `pierre`, `calcaire`
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` — (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é. 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` - **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). (provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
+149
View File
@@ -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.
+1
View File
@@ -10,6 +10,7 @@ pub mod assist;
pub mod compose; pub mod compose;
pub mod edit; pub mod edit;
pub mod names; pub mod names;
pub mod pending;
pub mod threshold; pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally}; pub use threshold::{FormulaParams, InertiaPreset, Tally};
+95
View File
@@ -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)
);
+7 -7
View File
@@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String {
{ {
p.to_owned() p.to_owned()
} }
_ => "/groupes".to_owned(), _ => "/".to_owned(),
} }
} }
@@ -577,12 +577,12 @@ mod tests {
#[test] #[test]
fn return_paths_stay_local() { fn return_paths_stay_local() {
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre"); assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
assert_eq!(safe_return(Some("//evil.example")), "/groupes"); assert_eq!(safe_return(Some("//evil.example")), "/");
assert_eq!(safe_return(Some("https://evil.example")), "/groupes"); assert_eq!(safe_return(Some("https://evil.example")), "/");
assert_eq!(safe_return(Some("/\\evil")), "/groupes"); assert_eq!(safe_return(Some("/\\evil")), "/");
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes"); assert_eq!(safe_return(Some("/\t/evil.example")), "/");
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes"); assert_eq!(safe_return(Some("/\n/evil.example")), "/");
assert_eq!(safe_return(None), "/groupes"); assert_eq!(safe_return(None), "/");
} }
#[tokio::test] #[tokio::test]
+3 -1
View File
@@ -289,9 +289,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
.bind(doc_id) .bind(doc_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .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!( let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?) "{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) .bind(doc_id)
.fetch_all(&state.db) .fetch_all(&state.db)
+8 -2
View File
@@ -11,9 +11,11 @@ pub mod auth;
pub mod config; pub mod config;
pub mod docs; pub mod docs;
pub mod error; pub mod error;
pub mod overview;
pub mod proposals; pub mod proposals;
pub mod publish; pub mod publish;
pub mod seed; pub mod seed;
pub mod seed_amend;
pub mod team; pub mod team;
pub mod util; pub mod util;
pub mod web; pub mod web;
@@ -41,6 +43,8 @@ pub struct AppState {
pub http: reqwest::Client, pub http: reqwest::Client,
/// Wakes the publisher as soon as an adoption is recorded. /// Wakes the publisher as soon as an adoption is recorded.
pub publish_wake: Arc<Notify>, 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 { impl AppState {
@@ -49,7 +53,8 @@ impl AppState {
.timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(15))
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
.build()?; .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("/auth/logout", post(auth::logout))
.route("/me", get(auth::me)) .route("/me", get(auth::me))
.route("/groups", get(docs::groups)) .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}", get(docs::document))
.route("/docs/{id}/preview", get(docs::preview)) .route("/docs/{id}/preview", get(docs::preview))
.route("/docs/{id}/assist", post(proposals::assist)) .route("/docs/{id}/assist", post(proposals::assist))
+222
View File
@@ -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);
}
}
+3 -2
View File
@@ -71,10 +71,11 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
Ok(()) 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)> { async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as( 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", ORDER BY id DESC LIMIT 1",
) )
.bind(&p.id) .bind(&p.id)
+20 -7
View File
@@ -2,10 +2,13 @@
//! //!
//! On start, missing groups are created and every `seeds/*.json` document is //! 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 //! 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 //! yet**. An existing document is never instantiated again: from its first
//! is runtime data. //! 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::AppState;
use crate::seed_amend;
use crate::util::{self, LangMap}; use crate::util::{self, LangMap};
use serde::Deserialize; use serde::Deserialize;
use std::path::Path; use std::path::Path;
@@ -64,14 +67,23 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
Ok(()) 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<()> { 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?; ensure_groups(state).await?;
let dir = state.cfg.seed_dir.clone(); let dir = state.cfg.seed_dir.clone();
let mut entries = match tokio::fs::read_dir(&dir).await { let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory"); tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
return Ok(()); return Ok(Vec::new());
} }
}; };
let mut files = Vec::new(); let mut files = Vec::new();
@@ -82,13 +94,14 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
} }
} }
files.sort(); files.sort();
let mut seeds = Vec::new();
for path in files { 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 raw = tokio::fs::read_to_string(path).await?;
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
for inst in &seed.instances { for inst in &seed.instances {
@@ -111,7 +124,7 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
instantiate(state, &seed, inst).await?; instantiate(state, &seed, inst).await?;
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated"); tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
} }
Ok(()) Ok(seed)
} }
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> { async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
+394
View File
@@ -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:?}");
}
}
}
+315 -8
View File
@@ -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. // Every other article is an open proposal at zero votes, standard inertia.
let e3 = unit(&doc, "E3"); let e3 = unit(&doc, "E3");
assert!(e3["inForce"].is_null()); 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"); assert_eq!(e3["inertia"], "standard");
// Translations seeded. // Translations seeded.
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit")); 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; b.login("Chiara", false).await;
let id = doc_id(&mut a, "monnaie-libre").await; let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let i1 = unit(&doc, "I1")["inForce"].clone(); let e1 = unit(&doc, "E1")["inForce"].clone();
let pid = i1["id"].as_str().expect("id").to_owned(); let pid = e1["id"].as_str().expect("id").to_owned();
// Anyone may fix the glued word of the preamble in force, without a vote. // Anyone may give the text in force its typographic apostrophes, without a vote.
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission"); let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
assert!(fixed.contains("m\u{2019}engage"));
let (s, v) = 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!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "typo"); assert_eq!(v["edit"], "typo");
// A change of meaning in force is refused: it goes through a proposal. // A change of meaning in force is refused: it goes through a proposal.
@@ -216,7 +219,7 @@ async fn editing_is_not_voting() {
.call( .call(
"PATCH", "PATCH",
&format!("/api/proposals/{pid}"), &format!("/api/proposals/{pid}"),
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })), Some(json!({ "title": e1["title"], "body": "Tout autre chose." })),
) )
.await; .await;
assert_eq!(s, StatusCode::CONFLICT); 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"); let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}"); 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!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale")); assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
let json: Value = 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); assert_eq!(pending(&db).await, 0);
let _ = std::fs::remove_dir_all(&dir); 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:?}");
}
}
+224
View File
@@ -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);
}
+27 -7
View File
@@ -14,10 +14,13 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
## Ce que voit la personne ## Ce que voit la personne
1. **Accueil sobre** : « Rendre possible la décision collective » / « en fonction du contexte, 1. **Accueil sobre** (revue du 2026-10-30) : « Rendre possible la décision collective », puis le
processus et modalités, boîte à outils », les quatre outils en une ligne chacun, puis la leitmotiv en trois lignes (contexte et cadrage · processus et modalités · boîte à outils), puis
connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles **directement le choix du groupe** (sans session : la connexion). Le pavé des quatre outils
(seul Docs de référence est actif). 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 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), 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 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'à 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 ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
groupes sur accord dans trustinWallet. groupes sur accord dans trustinWallet.
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ le 4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ la
Préambule et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués **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 : *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 **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. 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 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, 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 ». 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 ; 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. 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) 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. 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 ### Accès — trustinWallet
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter, Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
+16 -7
View File
@@ -9,19 +9,26 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| # | Contrôle | Geste humain | Attendu | | # | 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 » | | 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 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici |
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Préambule, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) | | 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 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 | | 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 | | 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 | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | | 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) | | 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à | | 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 | | 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 ## 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 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 ; - **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 ; - **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. - **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 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, 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, 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-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.
+60
View File
@@ -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).
+7 -7
View File
@@ -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", "slug": "acte-engagement-certification",
"title": { "title": {
"fr": "Acte d'engagement Certification", "fr": "Acte d'engagement Certification",
@@ -35,14 +35,14 @@
"label": "1.0.0", "label": "1.0.0",
"status": "in_force", "status": "in_force",
"title": { "title": {
"fr": "Préambule", "fr": "Raison d'être",
"en": "Preamble", "en": "Purpose",
"es": "Preámbulo" "es": "Razón de ser"
}, },
"body": { "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é.", "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": "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.", "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": "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." "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."
} }
} }
] ]
+40
View File
@@ -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"
}
}
]
}
+162
View File
@@ -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));
});
+8 -1
View File
@@ -1,12 +1,19 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers'; 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 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.goto(DOC);
await page.getByRole('button', { name: 'Menu' }).click(); await page.getByRole('button', { name: 'Menu' }).click();
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' }); const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible(); 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(); await drawer.getByRole('button', { name: 'Fermer' }).first().click();
const first = page.locator('section.text article.unit').first(); const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click(); await first.getByRole('button', { name: 'Le vote' }).click();
+73 -18
View File
@@ -1,39 +1,52 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers'; 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 page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective'); 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' }); const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) { 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', { 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(); 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 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']) { 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(g, { exact: true })).toBeVisible();
} }
await expect(page.getByText('Atelier du canal')).toHaveCount(0); 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 signIn(page, 'Chiara');
await page.goto(DOC); await page.goto(DOC);
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
const units = page.locator('section.text article.unit'); const units = page.locator('section.text article.unit');
await expect(units).toHaveCount(3); 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(1).locator('h2')).toHaveText('Unicité du compte');
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité'); await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3); await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); 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 }) => { 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 }) => { test('C6 — une coquille se corrige sans vote', async ({ page }) => {
await signIn(page, 'Chiara'); await signIn(page, 'Chiara');
await page.goto(TEST_DOC); await page.goto(TEST_DOC);
const preamble = page.locator('section.text article.unit').first(); const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await preamble.hover(); await unicity.hover();
await preamble.getByRole('button', { name: 'Le vote' }).click(); await unicity.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel'); const panel = page.locator('aside.panel');
await panel.getByText('Corriger une coquille').click(); await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true }); const body = panel.getByLabel('Texte', { exact: true });
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 panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible(); 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. // A change of meaning is turned away.
await panel.getByText('Corriger une coquille').click(); 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 panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible(); 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 ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage(); const invited = await ctx.newPage();
await invited.goto(`/invitation#${link.split('#')[1]}`); 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.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'); 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.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); await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the test group, the same person votes. // In the test group, the same person votes.
await invited.goto(TEST_DOC); 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(); 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 expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
await page.getByLabel('Ton nom').fill(name); await page.getByLabel('Ton nom').fill(name);
await page.getByRole('button', { name: 'Rejoindre' }).click(); await page.getByRole('button', { name: 'Rejoindre' }).click();
await expect(page).toHaveURL(/\/groupes$/); await expect(page).toHaveURL((u) => u.pathname === '/');
await ctx.close(); await ctx.close();
} }
// A shared browser: the person signed in steps aside, the next one gives a name. // 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(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2); 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);
});
+55 -2
View File
@@ -1,5 +1,8 @@
/* libreDecision v3 — tokens and base. /* 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. Borderless: depth comes from background and shadow, never from strokes.
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */ Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
@@ -23,12 +26,38 @@
--ochre: #a8661e; --ochre: #a8661e;
--ochre-wash: rgb(168 102 30 / 0.12); --ochre-wash: rgb(168 102 30 / 0.12);
--on-water: #f6f8f7; --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: 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); --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); --veil: rgb(16 30 31 / 0.28);
color-scheme: light; 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'] { [data-mood='nuit'] {
--bg: #0e1819; --bg: #0e1819;
--surface: #142224; --surface: #142224;
@@ -44,12 +73,36 @@
--ochre: #e0a458; --ochre: #e0a458;
--ochre-wash: rgb(224 164 88 / 0.12); --ochre-wash: rgb(224 164 88 / 0.12);
--on-water: #0e1819; --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: 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); --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); --veil: rgb(0 0 0 / 0.5);
color-scheme: dark; 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 { :root {
--font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif; --font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif;
@@ -166,7 +219,7 @@ button {
.btn.primary { .btn.primary {
background: var(--water); background: var(--water);
color: var(--on-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 { .btn.quiet {
background: transparent; background: transparent;
+18 -6
View File
@@ -4,15 +4,27 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" /> <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.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> <title>libreDecision</title>
<script> <script>
// Mood before first paint: no flash. // Mood before first paint: no flash. Four moods, two light (pierre, calcaire) and two dark
try { // (nuit, ardoise); a first visit follows the device's light or dark setting.
const m = localStorage.getItem('ld:mood'); (() => {
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches; const bg = { pierre: '#e9eeec', calcaire: '#f2ede4', nuit: '#0e1819', ardoise: '#171b20' };
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre'; let mood = 'pierre';
} catch { document.documentElement.dataset.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> </script>
%sveltekit.head% %sveltekit.head%
</head> </head>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+168 -28
View File
@@ -1,45 +1,126 @@
<script lang="ts"> <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 { plain } from '$lib/markdown';
import { relationOf, titleOf } from '$lib/labels'; 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 type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions';
import Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte'; import WaterLevel from './WaterLevel.svelte';
let { let {
items, articles,
selected, selected,
onopen, 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 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 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> </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"> <ul class="deep">
{#each items as { unit, proposal: p }, i (p.id)} {#each articles as a, i (a.unit.id)}
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section} {@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> <li class="section eyebrow">{sectionLabel(unit.section)}</li>
{/if} {/if}
<li> {#if best && a.versions.length === 1}
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}> <li class="article">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} /> <button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
<span class="main"> <WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="title">{titleOf(p, unit)}</span> <span class="main">
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if} <span class="title">{titleOf(best, unit)}</span>
<span class="meta"> {#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span> <span class="meta">
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if} <span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if} {#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 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} {@render marks(best)}
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if} </span>
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
</span> </span>
</span> </button>
</button> </li>
</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} {:else}
<li class="faint none">{t('deep.none')}</li> <li class="faint none">{t('deep.none')}</li>
{/each} {/each}
@@ -53,21 +134,28 @@
display: grid; display: grid;
gap: 0.25rem; 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 { .row {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
margin: 0 -1rem;
width: calc(100% + 2rem);
border: 0; border: 0;
border-radius: var(--r-card); border-radius: var(--r-card);
background: transparent; background: transparent;
text-align: left; 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 { .row.current {
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -98,6 +186,53 @@
color: var(--ink-soft); color: var(--ink-soft);
font-variant-numeric: tabular-nums; 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 { .section {
margin: 1.6rem 0 0.3rem; margin: 1.6rem 0 0.3rem;
color: var(--ink-soft); color: var(--ink-soft);
@@ -108,4 +243,9 @@
.none { .none {
padding: 0.8rem 0; padding: 0.8rem 0;
} }
@media (max-width: 560px) {
.versions {
padding-left: 0.6rem;
}
}
</style> </style>
+59
View File
@@ -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>
+144
View File
@@ -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>
+54
View File
@@ -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>
+89
View File
@@ -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>
+112 -79
View File
@@ -1,61 +1,70 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte'; import { TOOLS, type Tool } from '$lib/group';
import { mood } from '$lib/mood.svelte'; 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 { session } from '$lib/session.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import Stamp from './Stamp.svelte'; import Logo from './Logo.svelte';
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props(); let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
const group = $derived(page.params.group ?? null); const LABELS: Record<Tool, Key> = {
const docsHref = $derived( decisions: 'nav.decisions',
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes', docs: 'nav.docs',
); mandats: 'nav.mandates',
protocoles: 'nav.protocols',
};
const path = $derived(page.url.pathname); const path = $derived(page.url.pathname);
const tools = $derived([ /** The group of the page, if any. */
{ key: 'decisions', label: t('nav.decisions'), href: null }, const here = $derived(page.params.group ?? null);
{ key: 'docs', label: t('nav.docs'), href: docsHref }, /** Whose tools the menu shows: the group of the page, or else the last one opened. */
{ key: 'mandats', label: t('nav.mandates'), href: null }, const target = $derived(here ?? (session.me ? groups.last : null));
{ key: 'protocoles', label: t('nav.protocols'), href: 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() { async function signOut() {
await session.logout(); await session.logout();
groups.reset();
onnavigate?.(); onnavigate?.();
await goto('/'); await goto('/');
} }
</script> </script>
<nav class="sidebar" aria-label={t('nav.label')}> <nav class="sidebar" aria-label={t('nav.label')}>
<a class="brand" href="/" onclick={() => onnavigate?.()}> <a class="brand" href="/" onclick={() => onnavigate?.()}><Logo /></a>
<Stamp />
<span class="word">libre<b>Decision</b></span>
</a>
{#if session.me} {#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> <span class="eyebrow">{t('nav.groups')}</span>
</a> </a>
{/if} {/if}
<ul class="tools"> <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> <li>
{#if tool.href} <a
<a href={toolHref(tool, target)}
href={tool.href} class="tool {tool}"
class="tool" class:off={tool !== 'docs'}
class:current={path.includes('/docs')} class:current={on}
aria-current={path.includes('/docs') ? 'page' : undefined} aria-current={on ? 'page' : undefined}
onclick={() => onnavigate?.()}>{tool.label}</a aria-label={counted ? i18n.plural('nav.docs.count', count ?? 0, { label: t(LABELS[tool]) }) : undefined}
> onclick={() => onnavigate?.()}
{:else} >
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}> <span>{t(LABELS[tool])}</span>
{tool.label}<span class="soon">{t('nav.soon')}</span> {#if counted}<span class="count" aria-hidden="true">{count}</span>{/if}
</span> </a>
{/if}
</li> </li>
{/each} {/each}
</ul> </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> <button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each} {/each}
</div> </div>
<button <div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
class="icon-btn" {#each MOODS as m (m)}
type="button" <!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
onclick={() => mood.toggle()} <button
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')} type="button"
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')} role="radio"
> class="swatch"
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} /> data-mood={m}
</button> aria-checked={mood.current === m}
aria-label={t(`mood.${m}`)}
title={t(`mood.${m}`)}
onclick={() => mood.set(m)}
></button>
{/each}
</div>
</div> </div>
{#if session.me} {#if session.me}
@@ -109,17 +124,8 @@
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.7rem;
color: var(--ink);
text-decoration: none;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
} text-decoration: none;
.word {
font-size: 1.08rem;
letter-spacing: 0.01em;
}
.word b {
font-weight: 800;
} }
.groups { .groups {
padding: 0 0.6rem; padding: 0 0.6rem;
@@ -135,35 +141,47 @@
display: grid; display: grid;
gap: 0.15rem; 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 { .tool {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.55rem 0.7rem; min-height: 2.5rem;
padding: 0.5rem 0.7rem;
border-radius: var(--r-input); border-radius: var(--r-input);
color: var(--ink); color: var(--ink);
font-weight: 700; font-weight: 700;
text-decoration: none; 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); background: var(--water-wash);
} }
a.tool.current { .tool.docs.current {
background: var(--water-wash);
color: var(--water); color: var(--water);
} }
.tool.disabled { .tool.off.current {
color: var(--ink-faint); color: var(--ink-soft);
font-weight: 600;
cursor: default;
} }
.soon { .count {
font-size: 0.7rem; min-width: 1.6rem;
font-weight: 700; padding: 0.05rem 0.45rem;
letter-spacing: 0.04em; border-radius: var(--r-pill);
color: var(--ink-faint); 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 { .foot {
margin-top: auto; margin-top: auto;
@@ -197,6 +215,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.4rem;
padding: 0.2rem 0.3rem; padding: 0.2rem 0.3rem;
} }
.langs { .langs {
@@ -209,7 +228,7 @@
border: 0; border: 0;
background: transparent; background: transparent;
border-radius: var(--r-pill); border-radius: var(--r-pill);
min-width: 2.25rem; min-width: 2.05rem;
min-height: 2rem; min-height: 2rem;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 800; font-weight: 800;
@@ -220,19 +239,33 @@
color: var(--water); color: var(--water);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.icon-btn { .moods {
display: grid; display: inline-flex;
place-items: center; align-items: center;
width: 2.25rem; gap: 0.3rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: var(--ink-soft);
} }
.icon-btn:hover { .swatch {
background: var(--water-wash); position: relative;
color: var(--ink); 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 { .who {
display: flex; display: flex;
+49
View File
@@ -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>
-35
View File
@@ -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>
+62
View File
@@ -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>
+1 -1
View File
@@ -29,7 +29,7 @@
unit: { id: string; code: string; inertia: Inertia }; unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null; inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | 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: { signals: {
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null; 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; other: { id: string; title: string; status: string; code: string | null } | null;
+17
View File
@@ -1,7 +1,24 @@
import type { LangMap } from './types'; 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 { export interface GroupView {
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean }; group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
tools: { key: string; active: boolean }[]; tools: { key: string; active: boolean }[];
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[]; 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 }[];
} }
+93
View File
@@ -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();
+32 -15
View File
@@ -10,30 +10,29 @@ export const en: Messages = {
'nav.docs': 'Reference documents', 'nav.docs': 'Reference documents',
'nav.mandates': 'Mandates', 'nav.mandates': 'Mandates',
'nav.protocols': 'Protocols', 'nav.protocols': 'Protocols',
'nav.soon': 'coming soon',
'nav.groups': 'My groups', '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': 'Access',
'nav.access.hint': 'trustinWallet', 'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Give feedback', 'nav.feedback': 'Give feedback',
'nav.signin': 'Sign in', 'nav.signin': 'Sign in',
'nav.signout': 'Sign out', 'nav.signout': 'Sign out',
'nav.mood.toNight': 'Night mood', 'nav.mood': 'Mood',
'nav.mood.toStone': 'Stone mood', 'mood.pierre': 'Stone',
'mood.calcaire': 'Limestone',
'mood.nuit': 'Night',
'mood.ardoise': 'Slate',
'nav.lang': 'Language', 'nav.lang': 'Language',
'nav.menu': 'Menu', 'nav.menu': 'Menu',
'nav.close': 'Close', 'nav.close': 'Close',
'home.title': 'Making collective decisions possible', 'home.title': 'Making collective decisions possible',
'home.subtitle': 'depending on the context, processes and methods, a toolbox', 'home.leitmotiv.context': 'context and framing',
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.', 'home.leitmotiv.process': 'processes and methods',
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.', 'home.leitmotiv.tools': 'a toolbox',
'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.signin': 'Sign in with your Ğ1 key', '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.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.title': 'Sign in',
'signin.g1': 'Sign in with Ğ1', 'signin.g1': 'Sign in with Ğ1',
@@ -77,8 +76,23 @@ export const en: Messages = {
'groups.empty': 'No group is visible yet.', 'groups.empty': 'No group is visible yet.',
'group.tools': 'Tools', 'group.tools': 'Tools',
'group.tool.soon': 'This tool is coming later.', 'group.current': 'In progress',
'group.docs.empty': 'No reference documents in this group yet.', '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.title': 'Reference documents',
'docs.inForce.one': '{n} article in force', 'docs.inForce.one': '{n} article in force',
@@ -130,6 +144,10 @@ export const en: Messages = {
'deep.filter.mine': 'Mine', 'deep.filter.mine': 'Mine',
'deep.none': 'No proposals in this filter.', 'deep.none': 'No proposals in this filter.',
'deep.variant': 'variant {label}', '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.introduction': 'Introduction',
'section.fondamental': 'Fundamental commitments', 'section.fondamental': 'Fundamental commitments',
'section.technique': 'Technical commitments', 'section.technique': 'Technical commitments',
@@ -208,6 +226,7 @@ export const en: Messages = {
'history.initial': 'initial text', 'history.initial': 'initial text',
'history.typo': 'typo fixed', 'history.typo': 'typo fixed',
'history.substantive': 'substantive change (votes to reconfirm)', 'history.substantive': 'substantive change (votes to reconfirm)',
'history.amendment': 'starting text replaced',
'history.by': 'by {name}', 'history.by': 'by {name}',
'typo.title': 'Fix a typo', 'typo.title': 'Fix a typo',
@@ -331,8 +350,6 @@ export const en: Messages = {
'access.publication.queued': 'Publication started again.', 'access.publication.queued': 'Publication started again.',
'access.denied': 'Access management is for trustinWallet administrators only.', '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.unauthenticated': 'Sign in to continue.',
'error.forbidden': "You don't have access to this page.", 'error.forbidden': "You don't have access to this page.",
+32 -15
View File
@@ -10,30 +10,29 @@ export const es: Messages = {
'nav.docs': 'Documentos de referencia', 'nav.docs': 'Documentos de referencia',
'nav.mandates': 'Mandatos', 'nav.mandates': 'Mandatos',
'nav.protocols': 'Protocolos', 'nav.protocols': 'Protocolos',
'nav.soon': 'próximamente',
'nav.groups': 'Mis grupos', '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': 'Accesos',
'nav.access.hint': 'trustinWallet', 'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Dejar un comentario', 'nav.feedback': 'Dejar un comentario',
'nav.signin': 'Iniciar sesión', 'nav.signin': 'Iniciar sesión',
'nav.signout': 'Cerrar sesión', 'nav.signout': 'Cerrar sesión',
'nav.mood.toNight': 'Ambiente noche', 'nav.mood': 'Ambiente',
'nav.mood.toStone': 'Ambiente piedra', 'mood.pierre': 'Piedra',
'mood.calcaire': 'Caliza',
'mood.nuit': 'Noche',
'mood.ardoise': 'Pizarra',
'nav.lang': 'Idioma', 'nav.lang': 'Idioma',
'nav.menu': 'Menú', 'nav.menu': 'Menú',
'nav.close': 'Cerrar', 'nav.close': 'Cerrar',
'home.title': 'Hacer posible la decisión colectiva', 'home.title': 'Hacer posible la decisión colectiva',
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas', 'home.leitmotiv.context': 'contexto y encuadre',
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.', 'home.leitmotiv.process': 'procesos y modalidades',
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.', 'home.leitmotiv.tools': 'caja de herramientas',
'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.signin': 'Iniciar sesión con tu clave Ğ1', '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.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.title': 'Iniciar sesión',
'signin.g1': 'Iniciar sesión con Ğ1', '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.', 'groups.empty': 'Por ahora no hay ningún grupo visible.',
'group.tools': 'Herramientas', 'group.tools': 'Herramientas',
'group.tool.soon': 'Esta herramienta llegará más adelante.', 'group.current': 'En curso',
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.', '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.title': 'Documentos de referencia',
'docs.inForce.one': '{n} artículo en vigor', 'docs.inForce.one': '{n} artículo en vigor',
@@ -130,6 +144,10 @@ export const es: Messages = {
'deep.filter.mine': 'Las mías', 'deep.filter.mine': 'Las mías',
'deep.none': 'Ninguna propuesta en este filtro.', 'deep.none': 'Ninguna propuesta en este filtro.',
'deep.variant': 'variante {label}', '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.introduction': 'Introducción',
'section.fondamental': 'Compromisos fundamentales', 'section.fondamental': 'Compromisos fundamentales',
'section.technique': 'Compromisos técnicos', 'section.technique': 'Compromisos técnicos',
@@ -208,6 +226,7 @@ export const es: Messages = {
'history.initial': 'texto inicial', 'history.initial': 'texto inicial',
'history.typo': 'errata corregida', 'history.typo': 'errata corregida',
'history.substantive': 'cambio de fondo (votos por reconfirmar)', 'history.substantive': 'cambio de fondo (votos por reconfirmar)',
'history.amendment': 'texto de partida sustituido',
'history.by': 'por {name}', 'history.by': 'por {name}',
'typo.title': 'Corregir una errata', 'typo.title': 'Corregir una errata',
@@ -331,8 +350,6 @@ export const es: Messages = {
'access.publication.queued': 'Publicación relanzada.', 'access.publication.queued': 'Publicación relanzada.',
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.', '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.unauthenticated': 'Inicia sesión para continuar.',
'error.forbidden': 'No tienes acceso a esta página.', 'error.forbidden': 'No tienes acceso a esta página.',
+32 -15
View File
@@ -13,30 +13,29 @@ export const fr = {
'nav.docs': 'Docs de référence', 'nav.docs': 'Docs de référence',
'nav.mandates': 'Mandats', 'nav.mandates': 'Mandats',
'nav.protocols': 'Protocoles', 'nav.protocols': 'Protocoles',
'nav.soon': 'à venir',
'nav.groups': 'Mes groupes', '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': 'Accès',
'nav.access.hint': 'trustinWallet', 'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Faire un retour', 'nav.feedback': 'Faire un retour',
'nav.signin': 'Se connecter', 'nav.signin': 'Se connecter',
'nav.signout': 'Se déconnecter', 'nav.signout': 'Se déconnecter',
'nav.mood.toNight': 'Ambiance nuit', 'nav.mood': 'Ambiance',
'nav.mood.toStone': 'Ambiance pierre', 'mood.pierre': 'Pierre',
'mood.calcaire': 'Calcaire',
'mood.nuit': 'Nuit',
'mood.ardoise': 'Ardoise',
'nav.lang': 'Langue', 'nav.lang': 'Langue',
'nav.menu': 'Menu', 'nav.menu': 'Menu',
'nav.close': 'Fermer', 'nav.close': 'Fermer',
'home.title': 'Rendre possible la décision collective', 'home.title': 'Rendre possible la décision collective',
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils', 'home.leitmotiv.context': 'contexte et cadrage',
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.", 'home.leitmotiv.process': 'processus et modalités',
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.", 'home.leitmotiv.tools': 'boîte à outils',
'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.signin': 'Se connecter avec sa clé Ğ1', '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.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.title': 'Se connecter',
'signin.g1': 'Se connecter avec Ğ1', 'signin.g1': 'Se connecter avec Ğ1',
@@ -80,8 +79,23 @@ export const fr = {
'groups.empty': "Aucun groupe n'est visible pour l'instant.", 'groups.empty': "Aucun groupe n'est visible pour l'instant.",
'group.tools': 'Outils', 'group.tools': 'Outils',
'group.tool.soon': 'Cet outil arrive plus tard.', 'group.current': 'En cours',
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.", '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.title': 'Docs de référence',
'docs.inForce.one': '{n} article en vigueur', 'docs.inForce.one': '{n} article en vigueur',
@@ -133,6 +147,10 @@ export const fr = {
'deep.filter.mine': 'Les miennes', 'deep.filter.mine': 'Les miennes',
'deep.none': 'Aucune proposition dans ce filtre.', 'deep.none': 'Aucune proposition dans ce filtre.',
'deep.variant': 'variante {label}', '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.introduction': 'Introduction',
'section.fondamental': 'Engagements fondamentaux', 'section.fondamental': 'Engagements fondamentaux',
'section.technique': 'Engagements techniques', 'section.technique': 'Engagements techniques',
@@ -211,6 +229,7 @@ export const fr = {
'history.initial': 'texte initial', 'history.initial': 'texte initial',
'history.typo': 'coquille corrigée', 'history.typo': 'coquille corrigée',
'history.substantive': 'changement de fond (votes à reconfirmer)', 'history.substantive': 'changement de fond (votes à reconfirmer)',
'history.amendment': 'texte de départ remplacé',
'history.by': 'par {name}', 'history.by': 'par {name}',
'typo.title': 'Corriger une coquille', 'typo.title': 'Corriger une coquille',
@@ -334,8 +353,6 @@ export const fr = {
'access.publication.queued': 'Publication relancée.', 'access.publication.queued': 'Publication relancée.',
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.', '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.unauthenticated': 'Connecte-toi pour continuer.',
'error.forbidden': "Tu n'as pas accès à cette page.", 'error.forbidden': "Tu n'as pas accès à cette page.",
+15 -7
View File
@@ -1,19 +1,27 @@
// Two moods, light and dark: « pierre » (stone) and « nuit » (night). // Four moods, two light and two dark: « pierre » and « nuit » (the water),
export type Mood = 'pierre' | 'nuit'; // « 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 { class MoodState {
current = $state<Mood>('pierre'); current = $state<Mood>('pierre');
init() { init() {
const m = document.documentElement.dataset.mood; const m = document.documentElement.dataset.mood;
this.current = m === 'nuit' ? 'nuit' : 'pierre'; this.current = isMood(m) ? m : 'pierre';
} }
toggle() { set(m: Mood) {
this.current = this.current === 'nuit' ? 'pierre' : 'nuit'; this.current = m;
document.documentElement.dataset.mood = this.current; 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 { try {
localStorage.setItem('ld:mood', this.current); localStorage.setItem('ld:mood', m);
} catch { } catch {
/* private mode: the mood just won't persist */ /* private mode: the mood just won't persist */
} }
+79
View File
@@ -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 -8
View File
@@ -5,9 +5,10 @@
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte'; import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import Seal from '$lib/components/Seal.svelte'; import Seal from '$lib/components/Seal.svelte';
import Logo from '$lib/components/Logo.svelte';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import Stamp from '$lib/components/Stamp.svelte';
import Toasts from '$lib/components/Toasts.svelte'; import Toasts from '$lib/components/Toasts.svelte';
import { groups } from '$lib/groups.svelte';
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte'; import { mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
@@ -19,6 +20,7 @@
onMount(() => { onMount(() => {
i18n.init(); i18n.init();
mood.init(); mood.init();
groups.init();
void session.load(); void session.load();
}); });
@@ -39,7 +41,7 @@
</aside> </aside>
<header class="topbar"> <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)}> <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> <Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
</button> </button>
@@ -151,13 +153,8 @@
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; padding: 0.15rem 0.1rem;
color: var(--ink);
text-decoration: none; text-decoration: none;
font-size: 1.02rem;
}
.brand b {
font-weight: 800;
} }
.menu { .menu {
display: grid; display: grid;
+35 -73
View File
@@ -1,48 +1,36 @@
<script lang="ts"> <script lang="ts">
import GroupChoice from '$lib/components/GroupChoice.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.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> </script>
<div class="home"> <div class="home">
<header class="intro"> <header class="intro">
<h1>{t('home.title')}</h1> <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> </header>
<ul class="sections"> {#if session.me}
{#each sections as s (s.name)} <GroupChoice />
<li class="section" class:open={s.open}> {:else}
<div class="head"> <div class="enter">
<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}
<a class="btn primary" href="/connexion">{t('home.signin')}</a> <a class="btn primary" href="/connexion">{t('home.signin')}</a>
<p class="faint">{t('home.signin.note')}</p> <p class="faint">{t('home.signin.note')}</p>
{/if} </div>
</div> {/if}
</div> </div>
<style> <style>
.home { .home {
max-width: 50rem; max-width: 44rem;
margin: 0 auto; 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 { .intro h1 {
font-family: var(--font-text); font-family: var(--font-text);
@@ -54,56 +42,35 @@
max-width: 15ch; max-width: 15ch;
text-wrap: balance; text-wrap: balance;
} }
.sub { /* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */
margin-top: 1.1rem; .leitmotiv {
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 {
list-style: none; list-style: none;
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0; margin: 1.4rem 0 0;
padding: 0; padding: 0;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.2rem;
gap: 1px; font-family: var(--font-text);
background: var(--surface-2); font-style: italic;
border-radius: var(--r-card); font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem);
overflow: hidden; line-height: 1.45;
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) {
color: var(--ink-soft); color: var(--ink-soft);
} }
.head { .leitmotiv li {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; gap: 0.7rem;
gap: 0.6rem;
} }
h2 { .leitmotiv li::before {
font-size: 1.08rem; content: '';
font-weight: 800; flex: none;
color: var(--ink); width: 0.62em;
} height: 0.34em;
.section:not(.open) h2 { border-radius: 0.1em;
color: var(--ink-soft); background: var(--cle);
} opacity: 0.75;
.section p { translate: 0 -0.12em;
font-size: 0.96rem;
line-height: 1.55;
} }
.enter { .enter {
margin-top: 2.6rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -113,9 +80,4 @@
font-size: 0.9rem; font-size: 0.9rem;
max-width: 34ch; max-width: 34ch;
} }
@media (max-width: 640px) {
.sections {
grid-template-columns: 1fr;
}
}
</style> </style>
+1 -1
View File
@@ -6,7 +6,7 @@
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types'; 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')); const error = $derived(page.url.searchParams.get('erreur'));
let devName = $state(''); let devName = $state('');
let devAdmin = $state(false); let devAdmin = $state(false);
+1 -23
View File
@@ -1,23 +1 @@
<script lang="ts"> <!-- Redirects, see +page.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>
+7
View File
@@ -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()));
}
+1 -1
View File
@@ -58,7 +58,7 @@
try { try {
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name }); const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
session.me = r.me; session.me = r.me;
await goto('/groupes', { replaceState: true }); await goto('/', { replaceState: true });
} catch (err) { } catch (err) {
const code = err instanceof ApiError ? err.code : 'internal'; const code = err instanceof ApiError ? err.code : 'internal';
if (code === 'invitation_invalid') phase = 'invalid'; if (code === 'invitation_invalid') phase = 'invalid';
+17 -72
View File
@@ -1,19 +1,18 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 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 { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
const slug = $derived(page.params.group ?? ''); const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`)); const data = new Remote(() => groups.load(slug));
$effect(() => { $effect(() => {
void slug; void slug;
void data.load(); 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> </script>
{#if data.error} {#if data.error}
@@ -21,36 +20,24 @@
{:else if data.value} {:else if data.value}
{@const g = data.value.group} {@const g = data.value.group}
<div class="page"> <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> <h1>{g.name}</h1>
{#if !g.open} {#if !g.open}
<p class="muted">{t('groups.locked.long')}</p> <p class="muted">{t('groups.locked.long')}</p>
{:else} {:else}
<h2 class="eyebrow sub">{t('group.tools')}</h2> <h2 class="visually-hidden">{t('group.tools')}</h2>
<ul class="tools"> <Pillars group={g.slug} tools={data.value.tools} />
{#each data.value.tools as tool (tool.key)} <section class="current" aria-labelledby="current-title">
<li> <h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
{#if tool.active} <DocCards group={g.slug} docs={data.value.docs} summary={false} />
<a class="tool card" href="/g/{g.slug}/docs"> </section>
<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>
{/if} {/if}
</div> </div>
{/if} {/if}
<style> <style>
.page { .page {
max-width: 46rem; max-width: 48rem;
margin: 0 auto; margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem; padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid; display: grid;
@@ -63,56 +50,14 @@
font-family: var(--font-text); font-family: var(--font-text);
font-weight: 500; font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem); font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1.2rem;
} }
.sub { .current {
margin-top: 1.6rem;
color: var(--ink-soft);
}
.tools {
list-style: none;
margin: 0;
padding: 0;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem; gap: 0.8rem;
margin-top: 2.2rem;
} }
.tool { .current h2 {
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;
color: var(--ink-soft); color: var(--ink-soft);
} }
.off .text {
color: var(--ink-faint);
}
@media (max-width: 640px) {
.tools {
grid-template-columns: 1fr;
}
}
</style> </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>
+14 -46
View File
@@ -1,19 +1,18 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 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 { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
import type { LangMap } from '$lib/types';
const slug = $derived(page.params.group ?? ''); const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`)); const data = new Remote(() => groups.load(slug));
$effect(() => { $effect(() => {
void slug; void slug;
void data.load(); void data.load();
}); });
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script> </script>
{#if data.error} {#if data.error}
@@ -25,27 +24,19 @@
<h1>{t('docs.title')}</h1> <h1>{t('docs.title')}</h1>
{#if !g.open} {#if !g.open}
<p class="muted">{t('groups.locked.long')}</p> <p class="muted">{t('groups.locked.long')}</p>
{:else if !data.value.docs.length}
<p class="muted">{t('group.docs.empty')}</p>
{:else} {:else}
<ul class="docs"> <section class="current" aria-labelledby="current-title">
{#each data.value.docs as d (d.id)} <h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
<li> <DocCards group={g.slug} docs={data.value.docs} />
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}"> </section>
<span class="title">{pick(d.title)}</span> <SoonList items={data.value.upcoming.docs} />
<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>
{/if} {/if}
</div> </div>
{/if} {/if}
<style> <style>
.page { .page {
max-width: 46rem; max-width: 48rem;
margin: 0 auto; margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem; padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid; display: grid;
@@ -60,35 +51,12 @@
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem); font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.docs { .current {
list-style: none;
margin: 0;
padding: 0;
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
margin-bottom: 1.8rem;
} }
.doc { .current h2 {
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 {
color: var(--ink-soft); color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
} }
</style> </style>
@@ -10,6 +10,7 @@
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte'; import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte'; import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import type { Lang, Proposal, Unit } from '$lib/types'; import type { Lang, Proposal, Unit } from '$lib/types';
import { articles, type Filter } from '$lib/versions';
const doc = new DocState(); const doc = new DocState();
const group = $derived(page.params.group ?? ''); const group = $derived(page.params.group ?? '');
@@ -42,17 +43,14 @@
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true }); 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 view = $derived(doc.view);
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr'); const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
const lang = $derived<Lang>(doc.original ? source : i18n.lang); const lang = $derived<Lang>(doc.original ? source : i18n.lang);
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : ''); const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : ''); const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
const deep = $derived( // One line per article; an article stays when one of its versions passes the filter.
doc.open.filter(({ proposal: p }) => const deep = $derived(articles(doc.open, filter));
filter === 'wellrated' ? p.evaluation?.wellRated : filter === 'mine' ? p.mine : true,
),
);
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length; const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const VIEWS: View[] = ['inforce', 'wellrated', 'all']; const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
@@ -153,7 +151,7 @@
{/each} {/each}
</div> </div>
</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> <button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
</section> </section>
</div> </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
View File
@@ -1,139 +1 @@
<script lang="ts"> <!-- Redirects, see +page.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>
+6
View File
@@ -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, '/');
}
+1 -1
View File
@@ -19,7 +19,7 @@
try { try {
const r = await api.post<{ me: Me }>('/auth/invitation', { token }); const r = await api.post<{ me: Me }>('/auth/invitation', { token });
session.me = r.me; session.me = r.me;
await goto('/groupes', { replaceState: true }); await goto('/', { replaceState: true });
} catch { } catch {
state = 'invalid'; state = 'invalid';
} }
+1 -23
View File
@@ -1,23 +1 @@
<script lang="ts"> <!-- Redirects, see +page.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>
+7
View File
@@ -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
View File
@@ -1,23 +1 @@
<script lang="ts"> <!-- Redirects, see +page.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>
+7
View File
@@ -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()));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+16
View File
@@ -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" }
]
}
+98
View File
@@ -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.']);
});
});