7
0
forked from yvv/decision

Typo Plus Jakarta Sans + renommage libreDecision + mode démo prod + seed mandats

- Fonte : Nunito → Plus Jakarta Sans (moderne, ronde sans être toy)
- Logo : ğ(Decision) → libreDecision (libre italic/muted + Decision bold)
- Footer et watermark DocumentPreview mis à jour
- Mode démo : DEMO_MODE flag dans config.py + auth.py (profils rapides en prod)
- docker-compose : ENVIRONMENT=production explicite + DEMO_MODE=true par défaut
- Seed : +décision Licence G1 v0.4.0, +3 mandats (ComTech, Admin Forgerons, Modération)
  runner 7→10 étapes, import Mandate/MandateStep ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-03-24 01:22:36 +01:00
parent feaf4de7e0
commit a8dc0dfb40
9 changed files with 388 additions and 56 deletions

101
CLAUDE.md
View File

@@ -1,25 +1,88 @@
# libreDecision
Plateforme de decisions collectives pour la communaute Duniter/G1.
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
Documents modulaires sous vote permanent + protocoles de vote + mandats.
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
## Protocole de début de session
1. `git pull --rebase origin main`
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
3. Si l'objectif de la session n'est pas précisé, le demander
## Stack
- **Frontend**: Nuxt 4 + Nuxt UI v3 + Pinia + UnoCSS (port 3002)
- **Backend**: Python FastAPI + SQLAlchemy async + PostgreSQL asyncpg (port 8002)
- **Auth**: Duniter V2 Ed25519 challenge-response
- **Sanctuaire**: IPFS (kubo) + hash on-chain (system.remark)
## Commands
- Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
- Backend tests: `cd backend && pytest tests/ -v`
- Frontend: `cd frontend && npm run dev`
- Frontend build: `cd frontend && npm run build`
- Migrations: `cd backend && alembic upgrade head`
- Docker: `docker compose -f docker/docker-compose.yml up`
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
## Conventions
- French for UI text and documentation
- English for code (variable names, comments, docstrings)
- API versioned under `/api/v1/`
- Pydantic v2 for all schemas
- Async everywhere (SQLAlchemy, FastAPI)
- Ed25519 signatures for vote integrity
## Structure
```
frontend/
app/
components/ # composants Vue
layouts/ # layouts Nuxt
pages/ # routing file-based (9 pages sprint 1)
composables/ # (1 composable sprint 1)
stores/ # 5 Pinia stores (auth, ...)
assets/css/
moods.css # système de palettes (.mood-* sur <html>)
utils/ # (2 utils sprint 1)
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
backend/
app/
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
services/ # 6 services
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
models/ # 14 tables SQLAlchemy
alembic/versions/ # migrations
tests/ # 186 tests (63 intégration TDD sprint 1)
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
docker/
docker-compose.yml # postgres + backend + frontend + ipfs
backend.Dockerfile
frontend.Dockerfile
docs/content/ # 7 docs dev + 8 docs user
public/
hexagram-tsing.svg # sceau 井 (embossed)
hexagram-tsing-flat.svg
```
## Données runtime
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
- **ipfs-data** : volume Docker IPFS kubo
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
## Commandes
```bash
# Backend
cd backend && . venv/bin/activate
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
pytest tests/ -v
alembic upgrade head
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
# Frontend
cd frontend && npm run dev # :3002
npm run build
# Docker
docker compose -f docker/docker-compose.yml up
```
## Conventions / pièges
- **UI français, code anglais** (variables, commentaires, docstrings)
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
- **Domaine** : libredecision.org (Woodpecker CI ; ancien dossier : Glibredecision)
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test

View File

@@ -8,6 +8,7 @@ class Settings(BaseSettings):
# Environment
ENVIRONMENT: str = "development" # development, staging, production
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
LOG_LEVEL: str = "INFO"
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod

View File

