diff --git a/.woodpecker.yml b/.woodpecker.yml index 3d1e7f1..22bab2b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -69,7 +69,7 @@ steps: from_secret: deploy_key port: 22 script: - - cd /opt/glibredecision + - cd /opt/libredecision - docker compose -f docker/docker-compose.yml pull - docker compose -f docker/docker-compose.yml up -d --remove-orphans - docker image prune -f diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ad002a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contribuer à libreDecision + +## Environnement + +```bash +# Backend (Python 3.11+) +cd backend +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +alembic upgrade head +uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload + +# Frontend (Node 20+) +cd frontend +npm install +npm run dev +``` + +## Conventions + +- **UI** : français — **Code** : anglais (variables, commentaires, docstrings) +- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow` +- **Composants** : `pathPrefix: false` — noms courts, auto-import +- **API** : versionnée `/api/v1/`, Pydantic v2, async partout +- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback + +## Architecture toolbox + +Chaque section expose une `` avec : +- Contenu principal (slot `#default`) +- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll + +Composants toolbox : +- `ToolboxSection` : accordéon collapsible générique +- `ToolboxVignette` : carte compacte avec bullets toggleables +- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale) +- `toolbox/SocioElection` : guide élection sociocratique + advice process +- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom) + +## Tests + +```bash +cd backend && pytest tests/ -v +``` + +186 tests, zéro dette technique acceptée depuis le sprint 1. + +## Formule de vote inertiel + +`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)` + +Voir `docs/content/dev/` pour la documentation complète. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8a5760 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# libreDecision + +Plateforme de décisions collectives pour la communauté Duniter/G1. +Boîte à outils gouvernance multi-collectifs, architecture white-label. + +## Stack + +- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002) +- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002) +- **Auth** : Duniter V2 Ed25519 challenge-response +- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark) + +## Démarrage + +```bash +# Backend +cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload + +# Frontend +cd frontend && npm run dev +``` + +## Sections + +- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process) +- **Mandats** : élection sociocratique, cycle de mandat, révocation +- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS +- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote + +## Architecture + +Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index a0e03c9..1693651 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,4 +1,4 @@ -"""Alembic async environment for Glibredecision. +"""Alembic async environment for libreDecision. Uses asyncpg via SQLAlchemy's async engine. All models are imported so that Base.metadata is fully populated diff --git a/backend/app/config.py b/backend/app/config.py index 942956b..fa3e49e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,7 +3,7 @@ from pathlib import Path class Settings(BaseSettings): - APP_NAME: str = "Glibredecision" + APP_NAME: str = "libreDecision" DEBUG: bool = True # Environment @@ -11,7 +11,7 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" # Database — SQLite by default for local dev, PostgreSQL for Docker/prod - DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db" + DATABASE_URL: str = "sqlite+aiosqlite:///./libredecision.db" DATABASE_POOL_SIZE: int = 20 DATABASE_MAX_OVERFLOW: int = 10 diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index b67a942..8927c29 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -240,7 +240,7 @@ async def platform_status( sanctuary_count = sanctuary_count_result.scalar() or 0 return { - "platform": "Glibredecision", + "platform": "libreDecision", "documents_count": documents_count, "decisions_count": decisions_count, "active_votes_count": active_votes_count, diff --git a/backend/app/services/sanctuary_service.py b/backend/app/services/sanctuary_service.py index c698879..3b7b915 100644 --- a/backend/app/services/sanctuary_service.py +++ b/backend/app/services/sanctuary_service.py @@ -1,6 +1,6 @@ """Sanctuary service: immutable archival to IPFS + on-chain hash. -The sanctuary is the immutable layer of Glibredecision. Every adopted +The sanctuary is the immutable layer of libreDecision. Every adopted document version, decision result, or vote tally is hashed (SHA-256), stored on IPFS, and anchored on-chain via system.remark. """ @@ -241,7 +241,7 @@ async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]: call = substrate.compose_call( call_module="System", call_function="remark", - call_params={"remark": f"glibredecision:sanctuary:{content_hash}"}, + call_params={"remark": f"libredecision:sanctuary:{content_hash}"}, ) extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair) receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True) diff --git a/backend/seed.py b/backend/seed.py index 0d45fb2..f268059 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -2149,7 +2149,7 @@ async def seed_votes_on_items( async def run_seed(): print("=" * 60) - print("Glibredecision - Seed Database") + print("libreDecision - Seed Database") print("=" * 60) # Ensure tables exist diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index ed90d9b..aac659c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -7,15 +7,15 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-glibredecision} - POSTGRES_USER: ${POSTGRES_USER:-glibredecision} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev} + POSTGRES_DB: ${POSTGRES_DB:-libredecision} + POSTGRES_USER: ${POSTGRES_USER:-libredecision} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredecision-dev} ports: - "5432:5432" volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredecision}"] interval: 5s timeout: 3s retries: 10 @@ -34,7 +34,7 @@ services: ports: - "8002:8002" environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-glibredecision-dev}@postgres:5432/${POSTGRES_DB:-glibredecision} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD:-libredecision-dev}@postgres:5432/${POSTGRES_DB:-libredecision} SECRET_KEY: dev-secret-key-not-for-production DEBUG: "true" ENVIRONMENT: development diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f47d90c..fba1e92 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,19 +5,19 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-glibredecision} - POSTGRES_USER: ${POSTGRES_USER:-glibredecision} + POSTGRES_DB: ${POSTGRES_DB:-libredecision} + POSTGRES_USER: ${POSTGRES_USER:-libredecision} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision} -d ${POSTGRES_DB:-glibredecision}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredecision} -d ${POSTGRES_DB:-libredecision}"] interval: 10s timeout: 5s retries: 5 start_period: 30s networks: - - glibredecision + - libredecision backend: build: @@ -29,21 +29,21 @@ services: postgres: condition: service_healthy environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-glibredecision} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-libredecision} SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key} DEBUG: "false" - CORS_ORIGINS: '["https://${DOMAIN:-glibredecision.org}"]' + CORS_ORIGINS: '["https://${DOMAIN:-libredecision.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: - "traefik.enable=true" - - "traefik.http.routers.glibredecision-api.rule=Host(`${DOMAIN:-glibredecision.org}`) && PathPrefix(`/api`)" - - "traefik.http.routers.glibredecision-api.entrypoints=websecure" - - "traefik.http.routers.glibredecision-api.tls.certresolver=letsencrypt" - - "traefik.http.services.glibredecision-api.loadbalancer.server.port=8002" + - "traefik.http.routers.libredecision-api.rule=Host(`${DOMAIN:-libredecision.org}`) && PathPrefix(`/api`)" + - "traefik.http.routers.libredecision-api.entrypoints=websecure" + - "traefik.http.routers.libredecision-api.tls.certresolver=letsencrypt" + - "traefik.http.services.libredecision-api.loadbalancer.server.port=8002" networks: - - glibredecision + - libredecision - traefik frontend: @@ -55,15 +55,15 @@ services: depends_on: - backend environment: - NUXT_PUBLIC_API_BASE: https://${DOMAIN:-glibredecision.org}/api/v1 + NUXT_PUBLIC_API_BASE: https://${DOMAIN:-libredecision.org}/api/v1 labels: - "traefik.enable=true" - - "traefik.http.routers.glibredecision-front.rule=Host(`${DOMAIN:-glibredecision.org}`)" - - "traefik.http.routers.glibredecision-front.entrypoints=websecure" - - "traefik.http.routers.glibredecision-front.tls.certresolver=letsencrypt" - - "traefik.http.services.glibredecision-front.loadbalancer.server.port=3000" + - "traefik.http.routers.libredecision-front.rule=Host(`${DOMAIN:-libredecision.org}`)" + - "traefik.http.routers.libredecision-front.entrypoints=websecure" + - "traefik.http.routers.libredecision-front.tls.certresolver=letsencrypt" + - "traefik.http.services.libredecision-front.loadbalancer.server.port=3000" networks: - - glibredecision + - libredecision - traefik ipfs: @@ -72,14 +72,14 @@ services: volumes: - ipfs-data:/data/ipfs networks: - - glibredecision + - libredecision volumes: postgres-data: ipfs-data: networks: - glibredecision: + libredecision: driver: bridge traefik: external: true diff --git a/frontend/app/components/SectionLayout.vue b/frontend/app/components/SectionLayout.vue index d537724..b90c35c 100644 --- a/frontend/app/components/SectionLayout.vue +++ b/frontend/app/components/SectionLayout.vue @@ -264,7 +264,7 @@ function toggleStatus(statusId: string) { @media (min-width: 1024px) { .section__body { - grid-template-columns: 1fr 17rem; + grid-template-columns: 1fr 30rem; } } @@ -320,10 +320,12 @@ function toggleStatus(statusId: string) { display: none; position: sticky; top: 4.5rem; + align-self: start; flex-direction: column; background: var(--mood-surface); border-radius: 16px; - overflow: hidden; + max-height: calc(100vh - 5.5rem); + box-shadow: 0 4px 24px var(--mood-shadow); } @media (min-width: 1024px) { @@ -340,6 +342,7 @@ function toggleStatus(statusId: string) { color: var(--mood-accent); text-transform: uppercase; letter-spacing: 0.05em; + flex-shrink: 0; } .section__toolbox-head-icon { @@ -350,10 +353,8 @@ function toggleStatus(statusId: string) { padding: 0 0.75rem 0.875rem; display: flex; flex-direction: column; - gap: 0.625rem; - max-height: calc(100vh - 8rem); - overflow-y: auto; - scrollbar-width: thin; + gap: 0.5rem; + overflow: hidden; } .section__toolbox-empty { diff --git a/frontend/app/components/common/SectionLayout.vue b/frontend/app/components/common/SectionLayout.vue deleted file mode 100644 index 7930e02..0000000 --- a/frontend/app/components/common/SectionLayout.vue +++ /dev/null @@ -1,318 +0,0 @@ - - - - - diff --git a/frontend/app/components/common/ToolboxVignette.vue b/frontend/app/components/common/ToolboxVignette.vue index 783dc5a..609a255 100644 --- a/frontend/app/components/common/ToolboxVignette.vue +++ b/frontend/app/components/common/ToolboxVignette.vue @@ -1,6 +1,6 @@ @@ -70,19 +79,53 @@ function handleAction(action: ToolboxAction) { .vignette { background: var(--mood-accent-soft); border-radius: 12px; - padding: 0.75rem; + overflow: hidden; +} + +.vignette__header { display: flex; - flex-direction: column; - gap: 0.5rem; + align-items: center; + width: 100%; + padding: 0.625rem 0.75rem; + cursor: pointer; + text-align: left; + gap: 0.375rem; + transition: background 0.12s ease; +} + +.vignette__header:hover { + background: color-mix(in srgb, var(--mood-accent) 8%, transparent); } .vignette__title { + flex: 1; font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); margin: 0; } +.vignette__chevron { + font-size: 0.875rem; + color: var(--mood-text-muted); + opacity: 0.5; + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.vignette--open .vignette__chevron { + transform: rotate(180deg); + opacity: 1; + color: var(--mood-accent); +} + +.vignette__content { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0 0.75rem 0.625rem; +} + .vignette__bullets { margin: 0; padding: 0 0 0 1rem; diff --git a/frontend/app/components/toolbox/ToolboxSection.vue b/frontend/app/components/toolbox/ToolboxSection.vue new file mode 100644 index 0000000..bf98772 --- /dev/null +++ b/frontend/app/components/toolbox/ToolboxSection.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts index 4e3a05c..fdc9051 100644 --- a/frontend/app/composables/useApi.ts +++ b/frontend/app/composables/useApi.ts @@ -1,5 +1,5 @@ /** - * Composable for making authenticated API calls to the Glibredecision backend. + * Composable for making authenticated API calls to the libreDecision backend. * * Uses the runtime config `apiBase` and automatically injects the Bearer token * from the auth store when available. diff --git a/frontend/app/composables/useMood.ts b/frontend/app/composables/useMood.ts index c6bcf24..da92ab6 100644 --- a/frontend/app/composables/useMood.ts +++ b/frontend/app/composables/useMood.ts @@ -9,7 +9,7 @@ export interface Mood { isDark: boolean } -const STORAGE_KEY = 'glibredecision_mood' +const STORAGE_KEY = 'libredecision_mood' const moods: Mood[] = [ { id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false }, diff --git a/frontend/app/pages/decisions/index.vue b/frontend/app/pages/decisions/index.vue index b2d6854..371f186 100644 --- a/frontend/app/pages/decisions/index.vue +++ b/frontend/app/pages/decisions/index.vue @@ -238,13 +238,9 @@ function formatDate(dateStr: string): string {