@@ -147,7 +147,7 @@ async def verify_challenge(
# 6. Get or create identity (apply dev profile if available)
dev_profile = None
if settings.ENVIRONMENT == "development":
if settings.ENVIRONMENT == "development" or settings.DEMO_MODE:
dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None)
identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile)
@@ -162,8 +162,8 @@ async def verify_challenge(
@router.get("/dev/profiles")
async def list_dev_profiles():
"""List available dev profiles for quick login. Only available in development."""
if settings.ENVIRONMENT != "development":
"""List available demo profiles for quick login. Available in development or demo mode."""
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
return DEV_PROFILES

View File

@@ -31,6 +31,7 @@ from app.database import async_session, engine, Base, init_db
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
@@ -2143,6 +2144,270 @@ async def seed_votes_on_items(
print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)")
# ---------------------------------------------------------------------------
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
Decision,
"title",
"Évolution Licence G1 v0.4.0",
description=(
"Proposition d'évolution de la Licence G1 vers la version 0.4.0. "
"Intègre les retours du forum, clarifie les engagements de certification "
"et précise le processus de vote nuancé."
),
context=(
"La Licence G1 v0.3.0 est en vigueur depuis l'origine de la monnaie libre. "
"Des discussions communautaires approfondies (topics 31066, 32375, 32409, 32412) "
"ont permis d'identifier des clarifications nécessaires. "
"Cette décision lance le processus de vote communautaire pour l'adoption "
"de la v0.4.0 selon le protocole Vote WoT standard."
),
decision_type="community",
status="draft",
)
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
steps = [
{
"step_order": 1,
"step_type": "qualification",
"title": "Rédaction de la proposition",
"description": (
"Co-rédaction de la v0.4.0 en intégrant les retours des discussions "
"forum. Coordination entre 1000i100, Natha, Pini et la communauté."
),
},
{
"step_order": 2,
"step_type": "review",
"title": "Période de commentaires",
"description": (
"Publication de la proposition sur le forum pendant 30 jours. "
"Recueil des amendements et objections. Intégration des retours "
"dans la version finale soumise au vote."
),
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote WoT (nuancé)",
"description": (
"Vote nuancé à 6 niveaux ouvert à tous les membres de la WoT. "
"Durée : 30 jours. Protocole : Vote WoT standard avec formule inertie."
),
},
{
"step_order": 4,
"step_type": "execution",
"title": "Mise à jour du dépôt officiel",
"description": (
"Si le seuil est atteint : mise à jour du dépôt git officiel, "
"calcul du hash IPFS, ancrage on-chain via system.remark."
),
},
]
for step_data in steps:
step = DecisionStep(decision_id=decision.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps)} steps created")
return decision
# ---------------------------------------------------------------------------
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
# Find Charlie (techcomm voter) as mandatee, or use first voter
mandatee_techcomm = next(
(v for v in voters if "Cgeek" in v.display_name or "Elois" in v.display_name),
voters[0] if voters else None,
)
mandatee_smith = next(
(v for v in voters if "Moul" in v.display_name or "Tuxmain" in v.display_name),
voters[1] if len(voters) > 1 else None,
)
mandates_data = [
{
"title": "Mandat Comité Technique — Session 2025-2026",
"description": (
"Le Comité Technique (ComTech) est mandaté par la communauté pour "
"assurer la validation technique des propositions on-chain : "
"runtime upgrades, modifications de paramètres réseau, audits de code. "
"Composition : 5 membres élus pour 12 mois."
),
"mandate_type": "techcomm",
"status": "active",
"mandatee_id": mandatee_techcomm.id if mandatee_techcomm else None,
"starts_at": now - timedelta(days=90),
"ends_at": now + timedelta(days=275),
"steps": [
{
"step_order": 1,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "completed",
"description": "Période de candidatures ouvertes sur le forum Duniter. Durée : 14 jours.",
"outcome": "5 candidats retenus : Elois, Cgeek, Maaltir, Hugo, Tuxmain",
},
{
"step_order": 2,
"step_type": "vote",
"title": "Élection par vote nuancé",
"status": "completed",
"description": "Vote WoT ouvert à tous les membres. Protocole Vote Nuancé (6 niveaux).",
"outcome": "Quorum atteint. 5 membres élus à plus de 70% de soutien.",
},
{
"step_order": 3,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "completed",
"description": "Mise en place du ComTech, définition des processus internes de décision.",
"outcome": "ComTech opérationnel depuis le 2025-09-15.",
},
{
"step_order": 4,
"step_type": "reporting",
"title": "Rapport de mi-mandat",
"status": "active",
"description": "Rapport public d'activité à mi-parcours du mandat.",
},
{
"step_order": 5,
"step_type": "completion",
"title": "Fin de mandat et bilan",
"status": "pending",
"description": "Rapport final, transmission aux successeurs, renouvellement ou dissolution.",
},
],
},
{
"title": "Mandat Administrateur des Forgerons — Rotation 2026-Q1",
"description": (
"L'Administrateur des Forgerons coordonne l'onboarding des nouveaux "
"forgerons, maintient la liste des nœuds actifs et anime les "
"discussions techniques de la sous-WoT Smith. "
"Mandat tournant de 6 mois, renouvelable une fois."
),
"mandate_type": "smith",
"status": "voting",
"mandatee_id": mandatee_smith.id if mandatee_smith else None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Définition du rôle",
"status": "completed",
"description": "Rédaction de la fiche de rôle et des responsabilités de l'Administrateur.",
"outcome": "Fiche de rôle validée par consensus sur le forum Duniter (topic 34201).",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures forgerons",
"status": "completed",
"description": "Candidatures ouvertes aux forgerons actifs depuis plus de 6 mois.",
"outcome": "2 candidats : Moul (Forgeron senior), Tuxmain (Forgeron actif).",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote forgeron (Smith)",
"status": "active",
"description": (
"Vote Smith à double critère : quorum WoT + quorum forgerons. "
"Durée : 30 jours. En cours."
),
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Passation avec l'administrateur sortant. Accès aux outils de coordination.",
},
],
},
{
"title": "Mandat Modération Forum — Duniter V2",
"description": (
"Équipe de modération élue pour maintenir la qualité des discussions "
"sur forum.duniter.org et forum.monnaie-libre.fr. "
"3 modérateurs, mandat de 12 mois."
),
"mandate_type": "custom",
"status": "draft",
"mandatee_id": None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Élaboration de la charte de modération",
"status": "active",
"description": "Définition des règles, outils et périmètre de la modération communautaire.",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "pending",
"description": "Ouvert à tous les membres de la WoT G1.",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Élection",
"status": "pending",
"description": "Vote WoT standard.",
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Formation aux outils Discourse et mise en place de la rotation.",
},
],
},
]
for m_data in mandates_data:
steps_data = m_data.pop("steps")
mandate, created = await get_or_create(
session,
Mandate,
"title",
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
if created:
for step_data in steps_data:
step = MandateStep(mandate_id=mandate.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Main seed runner
# ---------------------------------------------------------------------------
@@ -2154,29 +2419,32 @@ async def run_seed():
# Ensure tables exist
await init_db()
print("[0/8] Tables created.\n")
print("[0/10] Tables created.\n")
async with async_session() as session:
async with session.begin():
print("\n[1/8] Formula Configs...")
print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/8] Voting Protocols...")
print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas)
print("\n[3/8] Document: Acte d'engagement Certification...")
print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols)
print("\n[4/8] Document: Acte d'engagement forgeron v2.0.0...")
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
print("\n[5/7] Decision: Runtime Upgrade...")
print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
print("\n[6/7] Simulated voters...")
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session)
print("\n[7/7] Votes on first 3 engagements forgeron...")
print("\n[8/10] Votes on first 3 engagements forgeron...")
await seed_votes_on_items(
session,
doc_forgeron,
@@ -2184,6 +2452,11 @@ async def run_seed():
voters,
)
print("\n[9/10] Mandates...")
await seed_mandates(session, voters)
print("\n[10/10] Done.")
print("\n" + "=" * 60)
print("Seed complete.")
print("=" * 60)

View File

@@ -31,15 +31,17 @@ services:
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-libredecision}
SECRET_KEY: ${SECRET_KEY}
ENVIRONMENT: production
DEBUG: "false"
CORS_ORIGINS: '["https://${APP_DOMAIN:-libredecision.org}"]'
DEMO_MODE: ${DEMO_MODE:-true}
CORS_ORIGINS: '["https://${APP_DOMAIN:-decision.librodrome.org}"]'
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080
labels:
# Registrator enregistre dans Consul, Fabio route automatiquement
- SERVICE_8002_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend-8002
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-libredecision.org}/api/*
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/api/*
# TCP : HTTP check échoue si le service redirige (301/302)
- SERVICE_8002_CHECK_TCP=true
networks:
@@ -53,10 +55,10 @@ services:
depends_on:
- backend
environment:
NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-libredecision.org}/api/v1
NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-decision.librodrome.org}/api/v1
labels:
- SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend-3000
- SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-libredecision.org}/*
- SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/*
- SERVICE_3000_CHECK_TCP=true
networks:
- sonic

View File

@@ -108,7 +108,7 @@ function isActive(to: string) {
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
</span>
<span class="app-header__logo-text">
<span class="app-header__logo-g">ğ</span><span class="app-header__logo-paren">(</span><span class="app-header__logo-word">Decision</span><span class="app-header__logo-paren">)</span>
<span class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span>
</span>
</NuxtLink>
</div>
@@ -262,7 +262,7 @@ function isActive(to: string) {
<!-- Footer -->
<footer class="app-footer">
<span>ğ(Decision) v0.1.0</span>
<span>libreDecision v0.1.0</span>
<span class="app-footer__sep">·</span>
<span>Licence libre</span>
</footer>
@@ -361,24 +361,17 @@ function isActive(to: string) {
display: flex;
align-items: baseline;
gap: 0;
letter-spacing: -0.01em;
}
.app-header__logo-g {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1;
.app-header__logo-libre {
font-size: 1.0625rem;
font-weight: 400;
font-style: italic;
}
.app-header__logo-paren {
font-size: 1.125rem;
font-weight: 300;
color: var(--mood-text-muted);
opacity: 0.5;
}
.app-header__logo-word {
.app-header__logo-decision {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);

View File

@@ -1,5 +1,5 @@
/* ==========================================================================
ğ(Decision) — Mood / Ambiance System
libreDecision — Mood / Ambiance System
Palettes harmoniques variees, colores en lite, lumineux en dark.
========================================================================== */
@@ -144,7 +144,7 @@
}
/* ==========================================================================
Global design tokens — Nunito, rounded, borderless
Global design tokens — Plus Jakarta Sans, rounded, borderless
========================================================================== */
*,
@@ -154,12 +154,12 @@
}
html {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
font-size: 16px;
}
body {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -234,7 +234,7 @@ input:focus, select:focus, textarea:focus {
:root [class*="u-button"],
:root [data-variant] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
}
:root input,
@@ -244,5 +244,5 @@ input:focus, select:focus, textarea:focus {
:root [class*="USelect"],
:root [class*="UTextarea"] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
}

View File

@@ -121,7 +121,7 @@ const today = new Date().toLocaleDateString('fr-FR', {
<!-- Footer -->
<div class="doc-preview__footer">
<div class="doc-preview__footer-main">
<span>ğ(Decision) · {{ document.title }} · v{{ document.version }}</span>
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
</div>
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
Projection non officielle texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}

View File

@@ -29,7 +29,7 @@ export default defineNuxtConfig({
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,700;1,800&display=swap' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700&display=swap' },
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' },
],
script: [