Compare commits

..

13 Commits

Author SHA1 Message Date
Yvv
ed9ed11cd4 Toolbox 30rem sticky + accordéons collapsibles + renommage libreDecision
- Boîte à outils élargie à 30rem (×1.75) — flottante sticky, zéro scroll visible
- ToolboxSection : nouveau composant accordéon générique (chevron, défaut fermé)
- ToolboxVignette : titre cliquable, bullets/actions cachés par défaut
- 4 pages : ContextMapper/SocioElection/WorkflowMilestones/inertie → ToolboxSection
- Suppression doublon SectionLayout (common/) — conflit de nommage résolu
- Renommage complet Glibredecision → libreDecision dans configs/docker/CI
- README.md + CONTRIBUTING.md ajoutés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:48:20 +01:00
Yvv
290548703d Boîtes à outils enrichies : ContextMapper, SocioElection, WorkflowMilestones
- ContextMapper : 4 questions contexte → méthode de décision optimale
  (advice process Laloux, vote inertiel WoT, consentement sociocratique, Smith…)
- SocioElection : guide élection sociocratique 6 étapes + advice process + clarté de rôle
- WorkflowMilestones : 11 jalons de protocole (7 essentiels), durées recommandées, principes Ostrom
- WorkspaceSelector : sélecteur de collectif multi-site dans le header
- SectionLayout : toolbox en USlideover droit sur mobile, sidebar sticky desktop
- Décisions : ContextMapper intégré + guide consentement
- Mandats : SocioElection intégré + cycle de mandat
- Documents : guide inertie 4 niveaux + structure + IPFS
- Protocoles : WorkflowMilestones + protocole élection sociocratique ajouté
- Renommage projet Glibredecision → libreDecision (dossier + sources)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:13:08 +01:00
Yvv
316d205593 Card Protocoles: count fixe + section opérationnelle toujours visible
- Home: card Protocoles affiche "2 protocoles" (count fixe) + modalités dynamiques
- Protocols: section "Protocoles opérationnels" déplacée hors template conditionnel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:12:10 +01:00
Yvv
8201e73d7c Accents FR, architecture modulaire, protocoles opérationnels
- Fix accents manquants dans 7 pages UI (décisions, boîte à outils, etc.)
- Titres accueil enrichis : Décisions structurantes, Documents de référence,
  Mandats et nominations, Protocoles et fonctionnement
- Retrait Embarquement Forgeron du seed (n'est pas une Decision)
- 2 protocoles opérationnels dans Protocoles : Embarquement Forgeron
  (lié à l'Acte d'engagement) + Soumission Runtime Upgrade (lié à la
  Décision Runtime Upgrade) avec timeline et liens croisés signalétiques
- Décision Runtime Upgrade : badge on-chain + lien protocole + contexte
- Document [slug] : lien protocole dans la section Qualification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:05:55 +01:00
Yvv
c19c1aa55e Restructure Engagement Forgeron + fix GenesisBlock + InertiaSlider
- Seed: restructure Engagement Forgeron (51→59 items) avec 3 nouvelles
  sections: Engagements fondamentaux (EF1-EF3), Engagements techniques
  (ET1-ET3), Qualification (Q0-Q1) liée au protocole Embarquement
- Seed: ajout protocole Embarquement Forgeron (5 jalons: candidature,
  miroir, évaluation, certification Smith, mise en ligne)
- GenesisBlock: fix lisibilité — fond mood-surface teinté accent au lieu
  de mood-text inversé, texte mood-aware au lieu de rgba blanc hardcodé
- InertiaSlider: mini affiche "Inertie" sous le curseur, compact en
  width:fit-content pour s'adapter au label
- Frontend: ajout section qualification dans SECTION_META/SECTION_ORDER
- Pages, composants et tests des sprints précédents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:44:33 +01:00
Yvv
4212e847d4 Sceau Tsing: proportions avatars (1.3:1), emboss CSS, sans voile dark
- SVG redessiné: viewBox 130×100, traits fins (5u), ratio calé sur Su.svg
- Emboss via CSS drop-shadow au lieu de filtre SVG inline (causait voile)
- Supprimé :global() dans scoped CSS (cause du voile sur dark moods)
- moods.css et useMood.ts inchangés, dark modes intacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:10:13 +01:00
Yvv
f087fb95c9 Signature 井 Tsing (Le Puits) — SVG embossé + flat
Hexagramme 48, signature personnelle Yvv.
- hexagram-tsing.svg : version embossée (filtre SVG inset shadow)
- hexagram-tsing-flat.svg : version plate
Transparent, currentColor, réutilisable dans tous les projets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:57:11 +01:00
Yvv
a1fa31c3f9 Accents dans les commentaires de section du seed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:49:37 +01:00
Yvv
3de07e8c17 Fix: accents manquants dans seed + labels type visibles
- Reseed avec tous les accents français corrigés (à, é, è, ê, î, ô)
- Labels type-étiquette: taille augmentée, fond accent léger, visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:45:15 +01:00
Yvv
21ceae4866 Français soigné, labels signalétiques, formule N1.1 visuelle, F1.2 lisible
- Accents français partout (seed + composants Vue)
- Labels discrets: Engagements, Préambule, Application, Variables
- N1.1: présentation visuelle des niveaux d'inertie avec formule
- F1.2: paramètres + lecture du curseur d'inertie
- MarkdownRenderer: espacement resserré, support code inline
- Toutes descriptions et meta en bon français

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:29:50 +01:00
Yvv
0b230483d9 UX: texte valorise, vote discret, inertie visuelle, genese repliable
- EngagementCard: texte agrandi (15-16px), vote board discret (opacity, scale)
- MiniVoteBoard: badge Adopte/En attente apres "Vote permanent :", board compact
- InertiaSlider: labels descriptifs (inertie pour le remplacement), schema SVG
  avec courbe de seuil, formule simplifiee et legende parametres
- GenesisBlock: toggle repliement individuel par section (source, outils,
  forum, processus, contributeurs)
- Votes varies dans Conseils et bonnes pratiques (non-adoptes inclus)
- Seed: Certification responsable → Reciprocite, ordonnancement inertie standard,
  notes variables K1/K2 (vote porte sur l'inclusion, pas les valeurs),
  init_db() dans seed.py pour DB vierge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:52:56 +01:00
Yvv
62808b974d Composants engagement: GenesisBlock, InertiaSlider, MiniVoteBoard, EngagementCard, DocumentTuto
Backend: genesis_json sur Document, section_tag/inertia_preset/is_permanent_vote sur DocumentItem
Frontend: 5 nouveaux composants pour vue detail document enrichie
- GenesisBlock: sources, outils, synthese forum, contributeurs (depliable)
- InertiaSlider: visualisation inertie 4 niveaux avec params formule G/M
- MiniVoteBoard: tableau vote compact (barre seuil, pour/contre, participation)
- EngagementCard: carte item enrichie integrant vote + inertie + actions
- DocumentTuto: modal pedagogique vote permanent/inertie/seuils
Seed et page [slug] enrichis pour exploiter les nouveaux champs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:59:05 +01:00
Yvv
11e4a4d60a Dev mode: panneau connexion rapide avec 4 profils pre-configures
Ajout d'un panneau dev sous le login (Alice=membre, Bob=forgeron,
Charlie=comite tech, Dave=observateur) pour tester les differents
roles sans keypair Ed25519. Endpoint GET /auth/dev/profiles renvoie
les profils uniquement en ENVIRONMENT=development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:09:40 +01:00
67 changed files with 10093 additions and 853 deletions

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# Glibredecision
# libreDecision
Plateforme de decisions collectives pour la communaute Duniter/G1.

52
CONTRIBUTING.md Normal file
View File

@@ -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 `<SectionLayout>` 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.

32
README.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -19,10 +19,11 @@ class Document(Base):
description: Mapped[str | None] = mapped_column(Text)
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.position")
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.sort_order")
class DocumentItem(Base):
@@ -31,11 +32,14 @@ class DocumentItem(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"), nullable=False)
position: Mapped[str] = mapped_column(String(16), nullable=False) # "1", "1.1", "3.2"
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section, genesis
title: Mapped[str | None] = mapped_column(String(256))
current_text: Mapped[str] = mapped_column(Text, nullable=False)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
section_tag: Mapped[str | None] = mapped_column(String(64)) # genesis, fondamental, technique, annexe, formule, inertie, ordonnancement
inertia_preset: Mapped[str] = mapped_column(String(16), default="standard") # low, standard, high, very_high
is_permanent_vote: Mapped[bool] = mapped_column(default=True) # permanent vote vs time-bounded
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -27,6 +27,38 @@ from app.services.auth_service import (
router = APIRouter()
# ── Dev profiles (only available when ENVIRONMENT == "development") ─────────
DEV_PROFILES = [
{
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"display_name": "Alice (Membre WoT)",
"wot_status": "member",
"is_smith": False,
"is_techcomm": False,
},
{
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
"display_name": "Bob (Forgeron)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": False,
},
{
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
"display_name": "Charlie (Comite Tech)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": True,
},
{
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
"display_name": "Dave (Observateur)",
"wot_status": "unknown",
"is_smith": False,
"is_techcomm": False,
},
]
# ── In-memory challenge store (short-lived, no persistence needed) ──────────
# Structure: { address: { "challenge": str, "expires_at": datetime } }
_pending_challenges: dict[str, dict] = {}
@@ -113,8 +145,11 @@ async def verify_challenge(
# 5. Consume the challenge
del _pending_challenges[payload.address]
# 6. Get or create identity
identity = await get_or_create_identity(db, payload.address)
# 6. Get or create identity (apply dev profile if available)
dev_profile = None
if settings.ENVIRONMENT == "development":
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)
# 7. Create session token
token = await create_session(db, identity)
@@ -125,6 +160,14 @@ 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":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
return DEV_PROFILES
@router.get("/me", response_model=IdentityOut)
async def get_me(
identity: DuniterIdentity = Depends(get_current_identity),

View File

@@ -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,

View File

@@ -42,6 +42,7 @@ class DocumentOut(BaseModel):
description: str | None = None
ipfs_cid: str | None = None
chain_anchor: str | None = None
genesis_json: str | None = None
created_at: datetime
updated_at: datetime
items_count: int = Field(default=0, description="Number of items in this document")
@@ -54,10 +55,13 @@ class DocumentItemCreate(BaseModel):
"""Payload for creating a document item (clause, rule, etc.)."""
position: str = Field(..., max_length=16, description='Hierarchical position e.g. "1", "1.1", "3.2"')
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section")
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section, genesis")
title: str | None = Field(default=None, max_length=256)
current_text: str = Field(..., min_length=1)
voting_protocol_id: UUID | None = None
section_tag: str | None = Field(default=None, max_length=64)
inertia_preset: str = Field(default="standard", max_length=16)
is_permanent_vote: bool = True
class DocumentItemUpdate(BaseModel):
@@ -82,6 +86,9 @@ class DocumentItemOut(BaseModel):
current_text: str
voting_protocol_id: UUID | None = None
sort_order: int
section_tag: str | None = None
inertia_preset: str = "standard"
is_permanent_vote: bool = True
created_at: datetime
updated_at: datetime
@@ -99,6 +106,9 @@ class DocumentItemFullOut(BaseModel):
current_text: str
voting_protocol_id: UUID | None = None
sort_order: int
section_tag: str | None = None
inertia_preset: str = "standard"
is_permanent_vote: bool = True
created_at: datetime
updated_at: datetime
versions: list[ItemVersionOut] = Field(default_factory=list)
@@ -118,6 +128,7 @@ class DocumentFullOut(BaseModel):
description: str | None = None
ipfs_cid: str | None = None
chain_anchor: str | None = None
genesis_json: str | None = None
created_at: datetime
updated_at: datetime
items: list[DocumentItemOut] = Field(default_factory=list)

View File

@@ -82,15 +82,38 @@ async def get_current_identity(
return identity
async def get_or_create_identity(db: AsyncSession, address: str) -> DuniterIdentity:
"""Get an existing identity by address or create a new one."""
async def get_or_create_identity(
db: AsyncSession,
address: str,
dev_profile: dict | None = None,
) -> DuniterIdentity:
"""Get an existing identity by address or create a new one.
If dev_profile is provided, apply the profile attributes on create or update.
"""
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.address == address))
identity = result.scalar_one_or_none()
if identity is None:
identity = DuniterIdentity(address=address)
kwargs: dict = {"address": address}
if dev_profile:
kwargs.update({
"display_name": dev_profile.get("display_name"),
"wot_status": dev_profile.get("wot_status", "unknown"),
"is_smith": dev_profile.get("is_smith", False),
"is_techcomm": dev_profile.get("is_techcomm", False),
})
identity = DuniterIdentity(**kwargs)
db.add(identity)
await db.commit()
await db.refresh(identity)
elif dev_profile:
# Update existing identity with dev profile data
identity.display_name = dev_profile.get("display_name", identity.display_name)
identity.wot_status = dev_profile.get("wot_status", identity.wot_status)
identity.is_smith = dev_profile.get("is_smith", identity.is_smith)
identity.is_techcomm = dev_profile.get("is_techcomm", identity.is_techcomm)
await db.commit()
await db.refresh(identity)
return identity

View File

@@ -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)

View File

@@ -0,0 +1,856 @@
"""TDD: Document ↔ Protocol ↔ Vote integration tests.
Tests the interrelation between:
- DocumentItem ←→ VotingProtocol (via voting_protocol_id)
- VotingProtocol ←→ FormulaConfig (formula parameters)
- VoteSession creation from DocumentItem context
- Threshold computation using item's protocol (inertia presets)
- Smith vs WoT standard protocol behavior
- ItemVersion lifecycle: propose → vote → accept/reject
- Multi-criteria adoption (WoT + Smith + TechComm)
All tests are pure unit tests exercising engine functions + service logic
without a real database (mocks only).
"""
from __future__ import annotations
import math
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.engine.mode_params import parse_mode_params
from app.engine.nuanced_vote import evaluate_nuanced
from app.engine.smith_threshold import smith_threshold
from app.engine.techcomm_threshold import techcomm_threshold
from app.engine.threshold import wot_threshold
# ===========================================================================
# 1. DOCUMENT-PROTOCOL INTERRELATION
# ===========================================================================
class TestInertiaPresetsThresholds:
"""Verify that different inertia presets produce different thresholds.
Inertia presets map to gradient_exponent values:
low → G=0.1 (easy replacement)
standard → G=0.2 (balanced)
high → G=0.4 (hard replacement)
very_high → G=0.6 (very hard replacement)
"""
WOT_SIZE = 7224
TOTAL_VOTES = 120 # ~1.66% participation
INERTIA_MAP = {
"low": {"gradient_exponent": 0.1, "majority_pct": 50},
"standard": {"gradient_exponent": 0.2, "majority_pct": 50},
"high": {"gradient_exponent": 0.4, "majority_pct": 60},
"very_high": {"gradient_exponent": 0.6, "majority_pct": 66},
}
def _threshold_for_preset(self, preset: str) -> int:
params = self.INERTIA_MAP[preset]
return wot_threshold(
wot_size=self.WOT_SIZE,
total_votes=self.TOTAL_VOTES,
majority_pct=params["majority_pct"],
gradient_exponent=params["gradient_exponent"],
)
def test_low_inertia_easiest(self):
"""Low inertia should produce the lowest threshold."""
t_low = self._threshold_for_preset("low")
t_std = self._threshold_for_preset("standard")
assert t_low < t_std, f"Low ({t_low}) should be < standard ({t_std})"
def test_standard_below_high(self):
"""Standard inertia should be below high."""
t_std = self._threshold_for_preset("standard")
t_high = self._threshold_for_preset("high")
assert t_std < t_high, f"Standard ({t_std}) should be < high ({t_high})"
def test_high_below_very_high(self):
"""High inertia should be below very_high."""
t_high = self._threshold_for_preset("high")
t_vh = self._threshold_for_preset("very_high")
assert t_high < t_vh, f"High ({t_high}) should be < very_high ({t_vh})"
def test_monotonic_ordering(self):
"""All 4 presets must be strictly ordered: low < standard < high < very_high."""
thresholds = {p: self._threshold_for_preset(p) for p in self.INERTIA_MAP}
assert thresholds["low"] < thresholds["standard"]
assert thresholds["standard"] < thresholds["high"]
assert thresholds["high"] < thresholds["very_high"]
def test_low_inertia_near_majority(self):
"""With low inertia (G=0.1), even at 1.66% participation,
threshold shouldn't be too far from total votes."""
t = self._threshold_for_preset("low")
# With G=0.1, inertia is mild even at low participation
assert t <= self.TOTAL_VOTES, f"Low inertia threshold ({t}) should be <= total ({self.TOTAL_VOTES})"
def test_very_high_inertia_near_unanimity(self):
"""With very_high inertia (G=0.6, M=66%), at 1.66% participation,
threshold should be very close to total votes (near unanimity)."""
t = self._threshold_for_preset("very_high")
# At very low participation with high G and high M, threshold ≈ total
ratio = t / self.TOTAL_VOTES
assert ratio > 0.85, f"Very high inertia ratio ({ratio:.2f}) should demand near-unanimity"
class TestSmithProtocolVsStandard:
"""Compare the behavior of Smith protocol (D30M50B.1G.2S.1) vs
WoT standard (D30M50B.1G.2) — the only difference is the Smith criterion."""
WOT_SIZE = 7224
SMITH_SIZE = 20
TOTAL = 120
VOTES_FOR = 97
def test_same_wot_threshold(self):
"""Both protocols have M50B.1G.2 → same WoT threshold."""
smith_params = parse_mode_params("D30M50B.1G.2S.1")
std_params = parse_mode_params("D30M50B.1G.2")
t_smith = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=smith_params["majority_pct"],
gradient_exponent=smith_params["gradient_exponent"],
)
t_std = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=std_params["majority_pct"],
gradient_exponent=std_params["gradient_exponent"],
)
assert t_smith == t_std
def test_smith_criterion_present_vs_absent(self):
"""Smith protocol has smith_exponent=0.1, standard has None."""
smith_params = parse_mode_params("D30M50B.1G.2S.1")
std_params = parse_mode_params("D30M50B.1G.2")
assert smith_params["smith_exponent"] == 0.1
assert std_params["smith_exponent"] is None
def test_smith_protocol_can_be_blocked_by_smiths(self):
"""Smith protocol adoption requires both WoT AND Smith criteria."""
wot_thresh = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=50, gradient_exponent=0.2,
)
wot_pass = self.VOTES_FOR >= wot_thresh
smith_thresh = smith_threshold(self.SMITH_SIZE, 0.1) # = 2
smith_votes_for = 1 # Only 1 smith voted → fails
adopted = wot_pass and (smith_votes_for >= smith_thresh)
assert wot_pass is True
assert smith_votes_for < smith_thresh
assert adopted is False
def test_standard_protocol_ignores_smith(self):
"""Standard protocol with no smith_exponent always passes smith criterion."""
params = parse_mode_params("D30M50B.1G.2")
smith_ok = True # Default when smith_exponent is None
if params["smith_exponent"] is not None:
smith_ok = False # Would need smith votes
assert smith_ok is True
def test_smith_threshold_scales_with_smith_size(self):
"""Smith threshold ceil(N^0.1) grows slowly with smith WoT size."""
sizes_and_expected = [
(1, 1), # ceil(1^0.1) = 1
(5, 2), # ceil(5^0.1) = ceil(1.175) = 2
(20, 2), # ceil(20^0.1) = ceil(1.35) = 2
(100, 2), # ceil(100^0.1) = ceil(1.585) = 2
(1000, 2), # ceil(1000^0.1) = ceil(1.995) = 2
(1024, 2), # ceil(1024^0.1) = ceil(2.0) = 2
(1025, 3), # ceil(1025^0.1) > 2
]
for size, expected in sizes_and_expected:
result = smith_threshold(size, 0.1)
assert result == expected, f"smith_threshold({size}, 0.1) = {result}, expected {expected}"
class TestModeParamsRoundtrip:
"""Verify mode_params parsing produces correct formula parameters."""
def test_smith_protocol_params(self):
"""D30M50B.1G.2S.1 — the Forgeron Smith protocol."""
params = parse_mode_params("D30M50B.1G.2S.1")
assert params["duration_days"] == 30
assert params["majority_pct"] == 50
assert params["base_exponent"] == 0.1
assert params["gradient_exponent"] == 0.2
assert params["smith_exponent"] == 0.1
assert params["techcomm_exponent"] is None
def test_techcomm_protocol_params(self):
"""D30M50B.1G.2T.1 — a TechComm protocol."""
params = parse_mode_params("D30M50B.1G.2T.1")
assert params["techcomm_exponent"] == 0.1
assert params["smith_exponent"] is None
def test_full_protocol_params(self):
"""D30M50B.1G.2S.1T.1 — both Smith AND TechComm."""
params = parse_mode_params("D30M50B.1G.2S.1T.1")
assert params["smith_exponent"] == 0.1
assert params["techcomm_exponent"] == 0.1
def test_high_inertia_params(self):
"""D30M60B.1G.4 — high inertia preset."""
params = parse_mode_params("D30M60B.1G.4")
assert params["majority_pct"] == 60
assert params["gradient_exponent"] == 0.4
def test_very_high_inertia_params(self):
"""D30M66B.1G.6 — very high inertia preset."""
params = parse_mode_params("D30M66B.1G.6")
assert params["majority_pct"] == 66
assert params["gradient_exponent"] == 0.6
def test_params_used_in_threshold_match(self):
"""Threshold computed from parsed params must match direct computation."""
params = parse_mode_params("D30M50B.1G.2S.1")
computed = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=params["majority_pct"],
base_exponent=params["base_exponent"],
gradient_exponent=params["gradient_exponent"],
)
direct = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=50, base_exponent=0.1, gradient_exponent=0.2,
)
assert computed == direct
# ===========================================================================
# 2. VOTE BEHAVIOR — ADVANCED SCENARIOS
# ===========================================================================
class TestInertiaFormulaBehavior:
"""Deep tests on the inertia formula behavior across participation levels."""
def test_participation_curve_is_monotonically_decreasing(self):
"""As participation increases, required threshold ratio decreases.
This is the fundamental property of inertia-based democracy."""
W = 7224
M = 0.5
G = 0.2
prev_ratio = float("inf")
for t in range(10, W + 1, 100):
participation = t / W
inertia = 1.0 - participation ** G
ratio = M + (1.0 - M) * inertia
assert ratio <= prev_ratio, (
f"Ratio must decrease: at T={t}, ratio={ratio:.4f} > prev={prev_ratio:.4f}"
)
prev_ratio = ratio
def test_at_full_participation_ratio_equals_majority(self):
"""At T=W (100% participation), ratio should equal M exactly."""
W = 7224
M_pct = 50
M = M_pct / 100
threshold = wot_threshold(wot_size=W, total_votes=W, majority_pct=M_pct)
expected = math.ceil(0.1 ** W + M * W)
assert threshold == expected
def test_at_1_percent_participation_near_unanimity(self):
"""At ~1% participation, threshold should be near total votes."""
W = 7224
T = 72 # ~1%
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50)
ratio = threshold / T
assert ratio > 0.75, f"At 1% participation, ratio={ratio:.2f} should be > 0.75"
def test_at_50_percent_participation(self):
"""At 50% participation with G=0.2, threshold is well above simple majority."""
W = 1000
T = 500
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50,
gradient_exponent=0.2)
# (500/1000)^0.2 ≈ 0.87, inertia ≈ 0.13, ratio ≈ 0.565
assert threshold > 250, "Should be above simple majority"
assert threshold < 400, "Should not be near unanimity"
def test_gradient_zero_means_always_majority(self):
"""With G=0, (T/W)^0 = 1, inertia = 0, ratio = M always.
This effectively disables inertia."""
W = 7224
M_pct = 50
for T in [10, 100, 1000, 7224]:
threshold = wot_threshold(wot_size=W, total_votes=T,
majority_pct=M_pct, gradient_exponent=0.0001)
expected_approx = M_pct / 100 * T
# With very small G, threshold should be close to M*T
assert abs(threshold - math.ceil(expected_approx)) <= 2, (
f"At T={T}, threshold={threshold}, expected≈{expected_approx:.0f}"
)
class TestMultiCriteriaAdoption:
"""Test that adoption requires ALL applicable criteria to pass."""
WOT = 7224
TOTAL = 120
VOTES_FOR = 97
SMITH_SIZE = 20
TECHCOMM_SIZE = 5
def _wot_threshold(self):
return wot_threshold(wot_size=self.WOT, total_votes=self.TOTAL,
majority_pct=50, gradient_exponent=0.2)
def test_all_pass(self):
"""WoT pass + Smith pass + TechComm pass → adopted."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1) # 3 >= 2
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 2 >= 2
assert wot_ok and smith_ok and tc_ok
def test_wot_fails(self):
"""WoT fail + Smith pass + TechComm pass → rejected."""
wot_ok = 50 >= self._wot_threshold() # 50 < 94
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert not wot_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_smith_fails(self):
"""WoT pass + Smith fail + TechComm pass → rejected."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 1 >= smith_threshold(self.SMITH_SIZE, 0.1) # 1 < 2
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert wot_ok and tc_ok
assert not smith_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_techcomm_fails(self):
"""WoT pass + Smith pass + TechComm fail → rejected."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 1 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 1 < 2
assert wot_ok and smith_ok
assert not tc_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_all_fail(self):
"""All three fail → rejected."""
wot_ok = 10 >= self._wot_threshold()
smith_ok = 0 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 0 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert not (wot_ok or smith_ok or tc_ok)
def test_no_smith_no_techcomm(self):
"""When protocol has no Smith/TechComm, only WoT matters."""
params = parse_mode_params("D30M50B.1G.2")
wot_ok = self.VOTES_FOR >= self._wot_threshold()
# smith_exponent and techcomm_exponent are None
smith_ok = True # default when not configured
tc_ok = True
if params["smith_exponent"] is not None:
smith_ok = False
if params["techcomm_exponent"] is not None:
tc_ok = False
assert wot_ok and smith_ok and tc_ok
class TestEdgeCasesVotes:
"""Edge cases in vote behavior."""
def test_single_vote_small_wot(self):
"""1 vote out of 5 WoT members → threshold near 1 (almost unanimity)."""
threshold = wot_threshold(wot_size=5, total_votes=1, majority_pct=50)
# With 1/5 = 20% participation, inertia is high → threshold ≈ 1
assert threshold == 1
def test_single_vote_large_wot(self):
"""1 vote out of 7224 WoT → threshold = 1 (need that 1 vote to be for)."""
threshold = wot_threshold(wot_size=7224, total_votes=1, majority_pct=50)
assert threshold == 1
def test_two_votes_disagree(self):
"""2 votes: 1 for + 1 against. At low participation → need near-unanimity."""
threshold = wot_threshold(wot_size=7224, total_votes=2, majority_pct=50)
# Threshold should be close to 2 (near unanimity)
assert threshold == 2
def test_exact_threshold_boundary(self):
"""votes_for == threshold exactly → adopted (>= comparison)."""
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
assert threshold >= threshold # votes_for == threshold → adopted
def test_one_below_threshold_boundary(self):
"""votes_for == threshold - 1 → rejected."""
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
assert (threshold - 1) < threshold
def test_constant_base_raises_minimum(self):
"""With C=5, threshold is at least 5 even with no participation effect."""
threshold = wot_threshold(wot_size=100, total_votes=0,
majority_pct=50, constant_base=5.0)
assert threshold >= 5
def test_wot_size_1_minimal(self):
"""WoT of 1 member, 1 vote → threshold = 1."""
threshold = wot_threshold(wot_size=1, total_votes=1, majority_pct=50)
assert threshold == 1
class TestNuancedVoteAdvanced:
"""Advanced nuanced vote scenarios."""
def test_all_level_5_adopted(self):
"""60 voters all at level 5 (TOUT A FAIT) → adopted."""
result = evaluate_nuanced([5] * 60)
assert result["adopted"] is True
assert result["positive_pct"] == 100.0
def test_all_level_0_rejected(self):
"""60 voters all at level 0 (CONTRE) → rejected."""
result = evaluate_nuanced([0] * 60)
assert result["adopted"] is False
assert result["positive_pct"] == 0.0
def test_exactly_80_pct_positive(self):
"""Exactly 80% positive (48/60) → threshold_met = True."""
# 48 positive (levels 3-5) + 12 negative (levels 0-2) = 60
votes = [4] * 48 + [1] * 12
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_pct"] == 80.0
assert result["threshold_met"] is True
assert result["adopted"] is True
def test_just_below_80_pct(self):
"""79.67% positive (47.8/60 ≈ 47/59) → threshold_met = False."""
# 47 positive + 13 negative = 60
votes = [4] * 47 + [1] * 13
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_pct"] < 80.0
assert result["threshold_met"] is False
assert result["adopted"] is False
def test_min_participants_exactly_met(self):
"""59 participants exactly → min_participants_met = True."""
votes = [5] * 59
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["min_participants_met"] is True
assert result["adopted"] is True
def test_min_participants_not_met(self):
"""58 participants → min_participants_met = False, even if 100% positive."""
votes = [5] * 58
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["min_participants_met"] is False
assert result["threshold_met"] is True # 100% > 80%
assert result["adopted"] is False # but quorum not met
def test_neutre_counts_as_positive(self):
"""Level 3 (NEUTRE) counts as positive in the formula."""
votes = [3] * 60
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_count"] == 60
assert result["positive_pct"] == 100.0
assert result["adopted"] is True
def test_invalid_level_raises(self):
"""Vote level 6 should raise ValueError."""
with pytest.raises(ValueError, match="invalide"):
evaluate_nuanced([6])
def test_negative_level_raises(self):
"""Vote level -1 should raise ValueError."""
with pytest.raises(ValueError, match="invalide"):
evaluate_nuanced([-1])
# ===========================================================================
# 3. ITEM MODIFICATION / DELETION / ADDITION WORKFLOW
# ===========================================================================
class TestItemVersionWorkflow:
"""Test the ItemVersion status lifecycle and apply/reject logic
using the document_service functions with mock database."""
def _make_item(self, item_id=None, text="Original text"):
item = MagicMock()
item.id = item_id or uuid.uuid4()
item.current_text = text
return item
def _make_version(self, version_id=None, item_id=None, proposed_text="New text",
status="proposed"):
version = MagicMock()
version.id = version_id or uuid.uuid4()
version.item_id = item_id or uuid.uuid4()
version.proposed_text = proposed_text
version.status = status
version.rationale = "Test rationale"
return version
@pytest.mark.asyncio
async def test_apply_version_updates_current_text(self):
"""When a version is accepted, item.current_text is updated."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id, "Old text")
version = self._make_version(version_id, item_id, "New improved text")
db = AsyncMock()
# Mock item query
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
# Mock version query
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
# Mock other versions query (no other pending versions)
other_result = MagicMock()
other_result.scalars.return_value = iter([])
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
result = await apply_version(item_id, version_id, db)
assert item.current_text == "New improved text"
assert version.status == "accepted"
@pytest.mark.asyncio
async def test_apply_version_rejects_competing_versions(self):
"""When a version is accepted, all other pending versions are rejected."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, item_id, "Winning text")
other1 = self._make_version(status="proposed")
other2 = self._make_version(status="voting")
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
other_result = MagicMock()
other_result.scalars.return_value = iter([other1, other2])
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
await apply_version(item_id, version_id, db)
assert other1.status == "rejected"
assert other2.status == "rejected"
@pytest.mark.asyncio
async def test_apply_version_wrong_item_raises(self):
"""Applying a version that belongs to a different item should raise."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, uuid.uuid4(), "Text") # different item_id
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
db.execute = AsyncMock(side_effect=[item_result, version_result])
with pytest.raises(ValueError, match="n'appartient pas"):
await apply_version(item_id, version_id, db)
@pytest.mark.asyncio
async def test_reject_version_sets_status(self):
"""Rejecting a version sets its status to 'rejected'."""
from app.services.document_service import reject_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, item_id, status="proposed")
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
db.execute = AsyncMock(side_effect=[item_result, version_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
result = await reject_version(item_id, version_id, db)
assert version.status == "rejected"
@pytest.mark.asyncio
async def test_apply_nonexistent_item_raises(self):
"""Applying a version to a nonexistent item should raise."""
from app.services.document_service import apply_version
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = None
db.execute = AsyncMock(return_value=item_result)
with pytest.raises(ValueError, match="introuvable"):
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_nonexistent_version_raises(self):
"""Applying a nonexistent version should raise."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
item = self._make_item(item_id)
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = None
db.execute = AsyncMock(side_effect=[item_result, version_result])
with pytest.raises(ValueError, match="introuvable"):
await apply_version(item_id, uuid.uuid4(), db)
class TestDocumentSerialization:
"""Test document serialization for IPFS archival with new fields."""
def _make_doc(self, items=None):
from app.services.document_service import serialize_document_to_text
doc = MagicMock()
doc.title = "Acte d'engagement Certification"
doc.version = "1.0.0"
doc.doc_type = "engagement"
doc.status = "active"
doc.description = "Test document"
doc.items = items or []
return doc
def _make_item(self, position, sort_order, title, text, section_tag=None, item_type="clause"):
item = MagicMock()
item.position = position
item.sort_order = sort_order
item.title = title
item.current_text = text
item.item_type = item_type
item.section_tag = section_tag
return item
def test_serialization_includes_section_items(self):
"""Serialization should include items from all sections."""
from app.services.document_service import serialize_document_to_text
items = [
self._make_item("I1", 0, "Preambule", "Texte preambule", "introduction"),
self._make_item("E1", 1, "Clause 1", "Texte clause", "fondamental"),
self._make_item("X1", 2, "Annexe 1", "Texte annexe", "annexe"),
]
doc = self._make_doc(items)
result = serialize_document_to_text(doc)
assert "Preambule" in result
assert "Clause 1" in result
assert "Annexe 1" in result
assert "Texte preambule" in result
def test_serialization_preserves_order(self):
"""Items should appear in sort_order, not insertion order."""
from app.services.document_service import serialize_document_to_text
items = [
self._make_item("X1", 2, "Third", "Text 3"),
self._make_item("I1", 0, "First", "Text 1"),
self._make_item("E1", 1, "Second", "Text 2"),
]
doc = self._make_doc(items)
result = serialize_document_to_text(doc)
first_pos = result.index("First")
second_pos = result.index("Second")
third_pos = result.index("Third")
assert first_pos < second_pos < third_pos
def test_serialization_deterministic(self):
"""Same document serialized twice must produce identical output."""
from app.services.document_service import serialize_document_to_text
items = [self._make_item("E1", 0, "Clause 1", "Text 1")]
doc = self._make_doc(items)
result1 = serialize_document_to_text(doc)
result2 = serialize_document_to_text(doc)
assert result1 == result2
class TestVoteSessionCreationFromItem:
"""Test the logic for creating a VoteSession from a DocumentItem's protocol context.
This simulates what the votes router does when creating a session."""
def test_session_inherits_protocol_params(self):
"""A vote session created for an item should use the item's protocol."""
# Simulate: DocumentItem has voting_protocol_id → VotingProtocol has mode_params
protocol_params = parse_mode_params("D30M50B.1G.2S.1")
# These params should be used in threshold computation
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=protocol_params["majority_pct"],
gradient_exponent=protocol_params["gradient_exponent"],
base_exponent=protocol_params["base_exponent"],
)
assert threshold == 94
def test_different_protocols_different_thresholds(self):
"""Items with different protocols should produce different thresholds."""
# Certification item (standard WoT)
std_params = parse_mode_params("D30M50B.1G.2")
# Forgeron item (Smith protocol)
smith_params = parse_mode_params("D30M50B.1G.2S.1")
# TechComm item
tc_params = parse_mode_params("D30M50B.1G.2T.1")
# WoT thresholds are the same (same M/B/G)
t_std = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=std_params["majority_pct"],
gradient_exponent=std_params["gradient_exponent"])
t_smith = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=smith_params["majority_pct"],
gradient_exponent=smith_params["gradient_exponent"])
assert t_std == t_smith
# But Smith protocol requires additional smith votes
smith_req = smith_threshold(20, smith_params["smith_exponent"])
assert smith_req == 2
# TechComm protocol requires additional techcomm votes
tc_req = techcomm_threshold(5, tc_params["techcomm_exponent"])
assert tc_req == 2
def test_session_duration_from_protocol(self):
"""Session duration should come from protocol's duration_days."""
params = parse_mode_params("D30M50B.1G.2")
assert params["duration_days"] == 30
params_short = parse_mode_params("D7M50B.1G.2")
assert params_short["duration_days"] == 7
class TestRealWorldScenarios:
"""Real-world voting scenarios from Duniter community."""
def test_forgeron_vote_feb_2026(self):
"""Engagement Forgeron v2.0.0 — Feb 2026.
97 pour / 23 contre, WoT 7224, Smith 20.
Mode: D30M50B.1G.2S.1 → threshold=94 → adopted."""
params = parse_mode_params("D30M50B.1G.2S.1")
threshold = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=params["majority_pct"],
gradient_exponent=params["gradient_exponent"],
)
assert threshold == 94
adopted = 97 >= threshold
assert adopted is True
# Smith criterion
smith_req = smith_threshold(20, params["smith_exponent"])
assert smith_req == 2
# Assume at least 2 smiths voted for → passes
smith_ok = 5 >= smith_req
assert smith_ok is True
def test_forgeron_vote_barely_passes(self):
"""Same scenario but with exactly 94 votes for → still passes."""
threshold = wot_threshold(wot_size=7224, total_votes=117,
majority_pct=50, gradient_exponent=0.2)
# Slightly different total (94+23=117)
assert 94 >= threshold
def test_forgeron_vote_would_fail_at_93(self):
"""93 votes for out of 116 → fails (threshold likely ~93-94)."""
threshold = wot_threshold(wot_size=7224, total_votes=116,
majority_pct=50, gradient_exponent=0.2)
# With 116 total, threshold is still high
# 93 may or may not pass depending on exact computation
if threshold > 93:
assert 93 < threshold
def test_certification_item_low_inertia(self):
"""A certification document item with low inertia (G=0.1) is easier to replace."""
threshold_low = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=50, gradient_exponent=0.1)
threshold_std = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=50, gradient_exponent=0.2)
assert threshold_low < threshold_std
def test_very_high_inertia_item(self):
"""A formule/ordonnancement item with very_high inertia (G=0.6, M=66%)."""
threshold = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=66, gradient_exponent=0.6)
# Should be very close to 120 (near unanimity at such low participation)
assert threshold >= 110, f"Very high inertia should demand near-unanimity, got {threshold}"
def test_full_wot_participation_simple_majority_wins(self):
"""If entire WoT of 7224 votes, simple majority (3613) suffices with standard params."""
threshold = wot_threshold(wot_size=7224, total_votes=7224,
majority_pct=50, gradient_exponent=0.2)
# At full participation: threshold ≈ 0.5 * 7224 = 3612
assert threshold == math.ceil(0.1 ** 7224 + 0.5 * 7224)
assert threshold == 3612
def test_techcomm_vote_cotec_5_members(self):
"""TechComm criterion with 5 members, exponent 0.1 → need 2 votes."""
tc_threshold = techcomm_threshold(5, 0.1)
assert tc_threshold == 2
# 1 TC vote → fails
assert 1 < tc_threshold
# 2 TC votes → passes
assert 2 >= tc_threshold

View File

@@ -43,6 +43,7 @@ def _make_document_mock(
doc.description = description
doc.ipfs_cid = None
doc.chain_anchor = None
doc.genesis_json = None
doc.created_at = datetime.now(timezone.utc)
doc.updated_at = datetime.now(timezone.utc)
doc.items = []
@@ -68,6 +69,9 @@ def _make_item_mock(
item.current_text = current_text
item.voting_protocol_id = None
item.sort_order = sort_order
item.section_tag = None
item.inertia_preset = "standard"
item.is_permanent_vote = True
item.created_at = datetime.now(timezone.utc)
item.updated_at = datetime.now(timezone.utc)
return item
@@ -135,8 +139,8 @@ class TestDocumentOutSchema:
expected_fields = {
"id", "slug", "title", "doc_type", "version", "status",
"description", "ipfs_cid", "chain_anchor", "created_at",
"updated_at", "items_count",
"description", "ipfs_cid", "chain_anchor", "genesis_json",
"created_at", "updated_at", "items_count",
}
assert expected_fields.issubset(set(data.keys()))

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -1,15 +1,15 @@
---
title: Documentation technique
description: Architecture, API et reference technique de Glibredecision
description: Architecture, API et reference technique de libreDecision
---
# Documentation technique
Bienvenue dans la documentation technique de Glibredecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
Bienvenue dans la documentation technique de libreDecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
## Presentation
Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter V2 de gerer des documents de reference modulaires sous vote permanent, prendre des decisions collectives multi-etapes, attribuer des mandats et archiver de maniere immuable les resultats via IPFS et la blockchain Duniter.
libreDecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter V2 de gerer des documents de reference modulaires sous vote permanent, prendre des decisions collectives multi-etapes, attribuer des mandats et archiver de maniere immuable les resultats via IPFS et la blockchain Duniter.
## Stack technique
@@ -37,7 +37,7 @@ Glibredecision est une plateforme de gouvernance decentralisee qui permet aux me
- **Version** : 1.0.0-rc
- **Statut** : Release candidate -- Sprint 5 (documentation et stabilisation)
- **Depot** : [git.duniter.org/tools/glibredecision](https://git.duniter.org/tools/glibredecision)
- **Depot** : [git.duniter.org/tools/libredecision](https://git.duniter.org/tools/libredecision)
## Sections

View File

@@ -0,0 +1,239 @@
# Spike : Moteur de workflow — Protocole Embarquement Forgerons
**Date** : 2026-03-02
**Statut** : Spike / Pré-étude
**Auteur** : Yvv + Claude
---
## Contexte : deux objets distincts
L'app a déjà deux concepts qui se côtoient dans "Protocoles" :
| Objet | Ce que c'est | Exemple |
|-------|-------------|---------|
| **VotingProtocol** | Règle de vote (formule, seuils, critères) | "Vote forgeron (Smith)" — D30M50B.1G.2S.1 |
| **Decision + Steps** | Processus multi-étapes (one-shot) | "Runtime Upgrade" — 5 étapes séquentielles |
Il manque un **troisième** objet : le **Protocole opérationnel réutilisable** — un template de workflow qui s'instancie pour chaque candidat/cas.
### Exemple : Embarquement Forgerons
Ce n'est pas une décision ponctuelle. C'est un **processus répétable** :
```
[Candidat] ──invite──▶ [Invitation on-chain]
◀──accept──
──setSessionKeys──▶ [Preuve technique]
┌──checklist aspirant (aléatoire, avec pièges)
├──certif smith 1 (checklist certificateur)
├──certif smith 2 (checklist certificateur)
└──certif smith 3 (checklist certificateur)
──goOnline──▶ [Autorité active]
```
Chaque étape a :
- Un **acteur** (candidat, certificateur, système)
- Des **prérequis** (étapes précédentes complétées)
- Une **preuve** (on-chain tx, checklist complétée, vote)
- Un **délai** (optionnel)
---
## Volume croissant prévisible
| Protocole opérationnel | Acteurs | Instances/an estimées |
|----------------------|---------|----------------------|
| Embarquement Forgerons | candidat + 3 certifieurs | ~10-50 |
| Embarquement Membre (Certification) | certifié + 5 certifieurs | ~500-2000 |
| Runtime Upgrade | CoTec + forgerons + communauté | ~4-12 |
| Modification Document | proposeur + communauté | ~10-50 |
| Mandat (élection/révocation) | candidat + communauté | ~5-20 |
| Engagement CoTec | candidat + CoTec | ~2-5 |
**Observation clé** : l'Embarquement Membre est le plus massif et partage la même structure que l'Embarquement Forgeron (checklist + certifications multiples). L'architecture doit être pensée pour ce volume.
---
## Options évaluées
### Option A : n8n (workflow automation)
**n8n** est un outil d'automatisation visuel (self-hosted, open source).
| Pour | Contre |
|------|--------|
| Éditeur visuel de workflows | Dépendance externe lourde (~500 MB Docker) |
| Webhooks, triggers, crons intégrés | Latence réseau (appels HTTP entre services) |
| 400+ intégrations (email, matrix, etc.) | Pas de MCP server configuré actuellement |
| Pas de code à écrire pour l'orchestration | Pas de concept natif de "checklist aléatoire" |
| | Les preuves on-chain nécessitent du dev custom de toute façon |
| | La communauté Duniter refuse les dépendances centralisées |
**Verdict** : n8n est excellent pour les **automations périphériques** (notifications, alertes, reporting), pas pour le **cœur du workflow**. Le cœur doit rester dans l'app.
**Usage recommandé de n8n** : connecteur optionnel pour les triggers de notification (webhook quand une étape change de statut → email/matrix/telegram). Ne pas en faire le moteur.
### Option B : Dev maison — étendre Decision/DecisionStep
Étendre le modèle `Decision` existant avec un concept de **template**.
```python
class ProcessTemplate(Base):
"""Reusable workflow template (e.g. "Embarquement Forgeron")."""
id: UUID
slug: str # "embarquement-forgeron"
name: str # "Embarquement Forgerons"
description: str
category: str # "onboarding", "governance", "upgrade"
step_templates: JSON # Ordered list of step definitions
checklist_document_id: UUID # FK to Document (engagement forgeron)
voting_protocol_id: UUID # FK to VotingProtocol
is_active: bool
class ProcessInstance(Base):
"""One execution of a template (e.g. "Embarquement de Matograine")."""
id: UUID
template_id: UUID # FK to ProcessTemplate
candidate_id: UUID # FK to DuniterIdentity (le candidat)
status: str # invited, accepted, keys_set, checklist, certifying, online, failed
current_step: int
started_at: datetime
completed_at: datetime | None
metadata: JSON # on-chain tx hashes, certifier IDs, etc.
class ProcessStepExecution(Base):
"""One step within an instance."""
id: UUID
instance_id: UUID
step_order: int
step_type: str # "on_chain", "checklist", "certification", "manual"
actor_id: UUID | None # Who must act
status: str # pending, active, completed, failed, skipped
proof: JSON | None # tx_hash, checklist_result, vote_session_id
started_at: datetime | None
completed_at: datetime | None
```
| Pour | Contre |
|------|--------|
| Zéro dépendance externe | Plus de code à écrire |
| Contrôle total sur la checklist (ordre aléatoire, pièges) | Faut designer le moteur de transitions |
| Les preuves on-chain sont natives (substrate-interface) | Le workflow avancé (timeouts, escalation) sera simpliste |
| S'intègre avec le vote engine existant | |
| La DB track tout (audit trail complet) | |
| Volume OK avec PostgreSQL (100k instances/an = rien) | |
**Verdict** : c'est la voie naturelle. Le modèle actuel `Decision + Steps` est une version simplifiée de ça. On l'étend proprement.
### Option C : Temporal.io / autre moteur de workflow distribué
| Pour | Contre |
|------|--------|
| Garanties transactionnelles fortes | Énorme pour le use case (~GB de RAM) |
| Retry/timeout/escalation natifs | Cluster Temporal = infra supplémentaire |
| Bon pour les longs workflows (jours/semaines) | Surcharge conceptuelle |
| | Aucune intégration native blockchain |
**Verdict** : overkill. À considérer uniquement si on dépasse 10 protocoles actifs avec des centaines d'instances simultanées. Pas avant 2028.
---
## Recommandation
### Sprint 2 : Option B — Dev maison, progressif
**Phase 1** (Sprint 2) — Fondations :
1. Créer `ProcessTemplate` + `ProcessInstance` + `ProcessStepExecution`
2. Seed : template "Embarquement Forgerons" avec ses 7 étapes
3. Frontend : page `/protocols/embarquement-forgerons` avec timeline visuelle
4. API : `POST /processes/{template_slug}/start` → crée une instance
5. API : `POST /processes/instances/{id}/advance` → passe à l'étape suivante
**Phase 2** (Sprint 3) — Checklist interactive :
1. UI de checklist avec ordre aléatoire + détection pièges
2. Liaison avec le Document (engagement forgeron) pour les clauses
3. Signature Ed25519 du résultat de checklist (preuve cryptographique)
**Phase 3** (Sprint 4+) — On-chain :
1. Trigger on-chain via substrate-interface (invite, accept, certify, goOnline)
2. Listener d'événements blockchain pour compléter automatiquement les étapes on-chain
3. Optionnel : webhook n8n pour notifications matrix/telegram
### Architecture cible
```
┌─────────────────────────────────────────────────┐
│ Frontend │
│ /protocols/embarquement-forgerons │
│ ├── Vue template (timeline, étapes) │
│ ├── Checklist interactive (aléatoire + pièges) │
│ └── Instance dashboard (candidats en cours) │
└──────────────────┬──────────────────────────────┘
│ API REST
┌──────────────────▼──────────────────────────────┐
│ Backend │
│ ProcessService │
│ ├── create_instance(template, candidate) │
│ ├── advance_step(instance, proof) │
│ ├── evaluate_checklist(instance, answers) │
│ └── on_chain_trigger(instance, extrinsic) │
│ │
│ SubstrateService (substrate-interface) │
│ ├── smithsMembership.invite() │
│ ├── smithsMembership.acceptInvitation() │
│ ├── smithsMembership.setSessionKeys() │
│ └── authorityMembers.goOnline() │
└──────────────────┬──────────────────────────────┘
│ Events
┌──────────────────▼──────────────────────────────┐
│ n8n (optionnel) │
│ Webhook → Notification matrix/telegram/email │
│ Cron → Relance candidats inactifs │
└─────────────────────────────────────────────────┘
```
### Ce que n8n ne fait PAS (et qu'on doit coder) :
- Checklist aléatoire avec clause piège et interruption
- Signature Ed25519 du résultat
- Appels substrate-interface (invite, certify, goOnline)
- Calcul de seuil unani-majoritaire
- Intégrité du workflow (preuve on-chain de chaque étape)
### Ce que n8n PEUT faire (optionnel, sprint 4+) :
- Webhook → notification email quand un candidat arrive à l'étape "certification"
- Cron → rappel hebdo aux certificateurs qui n'ont pas agi
- Webhook → post forum automatique quand un forgeron est accepté
- Dashboard monitoring (combien de candidats en cours, taux de completion)
---
## Nomenclature proposée dans l'UI
| Menu | Sous-section | Contenu |
|------|-------------|---------|
| **Protocoles** | Protocoles de vote | VotingProtocol (binaire, nuancé, smith, techcomm) |
| | Simulateur de formules | FormulaConfig interactif |
| | **Protocoles opérationnels** | ProcessTemplate (embarquement, upgrade, etc.) |
| **Décisions** | (inchangé) | Decision + Steps (instances one-shot) |
Les protocoles opérationnels ont leur propre section dans `/protocols` avec :
- Carte par template (nom, description, nb d'instances actives)
- Page détail : timeline template + liste d'instances en cours
- Page instance : suivi temps réel d'un candidat spécifique
---
## Prochaine étape
Valider cette orientation avec Yvv, puis :
1. Créer les 3 tables (ProcessTemplate, ProcessInstance, ProcessStepExecution)
2. Migration Alembic
3. Seed le template "Embarquement Forgerons" (7 étapes)
4. Router + service backend
5. Frontend : page template + page instance

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
---
title: Architecture
description: Vue d'ensemble de l'architecture technique de Glibredecision
description: Vue d'ensemble de l'architecture technique de libreDecision
---
# Architecture
## Vue d'ensemble
Glibredecision est organise en monorepo avec trois composants principaux :
libreDecision est organise en monorepo avec trois composants principaux :
```
Glibredecision/
libreDecision/
backend/ # API Python FastAPI (port 8002)
frontend/ # Application Nuxt 4 (port 3002)
docker/ # Fichiers Docker et orchestration

View File

@@ -1,6 +1,6 @@
---
title: Reference API
description: Liste des endpoints de l'API Glibredecision
description: Liste des endpoints de l'API libreDecision
---
# Reference API

View File

@@ -5,7 +5,7 @@ description: Tables et relations de la base de donnees PostgreSQL
# Schema de base de donnees
Glibredecision utilise PostgreSQL 16 avec SQLAlchemy 2.0 en mode asynchrone (asyncpg). Toutes les cles primaires sont des UUID v4.
libreDecision utilise PostgreSQL 16 avec SQLAlchemy 2.0 en mode asynchrone (asyncpg). Toutes les cles primaires sont des UUID v4.
## Tables

View File

@@ -5,7 +5,7 @@ description: Formules mathematiques de seuil WoT, criteres Smith et TechComm, si
# Formules de seuil
Glibredecision utilise un systeme de formules mathematiques pour determiner les seuils d'adoption des votes. Le mecanisme central est la **formule d'inertie WoT** qui impose une quasi-unanimite en cas de faible participation et converge vers une majorite simple a participation elevee.
libreDecision utilise un systeme de formules mathematiques pour determiner les seuils d'adoption des votes. Le mecanisme central est la **formule d'inertie WoT** qui impose une quasi-unanimite en cas de faible participation et converge vers une majorite simple a participation elevee.
## Formule principale -- Seuil WoT
@@ -174,7 +174,7 @@ Les parametres de formule sont encodes dans une chaine compacte pour faciliter l
## Vote nuance
En plus du vote binaire (pour/contre), Glibredecision supporte un vote nuance a 6 niveaux :
En plus du vote binaire (pour/contre), libreDecision supporte un vote nuance a 6 niveaux :
| Niveau | Label | Valeur normalisee |
| ------ | ------------- | ----------------: |
@@ -211,7 +211,7 @@ L'API expose un endpoint de simulation qui permet de tester le comportement de l
**Exemple de requete** :
```bash
curl -X POST https://glibredecision.example.org/api/v1/protocols/simulate \
curl -X POST https://libredecision.example.org/api/v1/protocols/simulate \
-H "Content-Type: application/json" \
-d '{
"wot_size": 7224,

View File

@@ -5,7 +5,7 @@ description: Integration Duniter V2, IPFS et ancrage on-chain
# Integration blockchain
Glibredecision s'integre a la blockchain Duniter V2 pour trois fonctions essentielles :
libreDecision s'integre a la blockchain Duniter V2 pour trois fonctions essentielles :
1. **Authentification** -- Verification de l'identite des membres via signature Ed25519
2. **Donnees WoT** -- Recuperation des tailles WoT, Smith et TechComm pour le calcul des seuils
@@ -100,7 +100,7 @@ L'ancrage on-chain consiste a soumettre un extrinsic `system.remark` contenant l
### Format du remark
```
glibredecision:sanctuary:{content_hash_sha256}
libredecision:sanctuary:{content_hash_sha256}
```
### Soumission
@@ -113,7 +113,7 @@ substrate = SubstrateInterface(url="wss://gdev.p2p.legal/ws")
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)

View File

@@ -1,11 +1,11 @@
---
title: Contribution
description: Guide de contribution au projet Glibredecision
description: Guide de contribution au projet libreDecision
---
# Guide de contribution
Merci de votre interet pour contribuer a Glibredecision. Ce guide explique comment configurer l'environnement de developpement, les conventions a respecter et le processus de contribution.
Merci de votre interet pour contribuer a libreDecision. Ce guide explique comment configurer l'environnement de developpement, les conventions a respecter et le processus de contribution.
## Prerequis
@@ -21,8 +21,8 @@ Merci de votre interet pour contribuer a Glibredecision. Ce guide explique comme
```bash
# Cloner le depot
git clone https://git.duniter.org/tools/glibredecision.git
cd glibredecision
git clone https://git.duniter.org/tools/libredecision.git
cd libredecision
# Copier le fichier d'environnement
cp .env.example .env

View File

@@ -1,11 +1,11 @@
---
title: Deploiement
description: Guide de deploiement en production de Glibredecision
description: Guide de deploiement en production de libreDecision
---
# Deploiement
Ce guide couvre le deploiement complet de Glibredecision en production avec Docker, Traefik, PostgreSQL et IPFS.
Ce guide couvre le deploiement complet de libreDecision en production avec Docker, Traefik, PostgreSQL et IPFS.
## Prerequis
@@ -13,7 +13,7 @@ Ce guide couvre le deploiement complet de Glibredecision en production avec Dock
| --------- | ---------------- | ----------- |
| Docker | 24+ | Moteur de conteneurs |
| Docker Compose | 2.20+ | Orchestration multi-conteneurs |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `glibredecision.org`) |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `libredecision.org`) |
| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt |
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees |
@@ -40,18 +40,18 @@ cp .env.example .env
| Variable | Description | Valeur par defaut | Production |
| -------- | ----------- | ----------------- | ---------- |
| `POSTGRES_DB` | Nom de la base de donnees | `glibredecision` | `glibredecision` |
| `POSTGRES_USER` | Utilisateur PostgreSQL | `glibredecision` | `glibredecision` |
| `POSTGRES_DB` | Nom de la base de donnees | `libredecision` | `libredecision` |
| `POSTGRES_USER` | Utilisateur PostgreSQL | `libredecision` | `libredecision` |
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL | `change-me-in-production` | **Generer un mot de passe fort** (32+ caracteres) |
| `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose |
| `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) |
| `DEBUG` | Mode debug | `true` | **`false`** |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://glibredecision.org"]` |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://libredecision.org"]` |
| `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production |
| `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) |
| `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://glibredecision.org/api/v1` |
| `DOMAIN` | Nom de domaine | `glibredecision.org` | Votre domaine |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://libredecision.org/api/v1` |
| `DOMAIN` | Nom de domaine | `libredecision.org` | Votre domaine |
### Generer les secrets
@@ -73,10 +73,10 @@ Ne commitez jamais le fichier `.env` contenant les secrets de production. Ajoute
```bash
# Se placer dans le repertoire du projet
cd /opt/glibredecision
cd /opt/libredecision
# Cloner le depot
git clone https://git.duniter.org/tools/glibredecision.git .
git clone https://git.duniter.org/tools/libredecision.git .
# Configurer l'environnement
cp .env.example .env
@@ -108,12 +108,12 @@ docker compose -f docker/docker-compose.yml ps
docker compose -f docker/docker-compose.yml logs -f backend
# Health check de l'API
curl -s https://glibredecision.org/api/health | jq .
curl -s https://libredecision.org/api/health | jq .
```
## Migration de base de donnees (Alembic)
Glibredecision utilise Alembic pour les migrations de schema PostgreSQL.
libreDecision utilise Alembic pour les migrations de schema PostgreSQL.
### Appliquer les migrations
@@ -182,7 +182,7 @@ services:
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@glibredecision.org"
- "--certificatesresolvers.letsencrypt.acme.email=admin@libredecision.org"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
@@ -207,10 +207,10 @@ docker compose -f docker-compose.traefik.yml up -d
### Routage
Le `docker-compose.yml` de Glibredecision configure automatiquement les labels Traefik :
Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik :
- **Frontend** : `Host(glibredecision.org)` sur le port 3000
- **Backend** : `Host(glibredecision.org) && PathPrefix(/api)` sur le port 8002
- **Frontend** : `Host(libredecision.org)` sur le port 3000
- **Backend** : `Host(libredecision.org) && PathPrefix(/api)` sur le port 8002
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
- Redirection HTTP vers HTTPS automatique
@@ -230,7 +230,7 @@ Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backe
```bash
# Health check de l'API
curl -s https://glibredecision.org/api/health
curl -s https://libredecision.org/api/health
# Reponse attendue : {"status": "healthy"}
```
@@ -257,8 +257,8 @@ Surveillez les indicateurs suivants :
| ---------- | -------- | --------------- |
| CPU/RAM conteneurs | `docker stats` | > 80% RAM |
| Espace disque | `df -h` | > 85% |
| Connexions PostgreSQL | `docker exec postgres psql -U glibredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 |
| Taille base de donnees | `docker exec postgres psql -U glibredecision -c "SELECT pg_size_pretty(pg_database_size('glibredecision'));"` | Information |
| Connexions PostgreSQL | `docker exec postgres psql -U libredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 |
| Taille base de donnees | `docker exec postgres psql -U libredecision -c "SELECT pg_size_pretty(pg_database_size('libredecision'));"` | Information |
| Statut IPFS | `docker exec ipfs ipfs id` | Erreur |
## Sauvegarde PostgreSQL
@@ -268,7 +268,7 @@ Surveillez les indicateurs suivants :
```bash
# Dump complet de la base
docker compose -f docker/docker-compose.yml exec postgres \
pg_dump -U glibredecision -Fc glibredecision > backup_$(date +%Y%m%d_%H%M%S).dump
pg_dump -U libredecision -Fc libredecision > backup_$(date +%Y%m%d_%H%M%S).dump
```
### Restauration
@@ -276,7 +276,7 @@ docker compose -f docker/docker-compose.yml exec postgres \
```bash
# Restaurer un dump
docker compose -f docker/docker-compose.yml exec -T postgres \
pg_restore -U glibredecision -d glibredecision --clean < backup_20260228_120000.dump
pg_restore -U libredecision -d libredecision --clean < backup_20260228_120000.dump
```
### Sauvegarde automatique (cron)
@@ -288,7 +288,7 @@ Ajoutez un crontab pour des sauvegardes quotidiennes :
crontab -e
# Ajouter une sauvegarde quotidienne a 3h du matin
0 3 * * * cd /opt/glibredecision && docker compose -f docker/docker-compose.yml exec -T postgres pg_dump -U glibredecision -Fc glibredecision > /opt/backups/glibredecision_$(date +\%Y\%m\%d).dump && find /opt/backups -name "glibredecision_*.dump" -mtime +30 -delete
0 3 * * * cd /opt/libredecision && docker compose -f docker/docker-compose.yml exec -T postgres pg_dump -U libredecision -Fc libredecision > /opt/backups/libredecision_$(date +\%Y\%m\%d).dump && find /opt/backups -name "libredecision_*.dump" -mtime +30 -delete
```
Cette commande :
@@ -301,7 +301,7 @@ Cette commande :
### Procedure standard
```bash
cd /opt/glibredecision
cd /opt/libredecision
# 1. Tirer les dernieres images
docker compose -f docker/docker-compose.yml pull
@@ -317,7 +317,7 @@ docker image prune -f
# 5. Verifier le deploiement
docker compose -f docker/docker-compose.yml ps
curl -s https://glibredecision.org/api/health
curl -s https://libredecision.org/api/health
```
### Pipeline CI/CD (Woodpecker)
@@ -377,7 +377,7 @@ docker compose -f docker/docker-compose.yml up -d # recree avec le nouveau m
**Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide.
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig glibredecision.org`
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig libredecision.org`
2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'`
3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme`

View File

@@ -1,17 +1,17 @@
---
title: Securite
description: Politique de securite et mesures de protection de Glibredecision
description: Politique de securite et mesures de protection de libreDecision
---
# Securite
Ce document decrit les mesures de securite implementees dans Glibredecision pour proteger l'integrite de la plateforme, des votes et des donnees des utilisateurs.
Ce document decrit les mesures de securite implementees dans libreDecision pour proteger l'integrite de la plateforme, des votes et des donnees des utilisateurs.
## Authentification Duniter V2 (Ed25519 challenge-response)
### Principe
Glibredecision n'utilise ni mot de passe ni systeme d'inscription classique. L'authentification repose entierement sur la cryptographie Ed25519 de la blockchain Duniter V2.
libreDecision n'utilise ni mot de passe ni systeme d'inscription classique. L'authentification repose entierement sur la cryptographie Ed25519 de la blockchain Duniter V2.
### Flux challenge-response
@@ -169,10 +169,10 @@ Contenu --> [SHA-256] --> hash
### Format du remark on-chain
```
glibredecision:sanctuary:{content_hash_sha256}
libredecision:sanctuary:{content_hash_sha256}
```
Le prefixe `glibredecision:sanctuary:` permet d'identifier les ancrages de Glibredecision parmi tous les remarks de la blockchain.
Le prefixe `libredecision:sanctuary:` permet d'identifier les ancrages de libreDecision parmi tous les remarks de la blockchain.
## WebSocket : authentification et securite
@@ -243,7 +243,7 @@ Les logs d'audit sont conserves de maniere permanente dans la base de donnees. L
### Processus
Si vous decouvrez une vulnerabilite de securite dans Glibredecision, merci de suivre cette procedure de divulgation responsable :
Si vous decouvrez une vulnerabilite de securite dans libreDecision, merci de suivre cette procedure de divulgation responsable :
1. **Ne divulguez pas publiquement** la vulnerabilite avant qu'un correctif soit disponible.
2. **Contactez l'equipe** via le canal securise indique sur le depot Git Duniter ou via le forum Duniter (message prive aux mainteneurs).

View File

@@ -1,15 +1,15 @@
---
title: Documentation utilisateur
description: Guide d'utilisation de la plateforme Glibredecision
description: Guide d'utilisation de la plateforme libreDecision
---
# Documentation utilisateur
Bienvenue dans la documentation utilisateur de Glibredecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
Bienvenue dans la documentation utilisateur de libreDecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
## Qu'est-ce que Glibredecision ?
## Qu'est-ce que libreDecision ?
Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter de :
libreDecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter de :
- Gerer des **documents de reference** modulaires (Licence G1, Engagements Forgeron, Reglement du Comite Technique, etc.) sous vote permanent
- Prendre des **decisions collectives** via des processus multi-etapes (qualification, examen, vote, execution, rapport)
@@ -33,7 +33,7 @@ La plateforme est entierement transparente : tous les votes sont publics, signes
## Par ou commencer ?
1. **Nouveau sur Glibredecision ?** Commencez par le guide [Premiers pas](/user/getting-started) pour vous connecter et decouvrir l'interface.
1. **Nouveau sur libreDecision ?** Commencez par le guide [Premiers pas](/user/getting-started) pour vous connecter et decouvrir l'interface.
2. **Vous voulez voter ?** Consultez le guide [Vote](/user/voting) pour comprendre les types de vote et la formule de seuil.
3. **Vous voulez proposer une modification ?** Le guide [Documents](/user/documents) explique comment proposer des modifications aux textes fondateurs.
4. **Une question ?** La [FAQ](/user/faq) repond aux questions les plus courantes.
@@ -42,7 +42,7 @@ La plateforme est entierement transparente : tous les votes sont publics, signes
Cette documentation est elle-meme un document en evolution. Si vous constatez une erreur, une imprecision ou un manque, vous pouvez :
- Ouvrir une issue sur le [depot Git](https://git.duniter.org/tools/glibredecision) de Glibredecision
- Ouvrir une issue sur le [depot Git](https://git.duniter.org/tools/libredecision) de libreDecision
- Proposer une modification directement via une merge request
- En discuter sur le [forum Duniter](https://forum.duniter.org)

View File

@@ -1,11 +1,11 @@
---
title: Premiers pas
description: Connexion et prise en main de Glibredecision
description: Connexion et prise en main de libreDecision
---
# Premiers pas
Ce guide vous accompagne de votre premiere visite jusqu'a votre premier vote sur Glibredecision.
Ce guide vous accompagne de votre premiere visite jusqu'a votre premier vote sur libreDecision.
## Prerequis
@@ -28,9 +28,9 @@ Vous pouvez **consulter** les documents, decisions et resultats de vote sans auc
3. Creez ou importez votre compte Duniter V2 dans l'extension.
4. Assurez-vous que votre adresse SS58 est bien celle liee a votre identite Duniter.
## Qui peut utiliser Glibredecision ?
## Qui peut utiliser libreDecision ?
Glibredecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duniter V2. Pour utiliser pleinement la plateforme, vous devez posseder une identite Duniter avec une adresse SS58 valide.
libreDecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duniter V2. Pour utiliser pleinement la plateforme, vous devez posseder une identite Duniter avec une adresse SS58 valide.
- **Consultation** : tout visiteur peut consulter les documents, decisions et resultats de vote.
- **Participation** (voter, proposer) : reservee aux membres authentifies via leur identite Duniter.
@@ -63,7 +63,7 @@ Votre cle privee n'est **jamais** transmise au serveur. Seule la signature du ch
La barre de navigation en haut de page contient :
- **Logo Glibredecision** : retour a l'accueil
- **Logo libreDecision** : retour a l'accueil
- **Menu principal** : acces aux cinq sections
- **Bouton de connexion** / **Votre profil** (si connecte)
- **Indicateur temps reel** : point colore indiquant l'etat de la connexion WebSocket

View File

@@ -1,6 +1,6 @@
---
title: Documents
description: Guide des documents de reference sur Glibredecision
description: Guide des documents de reference sur libreDecision
---
# Documents de reference
@@ -9,7 +9,7 @@ description: Guide des documents de reference sur Glibredecision
Un document de reference est un **texte fondateur** de la communaute Duniter/G1. Il peut s'agir d'une licence monetaire, d'un engagement que les membres s'engagent a respecter, d'un reglement interieur ou d'un texte constitutif. Ces documents definissent les regles, les valeurs et le fonctionnement de la communaute.
Ce qui rend Glibredecision unique, c'est que ces documents sont **modulaires** et sous **vote permanent** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. La communaute peut faire evoluer ses textes de maniere continue, sans procedure lourde ni periode speciale.
Ce qui rend libreDecision unique, c'est que ces documents sont **modulaires** et sous **vote permanent** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. La communaute peut faire evoluer ses textes de maniere continue, sans procedure lourde ni periode speciale.
## Types de documents

View File

@@ -1,6 +1,6 @@
---
title: Decisions
description: Guide des processus decisionnels sur Glibredecision
description: Guide des processus decisionnels sur libreDecision
---
# Decisions

View File

@@ -1,13 +1,13 @@
---
title: Vote
description: Guide du systeme de vote sur Glibredecision
description: Guide du systeme de vote sur libreDecision
---
# Vote
## Principe
Le systeme de vote de Glibredecision est concu pour adapter le seuil d'adoption a la participation reelle. Quand peu de membres votent, une quasi-unanimite est exigee. Quand la participation est elevee, une majorite simple suffit. Ce mecanisme d'**inertie** protege contre les decisions prises par un petit groupe.
Le systeme de vote de libreDecision est concu pour adapter le seuil d'adoption a la participation reelle. Quand peu de membres votent, une quasi-unanimite est exigee. Quand la participation est elevee, une majorite simple suffit. Ce mecanisme d'**inertie** protege contre les decisions prises par un petit groupe.
## Types de vote
@@ -42,7 +42,7 @@ Le vote nuance est recommande pour :
### L'analogie de l'inertie
Imaginez un gros rocher pose au sommet d'une colline. Pour le deplacer, il faut une force considerable : c'est l'**inertie**. Dans Glibredecision, le rocher represente le statu quo et la force necessaire represente le nombre de votes favorables.
Imaginez un gros rocher pose au sommet d'une colline. Pour le deplacer, il faut une force considerable : c'est l'**inertie**. Dans libreDecision, le rocher represente le statu quo et la force necessaire represente le nombre de votes favorables.
- **Quand peu de personnes poussent** (faible participation) : il faut que presque tout le monde pousse dans la meme direction. Si seulement 10 personnes sur 7000 votent, il faut que 9 sur 10 soient pour.
- **Quand beaucoup de personnes poussent** (forte participation) : la majorite simple suffit. Si 7000 personnes votent, il suffit que 3500 soient pour (50%).
@@ -279,7 +279,7 @@ Le simulateur montre visuellement l'impact : avec un gradient plus eleve, l'exig
## Mises a jour en temps reel
Glibredecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote.
libreDecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote.
### Ce qui est mis a jour en direct
@@ -318,7 +318,7 @@ Sur la page d'une session de vote, l'onglet **Votes** affiche la liste de tous l
- L'horodatage.
- Un lien pour verifier la signature Ed25519.
Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans Glibredecision : la transparence est un principe fondamental.
Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans libreDecision : la transparence est un principe fondamental.
## Meta-gouvernance : voter sur les regles du vote

View File

@@ -1,6 +1,6 @@
---
title: Mandats
description: Guide des mandats sur Glibredecision
description: Guide des mandats sur libreDecision
---
# Mandats

View File

@@ -1,15 +1,15 @@
---
title: Sanctuaire
description: Guide de l'archivage immuable sur Glibredecision
description: Guide de l'archivage immuable sur libreDecision
---
# Sanctuaire
## Qu'est-ce que le Sanctuaire ?
Le Sanctuaire est la couche d'**archivage immuable** de Glibredecision. C'est l'endroit ou les decisions adoptees, les documents archives et les resultats de vote sont preserves de maniere permanente et verifiable.
Le Sanctuaire est la couche d'**archivage immuable** de libreDecision. C'est l'endroit ou les decisions adoptees, les documents archives et les resultats de vote sont preserves de maniere permanente et verifiable.
Le principe est simple : une fois qu'un contenu entre dans le Sanctuaire, il ne peut plus etre modifie ni supprime. Meme si la plateforme Glibredecision disparaissait, les preuves resteraient accessibles et verifiables de maniere independante.
Le principe est simple : une fois qu'un contenu entre dans le Sanctuaire, il ne peut plus etre modifie ni supprime. Meme si la plateforme libreDecision disparaissait, les preuves resteraient accessibles et verifiables de maniere independante.
## Triple preuve : SHA-256 + IPFS + Blockchain
@@ -58,12 +58,12 @@ L'ancrage on-chain consiste a enregistrer le hash SHA-256 du contenu sur la bloc
- **Horodatage** : la date du bloc prouve que le contenu existait a cette date.
- **Immutabilite** : une fois inscrit dans la blockchain, le remark ne peut pas etre modifie.
- **Independance** : la preuve est verifiable sur la blockchain, independamment de Glibredecision.
- **Independance** : la preuve est verifiable sur la blockchain, independamment de libreDecision.
Le format du remark est :
```
glibredecision:sanctuary:{hash_sha256_du_contenu}
libredecision:sanctuary:{hash_sha256_du_contenu}
```
**Analogie** : C'est comme publier un hash dans un journal date et immuable. N'importe qui peut verifier que le hash etait bien la a cette date.
@@ -74,7 +74,7 @@ La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit q
- **Aucune decision adoptee ne peut etre modifiee retroactivement** : le hash et l'ancrage on-chain rendent toute falsification detectable.
- **Tout membre peut verifier l'authenticite** d'un document ou d'un resultat de vote de maniere independante.
- **L'historique des decisions est preserve** independamment de la plateforme : meme sans Glibredecision, les preuves restent sur IPFS et la blockchain.
- **L'historique des decisions est preserve** independamment de la plateforme : meme sans libreDecision, les preuves restent sur IPFS et la blockchain.
## Types d'entrees
@@ -110,7 +110,7 @@ Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un do
### Verification automatique
Glibredecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
libreDecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
1. Ouvrez l'entree a verifier dans le Sanctuaire.
2. Cliquez sur **Verifier l'integrite**.
@@ -126,7 +126,7 @@ Si les trois controles sont valides, le contenu est authentique et n'a pas ete m
### Verification manuelle (independante de la plateforme)
Pour une verification totalement independante de Glibredecision, suivez ces etapes :
Pour une verification totalement independante de libreDecision, suivez ces etapes :
#### Etape 1 : Recuperer le contenu via IPFS
@@ -161,7 +161,7 @@ Comparez le hash obtenu avec le hash affiche dans le Sanctuaire. Ils doivent etr
Si les trois hash correspondent (calcul local, Sanctuaire, on-chain), le contenu est authentique, integre et horodate. La triple preuve est confirmee.
::callout{type="tip"}
L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain.
L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme libreDecision disparait, la preuve reste verifiable sur la blockchain.
::
## Comprendre les informations d'ancrage on-chain

View File

@@ -1,19 +1,19 @@
---
title: FAQ
description: Questions frequentes sur Glibredecision
description: Questions frequentes sur libreDecision
---
# Questions frequentes
## Acces et authentification
### Ai-je besoin d'un compte Duniter pour utiliser Glibredecision ?
### Ai-je besoin d'un compte Duniter pour utiliser libreDecision ?
Pour **consulter** les documents, decisions et resultats de vote, aucune authentification n'est necessaire. Pour **voter**, **proposer des modifications** ou **creer des decisions**, vous devez posseder une identite Duniter V2 avec une adresse SS58.
### Comment fonctionne la connexion sans mot de passe ?
Glibredecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Voici le processus :
libreDecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Voici le processus :
1. Vous fournissez votre adresse Duniter SS58.
2. Le serveur genere un texte aleatoire (le "challenge") de 64 caracteres hexadecimaux.
@@ -37,7 +37,7 @@ Les sessions durent 24 heures. Reconnectez-vous en suivant le meme processus (ch
### Que se passe-t-il si je perds l'acces a ma cle privee ?
Glibredecision ne stocke jamais votre cle privee. Si vous perdez l'acces a votre cle, vous ne pourrez plus vous authentifier avec cette adresse. Vos votes passes restent enregistres et comptabilises. Contactez la communaute Duniter pour les procedures de recuperation d'identite si necessaire.
libreDecision ne stocke jamais votre cle privee. Si vous perdez l'acces a votre cle, vous ne pourrez plus vous authentifier avec cette adresse. Vos votes passes restent enregistres et comptabilises. Contactez la communaute Duniter pour les procedures de recuperation d'identite si necessaire.
### Puis-je me connecter depuis plusieurs appareils ?
@@ -101,7 +101,7 @@ C'est le codage compact des parametres de formule :
### Les votes sont-ils secrets ?
Non. Les votes et leurs signatures cryptographiques sont **publics**, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment par quiconque possede la cle publique du votant. Il n'y a pas de vote secret dans Glibredecision.
Non. Les votes et leurs signatures cryptographiques sont **publics**, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment par quiconque possede la cle publique du votant. Il n'y a pas de vote secret dans libreDecision.
### Le seuil peut-il changer pendant le vote ?
@@ -174,7 +174,7 @@ Oui. Un mandat actif peut etre revoque de maniere anticipee via l'action "Revoqu
### Pourquoi archiver sur IPFS et la blockchain ?
**IPFS** fournit un stockage distribue : le contenu est accessible meme si la plateforme Glibredecision est hors ligne. L'**ancrage on-chain** via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter V2. Le **hash SHA-256** garantit l'integrite du contenu. Ensemble, ils forment une **triple preuve** que le contenu n'a pas ete modifie depuis son archivage.
**IPFS** fournit un stockage distribue : le contenu est accessible meme si la plateforme libreDecision est hors ligne. L'**ancrage on-chain** via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter V2. Le **hash SHA-256** garantit l'integrite du contenu. Ensemble, ils forment une **triple preuve** que le contenu n'a pas ete modifie depuis son archivage.
### Comment verifier qu'un document n'a pas ete modifie ?
@@ -193,7 +193,7 @@ Oui. L'archivage est declenche automatiquement :
- Quand une decision est executee
- Quand un document est archive manuellement
### Puis-je acceder aux archives sans Glibredecision ?
### Puis-je acceder aux archives sans libreDecision ?
Oui. Les contenus archives sont accessibles via :
@@ -202,9 +202,9 @@ Oui. Les contenus archives sont accessibles via :
## Questions techniques
### Sur quelle blockchain Glibredecision fonctionne-t-il ?
### Sur quelle blockchain libreDecision fonctionne-t-il ?
Glibredecision se connecte a la blockchain **Duniter V2** (basee sur Substrate). En environnement de developpement, il se connecte au reseau de test GDev (`wss://gdev.p2p.legal/ws`).
libreDecision se connecte a la blockchain **Duniter V2** (basee sur Substrate). En environnement de developpement, il se connecte au reseau de test GDev (`wss://gdev.p2p.legal/ws`).
### Que se passe-t-il si la blockchain Duniter est indisponible ?
@@ -224,14 +224,14 @@ Oui. Les votes et leurs signatures cryptographiques sont publics, conformement a
### Les mises a jour sont-elles en temps reel ?
Oui. Glibredecision utilise une connexion WebSocket pour diffuser les mises a jour en temps reel :
Oui. libreDecision utilise une connexion WebSocket pour diffuser les mises a jour en temps reel :
- Nouveaux votes soumis : la jauge de seuil est recalculee instantanement
- Votes modifies : la jauge reflette le changement immediatement
- Sessions cloturees : le resultat final s'affiche
- Un indicateur de connexion (point vert/orange/rouge) en bas a droite indique l'etat de la connexion temps reel
### Ou est heberge Glibredecision ?
### Ou est heberge libreDecision ?
La plateforme est hebergee sur une infrastructure geree par la communaute, avec deploiement automatise via Docker et Woodpecker CI. Le code source est ouvert et disponible sur le depot Git Duniter.

View File

@@ -99,8 +99,11 @@ function isActive(to: string) {
</NuxtLink>
</div>
<!-- Center: Mood switcher (desktop) -->
<MoodSwitcher class="hidden sm:flex" />
<!-- Center: Workspace selector + Mood switcher (desktop) -->
<div class="app-header__center">
<WorkspaceSelector class="hidden sm:flex" />
<MoodSwitcher class="hidden sm:flex" />
</div>
<!-- Right: Auth -->
<div class="app-header__right">
@@ -159,7 +162,11 @@ function isActive(to: string) {
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<!-- Mood switcher in mobile drawer -->
<!-- Workspace + Mood in mobile drawer -->
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Espace</span>
<WorkspaceSelector />
</div>
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Ambiance</span>
<MoodSwitcher />
@@ -190,6 +197,25 @@ function isActive(to: string) {
<ErrorBoundary>
<NuxtPage />
</ErrorBoundary>
<!-- Tsing sceau (proportions calées sur avatars Yvv) -->
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
<!-- Line 6 (top) yin -->
<rect x="5" y="5" width="49" height="5" rx="1"/>
<rect x="76" y="5" width="49" height="5" rx="1"/>
<!-- Line 5 yang -->
<rect x="5" y="22" width="120" height="5" rx="1"/>
<!-- Line 4 yin -->
<rect x="5" y="39" width="49" height="5" rx="1"/>
<rect x="76" y="39" width="49" height="5" rx="1"/>
<!-- Line 3 yang -->
<rect x="5" y="56" width="120" height="5" rx="1"/>
<!-- Line 2 yang -->
<rect x="5" y="73" width="120" height="5" rx="1"/>
<!-- Line 1 (bottom) yin -->
<rect x="5" y="90" width="49" height="5" rx="1"/>
<rect x="76" y="90" width="49" height="5" rx="1"/>
</svg>
</main>
</div>
@@ -241,9 +267,18 @@ function isActive(to: string) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
height: 3.5rem;
}
.app-header__center {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1;
justify-content: center;
}
.app-header__left {
display: flex;
align-items: center;
@@ -577,6 +612,17 @@ function isActive(to: string) {
opacity: 0.3;
}
/* === Seal — 井 Tsing === */
.app-seal {
display: block;
width: 44px;
margin: 1.5rem 0 0.5rem auto;
color: var(--mood-accent);
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
}
/* === Transitions === */
.slide-up-enter-active,
.slide-up-leave-active {

View File

@@ -2,8 +2,8 @@
/**
* SectionLayout Mise en page pour sections.
*
* Status pills inside the content block (not header).
* Toolbox sidebar with condensed content.
* Desktop (1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
*/
export interface StatusFilter {
@@ -30,11 +30,13 @@ const props = withDefaults(
statuses: StatusFilter[]
toolboxItems?: ToolboxItem[]
activeStatus?: string | null
toolboxTitle?: string
}>(),
{
subtitle: undefined,
toolboxItems: undefined,
activeStatus: null,
toolboxTitle: 'Boîte à outils',
},
)
@@ -75,16 +77,27 @@ function toggleStatus(statusId: string) {
<template>
<div class="section">
<!-- Header: just title -->
<!-- Header -->
<div class="section__header">
<h1 class="section__title">{{ title }}</h1>
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
<div class="section__header-left">
<h1 class="section__title">{{ title }}</h1>
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
</div>
<!-- Mobile toolbox trigger -->
<button
class="section__toolbox-fab lg:hidden"
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
@click="toolboxOpen = true"
>
<UIcon name="i-lucide-wrench" />
<span>Outils</span>
</button>
</div>
<!-- Body: content + toolbox -->
<div class="section__body">
<div class="section__main">
<!-- Status pills INSIDE the list block -->
<!-- Status pills -->
<div v-if="statuses.length > 0" class="section__pills">
<button
v-for="status in statuses"
@@ -107,22 +120,17 @@ function toggleStatus(statusId: string) {
</div>
</div>
<!-- Desktop toolbox sidebar (1024px) -->
<aside class="section__toolbox">
<button class="section__toolbox-head" @click="toolboxOpen = !toolboxOpen">
<div class="section__toolbox-head-left">
<UIcon name="i-lucide-wrench" />
<span>Boite a outils</span>
</div>
<UIcon
:name="toolboxOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="section__toolbox-toggle"
/>
</button>
<div class="section__toolbox-content" :class="{ 'section__toolbox-content--open': toolboxOpen }">
<div v-if="$slots.toolbox" class="section__toolbox-body">
<div class="section__toolbox-head">
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
<span>{{ toolboxTitle }}</span>
</div>
<div class="section__toolbox-body">
<div v-if="$slots.toolbox">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
<div v-else-if="toolboxItems && toolboxItems.length > 0">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
@@ -135,6 +143,36 @@ function toggleStatus(statusId: string) {
</div>
</aside>
</div>
<!-- Mobile toolbox: USlideover from right -->
<USlideover
v-model:open="toolboxOpen"
side="right"
:title="toolboxTitle"
:ui="{
width: 'max-w-sm',
header: { padding: 'p-4' },
body: { padding: 'p-4' },
}"
>
<template #body>
<div class="section__toolbox-slideover">
<div v-if="$slots.toolbox">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
/>
</div>
<div v-else class="section__toolbox-empty">
Aucun outil disponible
</div>
</div>
</template>
</USlideover>
</div>
</template>
@@ -142,10 +180,24 @@ function toggleStatus(statusId: string) {
.section {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 1.25rem;
}
@media (min-width: 640px) {
.section { gap: 1.5rem; }
}
/* Header */
.section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.section__header-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
@@ -156,33 +208,66 @@ function toggleStatus(statusId: string) {
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
margin: 0;
}
@media (min-width: 640px) {
.section__title {
font-size: 1.75rem;
}
.section__title { font-size: 1.75rem; }
}
.section__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-weight: 500;
margin: 0;
line-height: 1.5;
}
@media (min-width: 640px) {
.section__subtitle {
font-size: 1rem;
}
.section__subtitle { font-size: 1rem; }
}
/* Mobile toolbox trigger */
.section__toolbox-fab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 20px;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.section__toolbox-fab:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
.section__toolbox-fab--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
/* Body layout */
.section__body {
display: grid;
grid-template-columns: 1fr 16rem;
grid-template-columns: 1fr;
gap: 1.5rem;
align-items: start;
}
@media (min-width: 1024px) {
.section__body {
grid-template-columns: 1fr 30rem;
}
}
.section__main {
display: flex;
flex-direction: column;
@@ -190,6 +275,7 @@ function toggleStatus(statusId: string) {
min-width: 0;
}
/* Status pills */
.section__pills {
display: flex;
gap: 0.5rem;
@@ -200,9 +286,7 @@ function toggleStatus(statusId: string) {
padding-bottom: 2px;
}
.section__pills::-webkit-scrollbar {
display: none;
}
.section__pills::-webkit-scrollbar { display: none; }
@media (min-width: 640px) {
.section__pills {
@@ -226,64 +310,51 @@ function toggleStatus(statusId: string) {
}
@media (max-width: 639px) {
.section__search {
flex-direction: column;
}
.section__search { flex-direction: column; }
}
.section__content {
min-height: 12rem;
}
.section__content { min-height: 12rem; }
/* Desktop toolbox sidebar */
.section__toolbox {
display: none;
position: sticky;
top: 4.5rem;
display: flex;
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) {
.section__toolbox { display: flex; }
}
.section__toolbox-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem;
cursor: pointer;
background: none;
}
.section__toolbox-head-left {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
padding: 0.875rem 1rem 0.625rem;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.section__toolbox-toggle {
color: var(--mood-text-muted);
.section__toolbox-head-icon {
font-size: 0.875rem;
}
.section__toolbox-content {
display: none;
padding: 0 1rem 1rem;
}
.section__toolbox-content--open {
display: block;
}
.section__toolbox-body {
padding: 0 0.75rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
gap: 0.5rem;
overflow: hidden;
}
.section__toolbox-empty {
@@ -293,26 +364,10 @@ function toggleStatus(statusId: string) {
padding: 1rem 0;
}
/* Desktop: toolbox always open, no toggle */
@media (min-width: 1024px) {
.section__toolbox-head {
cursor: default;
}
.section__toolbox-toggle {
display: none;
}
.section__toolbox-content {
display: block;
}
}
@media (max-width: 1023px) {
.section__body {
grid-template-columns: 1fr;
}
.section__toolbox {
position: static;
order: 2;
}
/* Slideover content */
.section__toolbox-slideover {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
/**
* WorkspaceSelector — Sélecteur de collectif / espace de travail.
* Compartimentage multi-collectifs, multi-sites.
* UI-only pour l'instant, prêt pour le backend (collective_id sur toutes les entités).
*/
interface Workspace {
id: string
name: string
slug: string
icon: string
role?: string
color?: string
}
// Mock data — sera remplacé par le store collectifs
const workspaces: Workspace[] = [
{
id: 'g1-main',
name: 'Duniter G1',
slug: 'duniter-g1',
icon: 'i-lucide-coins',
role: 'Membre',
color: 'accent',
},
{
id: 'axiom',
name: 'Axiom Team',
slug: 'axiom-team',
icon: 'i-lucide-layers',
role: 'Admin',
color: 'secondary',
},
]
const activeId = ref('g1-main')
const isOpen = ref(false)
const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
function selectWorkspace(id: string) {
activeId.value = id
isOpen.value = false
// TODO: store.setActiveCollective(id) + refetch all data
}
// Close on outside click
const containerRef = ref<HTMLElement | null>(null)
onMounted(() => {
document.addEventListener('click', (e) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
})
})
</script>
<template>
<div ref="containerRef" class="ws">
<button class="ws__trigger" :class="{ 'ws__trigger--open': isOpen }" @click="isOpen = !isOpen">
<div class="ws__icon" :class="`ws__icon--${active.color}`">
<UIcon :name="active.icon" />
</div>
<span class="ws__name">{{ active.name }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="ws__dropdown">
<div class="ws__dropdown-header">
Espace de travail
</div>
<div class="ws__items">
<button
v-for="ws in workspaces"
:key="ws.id"
class="ws__item"
:class="{ 'ws__item--active': ws.id === activeId }"
@click="selectWorkspace(ws.id)"
>
<div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
<UIcon :name="ws.icon" />
</div>
<div class="ws__item-info">
<span class="ws__item-name">{{ ws.name }}</span>
<span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
</div>
<UIcon v-if="ws.id === activeId" name="i-lucide-check" class="ws__item-check" />
</button>
</div>
<div class="ws__dropdown-footer">
<button class="ws__new-btn" disabled>
<UIcon name="i-lucide-plus" />
Nouveau collectif
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.ws {
position: relative;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--mood-accent-soft);
border-radius: 10px;
cursor: pointer;
transition: all 0.12s ease;
min-height: 2rem;
max-width: 11rem;
}
.ws__trigger:hover {
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
}
.ws__trigger--open {
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
}
.ws__icon {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.75rem;
}
.ws__icon--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
.ws__icon--secondary {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.ws__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ws__caret {
font-size: 0.75rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
/* Dropdown */
.ws__dropdown {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 13rem;
background: var(--mood-surface);
border-radius: 14px;
box-shadow: 0 8px 32px var(--mood-shadow);
z-index: 100;
overflow: hidden;
}
.ws__dropdown-header {
padding: 0.625rem 0.875rem 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.ws__items {
padding: 0.25rem 0.5rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.625rem;
border-radius: 10px;
cursor: pointer;
transition: background 0.1s ease;
text-align: left;
width: 100%;
}
.ws__item:hover { background: var(--mood-accent-soft); }
.ws__item--active { background: var(--mood-accent-soft); }
.ws__item-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 0.875rem;
}
.ws__item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ws__item-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
.ws__item-role {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.ws__item-check {
color: var(--mood-accent);
font-size: 0.875rem;
flex-shrink: 0;
}
.ws__dropdown-footer {
padding: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.ws__new-btn {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: not-allowed;
opacity: 0.5;
}
/* Transition */
.dropdown-enter-active, .dropdown-leave-active {
transition: all 0.15s ease;
transform-origin: top left;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>

View File

@@ -24,6 +24,9 @@ const renderedHtml = computed(() => {
// Convert *italic* to <em>
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Convert `code` to <code>
html = html.replace(/`([^`]+)`/g, '<code class="md-code">$1</code>')
// Process lines for list items
const lines = html.split('\n')
const result: string[] = []
@@ -34,7 +37,7 @@ const renderedHtml = computed(() => {
if (trimmed.startsWith('- ')) {
if (!inList) {
result.push('<ul class="list-disc list-inside space-y-1 my-2">')
result.push('<ul class="md-list">')
inList = true
}
result.push(`<li>${trimmed.slice(2)}</li>`)
@@ -46,7 +49,7 @@ const renderedHtml = computed(() => {
if (trimmed === '') {
result.push('<br>')
} else {
result.push(`<p class="my-1">${line}</p>`)
result.push(`<p class="md-para">${line}</p>`)
}
}
}
@@ -61,7 +64,49 @@ const renderedHtml = computed(() => {
<template>
<div
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 leading-relaxed"
class="md-rendered"
v-html="renderedHtml"
/>
</template>
<style scoped>
.md-rendered {
color: var(--mood-text);
line-height: 1.5;
}
.md-rendered :deep(.md-list) {
list-style-type: disc;
padding-left: 1.25em;
margin: 0.125em 0;
}
.md-rendered :deep(.md-list li) {
padding: 0;
line-height: 1.4;
}
.md-rendered :deep(.md-para) {
margin: 0.1em 0;
}
.md-rendered :deep(.md-code) {
font-family: monospace;
font-size: 0.875em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-bg));
color: var(--mood-text);
}
.md-rendered :deep(strong) {
font-weight: 700;
color: var(--mood-text);
}
.md-rendered :deep(em) {
font-style: italic;
color: var(--mood-text-muted);
font-size: 0.875em;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* ToolboxVignette — Carte compacte, bullet points, bouton Demarrer.
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
*/
export interface ToolboxAction {
@@ -16,10 +16,12 @@ const props = withDefaults(
title: string
bullets?: string[]
actions?: ToolboxAction[]
defaultOpen?: boolean
}>(),
{
bullets: undefined,
actions: undefined,
defaultOpen: false,
},
)
@@ -27,6 +29,8 @@ const emit = defineEmits<{
action: [actionEmit: string]
}>()
const open = ref(props.defaultOpen)
const defaultActions: ToolboxAction[] = [
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
@@ -46,22 +50,27 @@ function handleAction(action: ToolboxAction) {
</script>
<template>
<div class="vignette">
<h4 class="vignette__title">{{ title }}</h4>
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
</ul>
<div class="vignette__actions">
<button
v-for="action in resolvedActions"
:key="action.label"
class="vignette__btn"
:class="{ 'vignette__btn--primary': action.primary }"
@click="handleAction(action)"
>
<UIcon v-if="action.icon" :name="action.icon" />
<span>{{ action.label }}</span>
</button>
<div class="vignette" :class="{ 'vignette--open': open }">
<button class="vignette__header" @click="open = !open">
<h4 class="vignette__title">{{ title }}</h4>
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
</button>
<div v-show="open" class="vignette__content">
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
</ul>
<div class="vignette__actions">
<button
v-for="action in resolvedActions"
:key="action.label"
class="vignette__btn"
:class="{ 'vignette__btn--primary': action.primary }"
@click="handleAction(action)"
>
<UIcon v-if="action.icon" :name="action.icon" />
<span>{{ action.label }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -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;

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* DocumentTuto — Quick tutorial overlay explaining how the document works.
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
*/
const open = ref(false)
const steps = [
{
icon: 'i-lucide-infinity',
title: 'Vote permanent',
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
},
{
icon: 'i-lucide-sliders-horizontal',
title: 'Inertie variable',
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
},
{
icon: 'i-lucide-scale',
title: 'Seuil adaptatif',
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
},
{
icon: 'i-lucide-pen-line',
title: 'Contre-propositions',
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
},
{
icon: 'i-lucide-git-branch',
title: 'Dépôt automatique',
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
},
]
</script>
<template>
<div>
<UButton
icon="i-lucide-circle-help"
variant="ghost"
color="neutral"
size="sm"
@click="open = true"
/>
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
<template #content>
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
Comment ça marche ?
</h2>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="open = false"
/>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(step, idx) in steps"
:key="idx"
class="tuto-step"
>
<div class="tuto-step__icon">
<UIcon :name="step.icon" class="text-base" />
</div>
<div class="tuto-step__content">
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
{{ step.title }}
</h4>
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
{{ step.text }}
</p>
</div>
</div>
</div>
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
Référence : formule g1vote
<a
href="https://g1vote-view-237903.pages.duniter.org/"
target="_blank"
rel="noopener"
style="color: var(--mood-accent)"
>
g1vote-view
</a>
</p>
</div>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.tuto-step {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.tuto-step__icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
color: var(--mood-accent);
flex-shrink: 0;
}
.tuto-step__content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
</style>

View File

@@ -0,0 +1,303 @@
<script setup lang="ts">
/**
* EngagementCard — Enhanced item card with inline mini vote board,
* inertia indicator, and action buttons.
*
* Replaces the basic ItemCard for the document detail view.
*/
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
showVoteBoard?: boolean
}>(), {
showActions: false,
showVoteBoard: true,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const isSection = computed(() => props.item.item_type === 'section')
const isPreamble = computed(() => props.item.item_type === 'preamble')
const itemTypeIcon = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'i-lucide-shield-check'
case 'rule': return 'i-lucide-scale'
case 'verification': return 'i-lucide-check-circle'
case 'preamble': return 'i-lucide-scroll-text'
case 'section': return 'i-lucide-layout-list'
default: return 'i-lucide-file-text'
}
})
const itemTypeLabel = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'Engagement'
case 'rule': return 'Variables'
case 'verification': return 'Application'
case 'preamble': return 'Préambule'
case 'section': return 'Titre'
default: return props.item.item_type
}
})
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
const mockVotes = computed(() => {
const order = props.item.sort_order
const pos = props.item.position
// Conseils et bonnes pratiques: varied votes, some non-adopted
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
// Default: well-adopted items
const base = ((order * 7 + 13) % 5) + 8 // 8-12
const against = (order % 3) // 0-2
return { votesFor: base, votesAgainst: against }
})
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<!-- Section header (visual separator, not a card) -->
<div v-if="isSection" class="engagement-section">
<div class="engagement-section__line" />
<div class="engagement-section__content">
<h3 class="engagement-section__title">
{{ item.title }}
</h3>
<p class="engagement-section__text">
{{ item.current_text }}
</p>
<InertiaSlider
:preset="item.inertia_preset"
compact
class="mt-2 max-w-xs"
/>
</div>
</div>
<!-- Regular item card -->
<div
v-else
class="engagement-card"
:class="{
'engagement-card--preamble': isPreamble,
}"
>
<!-- Card header -->
<div class="engagement-card__header" @click="navigateToItem">
<div class="flex items-center gap-2.5 min-w-0">
<div class="engagement-card__position">
{{ item.position }}
</div>
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
<span v-if="item.title" class="engagement-card__title">
{{ item.title }}
</span>
<span class="engagement-card__type-label">
{{ itemTypeLabel }}
</span>
</div>
</div>
<!-- Item text -->
<div class="engagement-card__body" @click="navigateToItem">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Mini vote board -->
<div v-if="showVoteBoard" class="engagement-card__vote">
<MiniVoteBoard
:votes-for="mockVotes.votesFor"
:votes-against="mockVotes.votesAgainst"
:wot-size="7224"
:is-permanent="item.is_permanent_vote"
:inertia-preset="item.inertia_preset"
/>
</div>
<!-- Inertia indicator -->
<div class="engagement-card__inertia">
<InertiaSlider
:preset="item.inertia_preset"
compact
/>
</div>
<!-- Actions -->
<div v-if="showActions" class="engagement-card__actions">
<UButton
label="Proposer une alternative"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
<UButton
label="Voter"
icon="i-lucide-vote"
variant="soft"
color="success"
size="xs"
@click.stop="navigateToItem"
/>
</div>
</div>
</template>
<style scoped>
/* Section separator */
.engagement-section {
display: flex;
gap: 1rem;
padding: 1.5rem 0 0.5rem;
}
.engagement-section__line {
width: 4px;
background: var(--mood-accent);
border-radius: 2px;
flex-shrink: 0;
}
.engagement-section__content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.engagement-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
.engagement-section__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
/* Card */
.engagement-card {
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
transition: box-shadow 0.15s, transform 0.15s;
}
.engagement-card:hover {
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
transform: translateY(-1px);
}
.engagement-card--preamble {
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
}
.engagement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem 0;
cursor: pointer;
}
.engagement-card__position {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.625rem;
padding: 0 0.5rem;
border-radius: 8px;
background: var(--mood-accent);
color: white;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.engagement-card__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.engagement-card__type-label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-accent);
opacity: 0.5;
flex-shrink: 0;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
}
.engagement-card__body {
padding: 0.5rem 1rem 0.625rem;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.5;
color: var(--mood-text);
}
@media (min-width: 640px) {
.engagement-card__body {
font-size: 0.9375rem;
line-height: 1.55;
}
}
.engagement-card__vote {
padding: 0 1rem;
opacity: 0.7;
transform: scale(0.92);
transform-origin: left center;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__vote {
opacity: 1;
}
.engagement-card__inertia {
padding: 0.25rem 1rem 0.5rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__inertia {
opacity: 1;
}
.engagement-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
}
</style>

View File

@@ -0,0 +1,489 @@
<script setup lang="ts">
/**
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
* for a reference document. Main block collapsible, each sub-section independently collapsible.
*/
const props = defineProps<{
genesisJson: string
}>()
const expanded = ref(false)
// Individual section toggles
const sectionOpen = reactive<Record<string, boolean>>({
source: true,
tools: false,
forum: true,
process: false,
contributors: false,
})
function toggleSection(key: string) {
sectionOpen[key] = !sectionOpen[key]
}
interface GenesisData {
source_document: {
title: string
url: string
repo: string
}
reference_tools: Record<string, string>
forum_synthesis: Array<{
title: string
url: string
status: string
posts?: number
}>
formula_trigger: string
contributors: Array<{
name: string
role: string
}>
}
const genesis = computed((): GenesisData | null => {
try {
return JSON.parse(props.genesisJson)
} catch {
return null
}
})
const statusLabel = (status: string) => {
switch (status) {
case 'rejected': return 'Rejetée'
case 'in_progress': return 'En cours'
case 'reference': return 'Référence'
default: return status
}
}
const statusClass = (status: string) => {
switch (status) {
case 'rejected': return 'genesis-status--rejected'
case 'in_progress': return 'genesis-status--progress'
case 'reference': return 'genesis-status--reference'
default: return 'genesis-status--default'
}
}
</script>
<template>
<div v-if="genesis" class="genesis-block">
<!-- Header (always visible) -->
<button
class="genesis-block__header"
@click="expanded = !expanded"
>
<div class="flex items-center gap-3">
<div class="genesis-block__icon">
<UIcon name="i-lucide-file-archive" class="text-lg" />
</div>
<div class="text-left">
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
Bloc de genèse
</h3>
<p class="text-xs genesis-text-muted">
Sources, références et formule de dépôt
</p>
</div>
</div>
<UIcon
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-lg genesis-muted-icon"
/>
</button>
<!-- Expandable content -->
<Transition name="genesis-expand">
<div v-if="expanded" class="genesis-block__body">
<!-- Source document -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('source')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-file-text" />
Document source
</h4>
<UIcon
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.source" class="genesis-section__content">
<div class="genesis-card">
<p class="font-medium text-sm genesis-text">
{{ genesis.source_document.title }}
</p>
<div class="flex flex-col gap-1 mt-2">
<a
:href="genesis.source_document.url"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-external-link" class="text-xs" />
Texte officiel
</a>
<a
:href="genesis.source_document.repo"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
Dépôt git
</a>
</div>
</div>
</div>
</div>
<!-- Reference tools -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('tools')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de référence
</h4>
<UIcon
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.tools" class="genesis-section__content">
<div class="grid grid-cols-2 gap-2">
<a
v-for="(url, name) in genesis.reference_tools"
:key="name"
:href="url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--tool"
>
<span class="text-xs font-semibold capitalize genesis-text">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
</a>
</div>
</div>
</div>
<!-- Forum synthesis -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('forum')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthèse des discussions
</h4>
<UIcon
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.forum" class="genesis-section__content">
<div class="flex flex-col gap-2">
<a
v-for="topic in genesis.forum_synthesis"
:key="topic.url"
:href="topic.url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--forum"
>
<div class="flex items-start justify-between gap-2">
<span class="text-xs font-medium genesis-text">
{{ topic.title }}
</span>
<span
class="genesis-status shrink-0"
:class="statusClass(topic.status)"
>
{{ statusLabel(topic.status) }}
</span>
</div>
<span v-if="topic.posts" class="text-xs genesis-text-muted">
{{ topic.posts }} messages
</span>
</a>
</div>
</div>
</div>
<!-- Formula trigger -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('process')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de dépôt
</h4>
<UIcon
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.process" class="genesis-section__content">
<div class="genesis-card">
<p class="text-xs leading-relaxed genesis-text">
{{ genesis.formula_trigger }}
</p>
</div>
</div>
</div>
<!-- Contributors -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-users" />
Contributeurs
</h4>
<UIcon
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.contributors" class="genesis-section__content">
<div class="flex flex-wrap gap-2">
<div
v-for="c in genesis.contributors"
:key="c.name"
class="genesis-contributor"
>
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.genesis-block {
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
border-radius: 16px;
overflow: hidden;
}
.genesis-block__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.25rem;
cursor: pointer;
background: none;
transition: background 0.15s;
}
.genesis-block__header:hover {
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
}
.genesis-block__header h3 {
color: var(--mood-accent) !important;
}
.genesis-block__header p {
color: var(--mood-text-muted) !important;
}
.genesis-block__icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
color: var(--mood-accent);
}
.genesis-block__body {
padding: 0 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.genesis-section {
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
}
.genesis-section__toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
cursor: pointer;
background: none;
border: none;
transition: background 0.15s;
}
.genesis-section__toggle:hover {
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
}
.genesis-section__title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin: 0;
}
.genesis-section__toggle .text-sm {
color: var(--mood-text-muted) !important;
}
.genesis-section__content {
padding: 0 0.75rem 0.75rem;
}
.genesis-card {
padding: 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-card .font-medium,
.genesis-card .text-xs,
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-card--tool {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--tool .text-xs {
color: var(--mood-text) !important;
}
.genesis-card--tool:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-card--forum {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--forum:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-accent);
text-decoration: none;
font-weight: 500;
}
.genesis-link:hover {
text-decoration: underline;
}
.genesis-contributor {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-contributor .font-semibold {
color: var(--mood-text) !important;
}
.genesis-contributor .text-xs:not(.font-semibold) {
color: var(--mood-text-muted) !important;
}
/* Status badges — palette-aware */
.genesis-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.genesis-status--reference {
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
color: var(--mood-accent);
}
.genesis-status--progress {
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
color: var(--mood-warning);
}
.genesis-status--rejected {
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
color: var(--mood-error);
}
.genesis-status--default {
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
color: var(--mood-text-muted);
}
/* Genesis-context text utilities */
.genesis-accent {
color: var(--mood-accent) !important;
}
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-text-muted {
color: var(--mood-text-muted) !important;
}
.genesis-muted-icon {
color: var(--mood-text-muted) !important;
}
/* Expand/collapse transition */
.genesis-expand-enter-active,
.genesis-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.genesis-expand-enter-from,
.genesis-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
/**
* Inertia slider — displays the inertia preset level for a section.
* Read-only indicator (voting on the preset uses the standard vote flow).
* In full mode: shows formula diagram with simplified curve visualization.
*/
const props = withDefaults(defineProps<{
preset: string
compact?: boolean
mini?: boolean
}>(), {
compact: false,
mini: false,
})
interface InertiaLevel {
label: string
gradient: number
majority: number
color: string
position: number // 0-100 for slider position
description: string
}
const LEVELS: Record<string, InertiaLevel> = {
low: {
label: 'Remplacement facile',
gradient: 0.1,
majority: 50,
color: '#22c55e',
position: 10,
description: 'Majorité simple suffit, même à faible participation',
},
standard: {
label: 'Inertie pour le remplacement',
gradient: 0.2,
majority: 50,
color: '#3b82f6',
position: 37,
description: 'Équilibre : consensus croissant avec la participation',
},
high: {
label: 'Remplacement difficile',
gradient: 0.4,
majority: 60,
color: '#f59e0b',
position: 63,
description: 'Forte mobilisation et super-majorité requises',
},
very_high: {
label: 'Remplacement très difficile',
gradient: 0.6,
majority: 66,
color: '#ef4444',
position: 90,
description: 'Quasi-unanimité requise à toute participation',
},
}
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
// Generate SVG curve points for the inertia function
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
const curvePath = computed(() => {
const G = level.value.gradient
const M = level.value.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps // T/W ratio 0..1
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return `M ${points.join(' L ')}`
})
// The 4 curve paths for the diagram overlay
const allCurves = computed(() => {
return Object.entries(LEVELS).map(([key, lvl]) => {
const G = lvl.gradient
const M = lvl.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return {
key,
color: lvl.color,
path: `M ${points.join(' L ')}`,
active: key === props.preset,
}
})
})
</script>
<template>
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
<!-- Slider track -->
<div class="inertia__track">
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
<div
class="inertia__thumb"
:style="{ left: `${level.position}%`, borderColor: level.color }"
/>
<!-- Level marks -->
<div
v-for="(lvl, key) in LEVELS"
:key="key"
class="inertia__mark"
:class="{ 'inertia__mark--active': key === preset }"
:style="{ left: `${lvl.position}%` }"
/>
</div>
<!-- Label row -->
<div v-if="mini" class="inertia__info">
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
Inertie
</span>
</div>
<div v-else class="inertia__info">
<span class="inertia__label" :style="{ color: level.color }">
{{ level.label }}
</span>
<span v-if="!compact" class="inertia__params">
G={{ level.gradient }} M={{ level.majority }}%
</span>
</div>
<!-- Description (not in compact mode) -->
<p v-if="!compact" class="inertia__desc">
{{ level.description }}
</p>
<!-- Formula diagram (not in compact mode) -->
<div v-if="!compact" class="inertia__diagram">
<svg viewBox="0 0 220 100" class="inertia__svg">
<!-- Grid -->
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
<!-- Grid lines -->
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
<!-- Majority line M -->
<line
x1="30"
:y1="10 + (1 - level.majority / 100) * 70"
x2="200"
:y2="10 + (1 - level.majority / 100) * 70"
class="inertia__majority-line"
/>
<text
x="203"
:y="13 + (1 - level.majority / 100) * 70"
class="inertia__axis-label"
style="fill: var(--mood-accent)"
>M={{ level.majority }}%</text>
<!-- Background curves (ghosted) -->
<path
v-for="curve in allCurves"
:key="curve.key"
:d="curve.path"
fill="none"
:stroke="curve.color"
:stroke-width="curve.active ? 0 : 1"
:opacity="curve.active ? 0 : 0.15"
stroke-dasharray="3 3"
/>
<!-- Active curve -->
<path
:d="curvePath"
fill="none"
:stroke="level.color"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- Axis labels -->
<text x="15" y="14" class="inertia__axis-label">100%</text>
<text x="15" y="49" class="inertia__axis-label">50%</text>
<text x="15" y="84" class="inertia__axis-label">0%</text>
<text x="28" y="95" class="inertia__axis-label">0%</text>
<text x="105" y="95" class="inertia__axis-label">50%</text>
<text x="185" y="95" class="inertia__axis-label">100%</text>
<!-- Axis titles -->
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
</svg>
<!-- Simplified formula -->
<div class="inertia__formula">
<span class="inertia__formula-label">Formule :</span>
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
</div>
<div class="inertia__formula-legend">
<span><strong>T</strong> = votes exprimés</span>
<span><strong>W</strong> = taille WoT</span>
<span><strong>M</strong> = majorité cible</span>
<span><strong>G</strong> = gradient d'inertie</span>
</div>
</div>
</div>
</template>
<style scoped>
.inertia {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.inertia--compact {
gap: 0.25rem;
width: fit-content;
}
.inertia--mini {
gap: 0.125rem;
width: fit-content;
min-width: 3rem;
}
.inertia--mini .inertia__track {
height: 3px;
}
.inertia--mini .inertia__thumb {
width: 8px;
height: 8px;
border-width: 2px;
}
.inertia__track {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
}
.inertia--compact .inertia__track {
height: 4px;
}
.inertia__fill {
position: absolute;
inset: 0;
right: auto;
border-radius: 3px;
transition: width 0.3s ease;
}
.inertia__thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--mood-bg);
border: 3px solid;
transition: left 0.3s ease;
z-index: 2;
}
.inertia--compact .inertia__thumb {
width: 10px;
height: 10px;
border-width: 2px;
}
.inertia__mark {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
z-index: 1;
}
.inertia__mark--active {
background: transparent;
}
.inertia__info {
display: flex;
align-items: center;
justify-content: space-between;
}
.inertia__label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia--compact .inertia__label {
font-size: 0.625rem;
}
.inertia__label--mini {
font-size: 0.5625rem;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.inertia__params {
font-size: 0.625rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.inertia__desc {
font-size: 0.6875rem;
color: var(--mood-text-muted);
line-height: 1.3;
}
/* Diagram */
.inertia__diagram {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inertia__svg {
width: 100%;
max-width: 320px;
height: auto;
}
.inertia__axis {
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
stroke-width: 1;
}
.inertia__grid {
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
stroke-width: 0.5;
stroke-dasharray: 2 4;
}
.inertia__majority-line {
stroke: var(--mood-accent);
stroke-width: 0.75;
stroke-dasharray: 4 3;
opacity: 0.5;
}
.inertia__axis-label {
font-size: 5px;
fill: var(--mood-text-muted);
font-family: monospace;
}
.inertia__axis-title {
font-size: 5px;
fill: var(--mood-text-muted);
font-weight: 600;
}
.inertia__formula {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.inertia__formula-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.inertia__formula-code {
font-size: 0.6875rem;
font-family: monospace;
color: var(--mood-text);
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
}
.inertia__formula-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.5625rem;
color: var(--mood-text-muted);
}
.inertia__formula-legend strong {
color: var(--mood-text);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* MiniVoteBoard — compact inline vote status for an engagement item.
*
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
* Uses mock data when no vote session is linked (dev mode).
*/
import { useVoteFormula } from '~/composables/useVoteFormula'
const props = withDefaults(defineProps<{
votesFor?: number
votesAgainst?: number
wotSize?: number
isPermanent?: boolean
inertiaPreset?: string
startsAt?: string | null
endsAt?: string | null
}>(), {
votesFor: 0,
votesAgainst: 0,
wotSize: 7224,
isPermanent: true,
inertiaPreset: 'standard',
startsAt: null,
endsAt: null,
})
const { computeThreshold } = useVoteFormula()
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
}
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
const threshold = computed(() => {
if (totalVotes.value === 0) return 1
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
})
const isPassing = computed(() => props.votesFor >= threshold.value)
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
const thresholdPct = computed(() => {
if (totalVotes.value === 0) return 50
return Math.min((threshold.value / totalVotes.value) * 100, 100)
})
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
const remaining = computed(() => {
const diff = threshold.value - props.votesFor
return diff > 0 ? diff : 0
})
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<div class="mini-board">
<!-- Vote type + status on same line -->
<div class="mini-board__header">
<div class="flex items-center gap-2 flex-wrap">
<template v-if="isPermanent">
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
</template>
<template v-else>
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
</span>
</template>
<UBadge
:color="isPassing ? 'success' : 'warning'"
:variant="isPassing ? 'solid' : 'subtle'"
size="xs"
>
{{ isPassing ? 'Adopté' : 'En attente' }}
</UBadge>
</div>
</div>
<!-- Progress bar -->
<div class="mini-board__bar">
<div
class="mini-board__bar-for"
:style="{ width: `${forPct}%` }"
/>
<div
class="mini-board__bar-against"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="mini-board__bar-threshold"
:style="{ left: `${thresholdPct}%` }"
/>
</div>
<!-- Stats row -->
<div class="mini-board__stats">
<div class="flex items-center gap-3">
<span class="mini-board__stat mini-board__stat--for">
{{ votesFor }} pour
</span>
<span class="mini-board__stat mini-board__stat--against">
{{ votesAgainst }} contre
</span>
</div>
<div class="flex items-center gap-3">
<span class="mini-board__stat">
{{ votesFor }}/{{ threshold }} requis
</span>
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Participation -->
<div class="mini-board__footer">
<span class="text-xs" style="color: var(--mood-text-muted)">
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
</div>
</div>
</template>
<style scoped>
.mini-board {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
}
.mini-board__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mini-board__bar {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
overflow: visible;
}
.mini-board__bar-for {
position: absolute;
inset: 0;
right: auto;
background: #22c55e;
border-radius: 3px 0 0 3px;
transition: width 0.4s ease;
}
.mini-board__bar-against {
position: absolute;
inset: 0;
right: auto;
background: #ef4444;
transition: width 0.4s ease, left 0.4s ease;
}
.mini-board__bar-threshold {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
background: #facc15;
border-radius: 1px;
transform: translateX(-50%);
transition: left 0.4s ease;
z-index: 2;
}
.mini-board__stats {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.25rem;
}
.mini-board__stat {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.mini-board__stat--for {
color: #22c55e;
}
.mini-board__stat--against {
color: #ef4444;
}
.mini-board__stat--remaining {
color: #f59e0b;
}
.mini-board__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,659 @@
<script setup lang="ts">
/**
* ContextMapper — Recommandeur de méthode de décision.
* 4 questions de contexte → méthode optimale + justification.
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
*/
interface Option { value: string; label: string; icon: string }
interface Question { id: string; question: string; hint?: string; options: Option[] }
interface MethodRec {
name: string
icon: string
tag: string
tagColor: string
description: string
formula?: string
when: string
pros: string[]
cons: string[]
}
const questions: Question[] = [
{
id: 'urgency',
question: 'Quelle est l\'urgence ?',
hint: 'Le délai disponible avant que la décision soit nécessaire',
options: [
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
],
},
{
id: 'stakes',
question: 'Quel est l\'enjeu ?',
hint: 'L\'impact et la réversibilité de la décision',
options: [
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
],
},
{
id: 'groupSize',
question: 'Taille du groupe ?',
hint: 'Nombre de personnes concernées ou habilitées à voter',
options: [
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
{ value: 'medium', label: '10 100', icon: 'i-lucide-users' },
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
],
},
{
id: 'nature',
question: 'Nature de la décision ?',
hint: 'Le type de compétence principalement sollicité',
options: [
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
],
},
]
const answers = ref<Record<string, string>>({})
const step = ref(0)
const animating = ref(false)
const currentQuestion = computed(() => questions[step.value])
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
const progress = computed(() => (step.value / questions.length) * 100)
function selectAnswer(questionId: string, value: string) {
answers.value = { ...answers.value, [questionId]: value }
if (step.value < questions.length - 1) {
animating.value = true
setTimeout(() => {
step.value++
animating.value = false
}, 160)
}
}
function goBack() {
if (step.value > 0) step.value--
}
function reset() {
answers.value = {}
step.value = 0
}
const recommendation = computed((): MethodRec | null => {
if (!isComplete.value) return null
const { urgency, stakes, groupSize, nature } = answers.value
// Immediate → Advice process (Laloux)
if (urgency === 'immediate') {
return {
name: 'Processus de sollicitation d\'avis',
icon: 'i-lucide-message-circle',
tag: 'Laloux / Teal',
tagColor: 'teal',
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
}
}
// Technical + medium/large → Smith WoT
if (nature === 'technical' && groupSize !== 'small') {
return {
name: 'Vote inertiel WoT + critère Smith',
icon: 'i-lucide-network',
tag: 'G1 standard',
tagColor: 'accent',
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)\nSeuil Smith : ⌈SmithWoT^S⌉',
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
}
}
// Irreversible + large → High threshold WoT
if (stakes === 'irreversible' && groupSize === 'large') {
return {
name: 'Vote inertiel WoT (inertie forte)',
icon: 'i-lucide-shield',
tag: 'G1 renforcé',
tagColor: 'secondary',
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)\nParamètres : M=67%, G=0.3 (inertie forte)',
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
}
}
// Small group → Sociocratic consent
if (groupSize === 'small') {
return {
name: 'Consentement sociocratique',
icon: 'i-lucide-check-circle-2',
tag: 'Sociocracie',
tagColor: 'tertiary',
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
}
}
// Political + medium → WoT majority
if (nature === 'political') {
return {
name: 'Vote majoritaire WoT',
icon: 'i-lucide-vote',
tag: 'G1 standard',
tagColor: 'accent',
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)',
when: 'Décision politique communautaire, participation variable, groupe >10.',
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
cons: ['Durée 7-30j', 'Participation faible possible'],
}
}
// Default: minor/operational
return {
name: 'Advice process + validation légère',
icon: 'i-lucide-thumbs-up',
tag: 'Léger',
tagColor: 'teal',
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
formula: 'Consultation → Décision → Notification (sans vote formel)',
when: 'Décision opérationnelle de faible impact, facilement réversible.',
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
}
})
const emit = defineEmits<{ use: [name: string] }>()
</script>
<template>
<div class="cmap">
<!-- Header -->
<div class="cmap__head">
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
<div>
<h3 class="cmap__title">Choisir une méthode</h3>
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
</div>
</div>
<!-- Result -->
<Transition name="fade-up" mode="out-in">
<div v-if="isComplete" key="result" class="cmap__result">
<div class="cmap__result-header">
<div class="cmap__result-icon">
<UIcon :name="recommendation!.icon" />
</div>
<div class="cmap__result-info">
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
{{ recommendation!.tag }}
</span>
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
</div>
</div>
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
<div v-if="recommendation!.formula" class="cmap__formula">
<span class="cmap__formula-label">Formule</span>
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
</div>
<div class="cmap__pros-cons">
<div>
<span class="cmap__pros-label">Pour</span>
<ul class="cmap__list cmap__list--pro">
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
</ul>
</div>
<div>
<span class="cmap__cons-label">Contre</span>
<ul class="cmap__list cmap__list--con">
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
</ul>
</div>
</div>
<p class="cmap__when">
<UIcon name="i-lucide-lightbulb" />
{{ recommendation!.when }}
</p>
<div class="cmap__result-actions">
<button class="cmap__btn-reset" @click="reset">
<UIcon name="i-lucide-refresh-cw" />
Recommencer
</button>
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
<UIcon name="i-lucide-play" />
Utiliser cette méthode
</button>
</div>
</div>
<!-- Quiz -->
<div v-else key="quiz" class="cmap__quiz">
<!-- Progress -->
<div class="cmap__progress">
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
</div>
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
<!-- Question -->
<Transition name="slide-right" mode="out-in">
<div :key="step" class="cmap__question-block">
<p class="cmap__question">{{ currentQuestion.question }}</p>
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
<div class="cmap__options">
<button
v-for="opt in currentQuestion.options"
:key="opt.value"
class="cmap__option"
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
@click="selectAnswer(currentQuestion.id, opt.value)"
>
<div class="cmap__option-icon">
<UIcon :name="opt.icon" />
</div>
<span class="cmap__option-label">{{ opt.label }}</span>
</button>
</div>
</div>
</Transition>
<button v-if="step > 0" class="cmap__back" @click="goBack">
<UIcon name="i-lucide-chevron-left" />
Retour
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.cmap {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cmap__head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.cmap__head-icon {
font-size: 1.375rem;
color: var(--mood-accent);
flex-shrink: 0;
margin-top: 0.125rem;
}
.cmap__title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.cmap__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
}
/* Progress */
.cmap__progress {
height: 4px;
background: var(--mood-accent-soft);
border-radius: 4px;
overflow: hidden;
}
.cmap__progress-bar {
height: 100%;
background: var(--mood-accent);
border-radius: 4px;
transition: width 0.3s ease;
}
.cmap__step-label {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Question */
.cmap__question-block {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cmap__question {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
line-height: 1.4;
}
.cmap__hint {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.cmap__options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cmap__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
text-align: left;
min-height: 2.75rem;
}
.cmap__option:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
.cmap__option:active { transform: translateY(0); }
.cmap__option--selected {
background: var(--mood-accent);
}
.cmap__option--selected .cmap__option-icon,
.cmap__option--selected .cmap__option-label {
color: var(--mood-accent-text);
}
.cmap__option-icon {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
flex-shrink: 0;
font-size: 0.875rem;
}
.cmap__option--selected .cmap__option-icon {
background: rgba(255,255,255,0.2);
color: var(--mood-accent-text);
}
.cmap__option-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
.cmap__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: pointer;
padding: 0.375rem 0;
transition: color 0.1s ease;
}
.cmap__back:hover { color: var(--mood-text); }
/* Result */
.cmap__result {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.cmap__result-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.cmap__result-icon {
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.125rem;
}
.cmap__result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__result-tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 20px;
width: fit-content;
}
.cmap__result-tag--accent {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.cmap__result-tag--teal {
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.cmap__result-tag--secondary {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.cmap__result-tag--tertiary {
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
}
.cmap__result-name {
font-size: 0.9375rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
line-height: 1.3;
}
.cmap__result-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.cmap__formula {
background: var(--mood-accent-soft);
border-radius: 10px;
padding: 0.625rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__formula-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
}
.cmap__formula-code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
color: var(--mood-text);
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}
.cmap__pros-cons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.cmap__pros-label,
.cmap__cons-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.25rem;
}
.cmap__pros-label { color: var(--mood-success); }
.cmap__cons-label { color: var(--mood-error); }
.cmap__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__list li {
font-size: 0.6875rem;
color: var(--mood-text-muted);
padding-left: 0.875rem;
position: relative;
line-height: 1.4;
}
.cmap__list--pro li::before {
content: '✓';
position: absolute;
left: 0;
color: var(--mood-success);
font-weight: 700;
font-size: 0.5rem;
top: 0.2em;
}
.cmap__list--con li::before {
content: '·';
position: absolute;
left: 0;
color: var(--mood-error);
font-weight: 700;
}
.cmap__when {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
font-style: italic;
}
.cmap__result-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.cmap__btn-reset {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease;
}
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
.cmap__btn-use {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.125rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.cmap__btn-use:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* Transitions */
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
</style>

View File

@@ -0,0 +1,666 @@
<script setup lang="ts">
/**
* SocioElection — Guide processus d'élection sociocratique.
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
*/
interface Step {
num: number
title: string
actor: string
duration: string
icon: string
description: string
tips: string[]
pitfall?: string
}
const steps: Step[] = [
{
num: 1,
title: 'Clarifier le rôle',
actor: 'Facilitateur + cercle',
duration: '10-15 min',
icon: 'i-lucide-clipboard-list',
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
tips: [
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
'Fixer une durée standard (ex: 1 an renouvelable)',
'Identifier les compétences nécessaires — pas souhaitables',
],
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
},
{
num: 2,
title: 'Nommer en silence',
actor: 'Tous les membres',
duration: '3-5 min',
icon: 'i-lucide-pencil',
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
tips: [
'Pas de discussion pendant cette étape',
'S\'auto-nommer est bienvenu et valorisé',
'Une seule nomination par personne',
],
},
{
num: 3,
title: 'Recueillir les nominations',
actor: 'Facilitateur',
duration: '5-10 min',
icon: 'i-lucide-list-checks',
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
tips: [
'Lire nom + raison tels qu\'écrits',
'Le facilitateur lit aussi sa propre nomination',
'Compter et afficher les nominations',
],
},
{
num: 4,
title: 'Argumenter',
actor: 'Chaque membre',
duration: '1-2 min / personne',
icon: 'i-lucide-message-square',
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
tips: [
'1 minute maximum par personne',
'Argumenter pour, pas contre',
'Les candidats s\'expriment aussi brièvement',
],
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
},
{
num: 5,
title: 'Lever les objections',
actor: 'Facilitateur + cercle',
duration: '5-15 min',
icon: 'i-lucide-shield-check',
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
tips: [
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
'Une objection peut mener à reconsidérer une candidature',
'L\'élu·e peut décliner — c\'est légitime',
],
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
},
{
num: 6,
title: 'Célébrer',
actor: 'Tous',
duration: '2-3 min',
icon: 'i-lucide-star',
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
tips: [
'Documenter l\'élection (date, durée, personnes présentes)',
'Annoncer à la communauté au sens large',
'Fixer la prochaine évaluation du rôle',
],
},
]
const expandedStep = ref<number | null>(null)
function toggleStep(num: number) {
expandedStep.value = expandedStep.value === num ? null : num
}
// Advice process (Laloux)
const adviceSteps = [
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
]
// Role clarity framework
interface RoleAxis {
label: string
icon: string
question: string
example: string
}
const roleAxes: RoleAxis[] = [
{
label: 'Mission',
icon: 'i-lucide-target',
question: 'Pourquoi ce rôle existe-t-il ?',
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
},
{
label: 'Domaine',
icon: 'i-lucide-shield',
question: 'Sur quoi a-t-il autorité exclusive ?',
example: 'Configuration des serveurs de forge, rotation des clés',
},
{
label: 'Redevabilités',
icon: 'i-lucide-check-square',
question: 'Quelles activités doit-il assurer ?',
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
},
{
label: 'Durée',
icon: 'i-lucide-calendar',
question: 'Pour combien de temps ?',
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
},
]
const activeTab = ref<'election' | 'advice' | 'role'>('election')
</script>
<template>
<div class="se">
<!-- Tabs -->
<div class="se__tabs">
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'election' }"
@click="activeTab = 'election'"
>
<UIcon name="i-lucide-users" />
Élection
</button>
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'advice' }"
@click="activeTab = 'advice'"
>
<UIcon name="i-lucide-message-circle" />
Conseil
</button>
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'role' }"
@click="activeTab = 'role'"
>
<UIcon name="i-lucide-clipboard-list" />
Rôle
</button>
</div>
<!-- Election sociocratique -->
<div v-if="activeTab === 'election'" class="se__panel">
<p class="se__intro">
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
et le consentement collectif — pas sur la popularité.
</p>
<div class="se__steps">
<div
v-for="s in steps"
:key="s.num"
class="se__step"
:class="{ 'se__step--open': expandedStep === s.num }"
>
<button class="se__step-head" @click="toggleStep(s.num)">
<div class="se__step-num">{{ s.num }}</div>
<div class="se__step-icon">
<UIcon :name="s.icon" />
</div>
<div class="se__step-info">
<span class="se__step-title">{{ s.title }}</span>
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
</div>
<UIcon
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="se__step-toggle"
/>
</button>
<Transition name="expand">
<div v-if="expandedStep === s.num" class="se__step-body">
<p class="se__step-desc">{{ s.description }}</p>
<ul class="se__step-tips">
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
</ul>
<div v-if="s.pitfall" class="se__step-pitfall">
<UIcon name="i-lucide-alert-triangle" />
{{ s.pitfall }}
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Advice process -->
<div v-if="activeTab === 'advice'" class="se__panel">
<div class="se__advice-header">
<span class="se__advice-tag">Laloux / Teal</span>
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
<p class="se__advice-subtitle">
Toute personne peut prendre une décision à condition d'avoir d'abord
consulté les experts et les impactés.
</p>
</div>
<div class="se__advice-steps">
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
<div class="se__advice-dot">
<UIcon :name="as.icon" />
</div>
<span class="se__advice-text">{{ as.text }}</span>
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
</div>
</div>
<div class="se__advice-rule">
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
<div>
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
consulter largement. Mais la décision finale appartient toujours à celui ou
celle qui l'a initiée.
</div>
</div>
<div class="se__advice-when">
<div class="se__advice-when-item se__advice-when-item--yes">
<span class="se__advice-when-label">Adapter pour</span>
<ul>
<li>Décisions urgentes</li>
<li>Rôles bien définis</li>
<li>Culture de confiance</li>
</ul>
</div>
<div class="se__advice-when-item se__advice-when-item--no">
<span class="se__advice-when-label">Éviter si</span>
<ul>
<li>Décision irréversible</li>
<li>Groupe > 100 personnes</li>
<li>Enjeu fondateur</li>
</ul>
</div>
</div>
</div>
<!-- Role clarity -->
<div v-if="activeTab === 'role'" class="se__panel">
<p class="se__intro">
Un rôle bien défini évite les zones grises, les conflits d'autorité
et les mandats flous. Quatre axes suffisent.
</p>
<div class="se__role-axes">
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
<div class="se__role-axis-icon">
<UIcon :name="axis.icon" />
</div>
<div class="se__role-axis-body">
<span class="se__role-axis-label">{{ axis.label }}</span>
<p class="se__role-axis-question">{{ axis.question }}</p>
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
</div>
</div>
</div>
<div class="se__role-tip">
<UIcon name="i-lucide-info" />
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
de gouvernance sans changer la personne qui le tient.</span>
</div>
</div>
</div>
</template>
<style scoped>
.se { display: flex; flex-direction: column; gap: 1rem; }
/* Tabs */
.se__tabs {
display: flex;
gap: 0.25rem;
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 3px;
}
.se__tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-text-muted);
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.se__tab--active {
background: var(--mood-surface);
color: var(--mood-accent);
box-shadow: 0 1px 4px var(--mood-shadow);
}
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
.se__intro {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
/* Steps */
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
.se__step {
background: var(--mood-accent-soft);
border-radius: 12px;
overflow: hidden;
}
.se__step--open { background: var(--mood-surface); }
.se__step-head {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.75rem 0.875rem;
cursor: pointer;
text-align: left;
background: none;
}
.se__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
}
.se__step-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
font-size: 0.875rem;
}
.se__step-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.se__step-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.se__step-meta {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.se__step-toggle {
color: var(--mood-text-muted);
font-size: 0.875rem;
flex-shrink: 0;
}
.se__step-body {
padding: 0 0.875rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.se__step-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.se__step-tips {
margin: 0;
padding: 0 0 0 1rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
list-style-type: disc;
line-height: 1.6;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.se__step-tips li::marker { color: var(--mood-accent); }
.se__step-pitfall {
display: flex;
align-items: flex-start;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
border-radius: 8px;
font-size: 0.6875rem;
color: var(--mood-error);
line-height: 1.5;
}
/* Advice */
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
.se__advice-tag {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
width: fit-content;
}
.se__advice-title {
font-size: 0.9375rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.se__advice-subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
.se__advice-step {
display: flex;
align-items: flex-start;
gap: 0.625rem;
position: relative;
padding: 0.5rem 0;
}
.se__advice-dot {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
z-index: 1;
}
.se__advice-text {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
padding-top: 0.375rem;
line-height: 1.4;
}
.se__advice-line {
position: absolute;
left: calc(1rem - 1px);
top: calc(0.5rem + 2rem);
width: 2px;
height: calc(100% - 2rem + 0.5rem);
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
}
.se__advice-rule {
display: flex;
gap: 0.625rem;
align-items: flex-start;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
.se__advice-rule strong { color: var(--mood-text); }
.se__advice-when {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.se__advice-when-item {
padding: 0.625rem;
border-radius: 10px;
font-size: 0.75rem;
}
.se__advice-when-item--yes {
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
}
.se__advice-when-item--no {
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
}
.se__advice-when-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.25rem;
}
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
.se__advice-when-item ul {
margin: 0;
padding: 0 0 0 0.875rem;
color: var(--mood-text-muted);
list-style-type: disc;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* Role */
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
.se__role-axis {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 12px;
}
.se__role-axis-icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
font-size: 0.875rem;
}
.se__role-axis-body { flex: 1; min-width: 0; }
.se__role-axis-label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin-bottom: 0.125rem;
}
.se__role-axis-question {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
margin: 0;
}
.se__role-axis-example {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin: 0.125rem 0 0;
line-height: 1.4;
font-style: italic;
}
.se__role-tip {
display: flex;
gap: 0.5rem;
align-items: flex-start;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
}
/* Expand transition */
.expand-enter-active, .expand-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
max-height: 0;
opacity: 0;
}
.expand-enter-to, .expand-leave-from {
max-height: 500px;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
* Toggle le contenu pour économiser la hauteur visible.
*/
const props = withDefaults(
defineProps<{
title: string
icon?: string
defaultOpen?: boolean
}>(),
{
icon: undefined,
defaultOpen: false,
},
)
const open = ref(props.defaultOpen)
</script>
<template>
<div class="tsection" :class="{ 'tsection--open': open }">
<button class="tsection__header" @click="open = !open">
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
<span class="tsection__title">{{ title }}</span>
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
</button>
<div v-show="open" class="tsection__body">
<slot />
</div>
</div>
</template>
<style scoped>
.tsection {
background: var(--mood-accent-soft);
border-radius: 14px;
overflow: hidden;
}
.tsection__header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 0.875rem;
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.tsection__header:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
}
.tsection__icon {
font-size: 0.9375rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.tsection__title {
flex: 1;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tsection__chevron {
font-size: 0.875rem;
color: var(--mood-accent);
opacity: 0.6;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tsection--open .tsection__chevron {
transform: rotate(180deg);
}
.tsection__body {
padding: 0 0.875rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
</style>

View File

@@ -0,0 +1,551 @@
<script setup lang="ts">
/**
* WorkflowMilestones — 11 jalons de protocole de fonctionnement.
* Sélectif et qualitatif : ce qui fait la différence entre un protocole
* qui tient et un qui dérive.
* Référence : g1vote, sociocracie, Laloux, Elinor Ostrom (gouvernance des communs).
*/
interface Milestone {
num: number
name: string
icon: string
actor: string
duration: { min: string; standard: string; major: string }
description: string
essential: boolean
tip?: string
ostrom?: string
}
const milestones: Milestone[] = [
{
num: 1,
name: 'Prise d\'initiative',
icon: 'i-lucide-lightbulb',
actor: 'Tout membre',
duration: { min: '—', standard: '1-2j', major: '1-2j' },
description: 'Formaliser l\'intention : quel problème, quel besoin, quelle cible visée. Nommer un·e porteur·euse responsable.',
essential: true,
tip: 'Une initiative sans porteur identifié ne décolle pas. La responsabilité individuelle est le premier jalon.',
ostrom: 'Principe 1 — Frontières claires : qui est concerné, pourquoi.',
},
{
num: 2,
name: 'Processus d\'avis (advice)',
icon: 'i-lucide-message-circle',
actor: 'Porteur + experts + impactés',
duration: { min: '1j', standard: '3-7j', major: '7-14j' },
description: 'Consulter les personnes qui ont l\'expertise ET celles qui seront impactées. Écouter vraiment, intégrer ou expliquer pourquoi on n\'intègre pas.',
essential: true,
tip: 'Ce jalon est souvent escamoté. C\'est la principale cause d\'échec ou de résistance en implémentation.',
ostrom: 'Principe 5 — Résolution des conflits accessible et peu coûteuse.',
},
{
num: 3,
name: 'Rédaction + amendements',
icon: 'i-lucide-file-edit',
actor: 'Porteur + communauté',
duration: { min: '1-2j', standard: '3-7j', major: '7-21j' },
description: 'Rédiger la proposition formelle. Ouvrir une période d\'amendements publics. Intégrer les modifications acceptées, rejeter les autres avec justification.',
essential: true,
tip: 'Distinguer amendements substantiels (re-vote possible) et de forme (porteur décide).',
},
{
num: 4,
name: 'Qualification technique',
icon: 'i-lucide-shield-check',
actor: 'Comité technique (si applicable)',
duration: { min: '—', standard: '2-5j', major: '5-10j' },
description: 'Pour les décisions techniques : revue par les experts désignés. Évaluation de faisabilité, risques, impact. Avis formel (non bloquant, sauf veto défini).',
essential: false,
tip: 'Optionnel selon la nature de la décision. Systématique pour les Runtime Upgrades.',
},
{
num: 5,
name: 'Ouverture du vote',
icon: 'i-lucide-vote',
actor: 'Porteur + plateforme',
duration: { min: '—', standard: '1j', major: '1j' },
description: 'Publier la proposition finale. Notifier la communauté. Ouvrir la session de vote avec les paramètres définis (protocole, formule, durée).',
essential: true,
tip: 'L\'ouverture doit être annoncée à l\'avance (délai de préavis selon règlement).',
},
{
num: 6,
name: 'Phase de vote',
icon: 'i-lucide-bar-chart-2',
actor: 'Membres habilités',
duration: { min: '3j', standard: '7-14j', major: '21-30j' },
description: 'Les membres habilités votent selon le protocole. Seuil de participation minimal surveillé. Résultats intermédiaires visibles (ou non, selon le protocole).',
essential: true,
ostrom: 'Principe 3 — Choix collectifs : ceux qui sont concernés participent aux décisions.',
},
{
num: 7,
name: 'Contrôle du quorum',
icon: 'i-lucide-check-circle',
actor: 'Plateforme + porteur',
duration: { min: '—', standard: '—', major: '—' },
description: 'Vérifier que le quorum minimum est atteint avant clôture. Si non atteint : prolonger, relancer, ou annuler selon les règles préétablies.',
essential: true,
tip: 'Définir à l\'avance le quorum et la procédure si non atteint — évite les ambiguïtés.',
ostrom: 'Principe 4 — Supervision des règles par les membres.',
},
{
num: 8,
name: 'Proclamation des résultats',
icon: 'i-lucide-megaphone',
actor: 'Plateforme + porteur',
duration: { min: '—', standard: '1j', major: '1j' },
description: 'Annoncer le résultat officiel avec les chiffres détaillés (votes pour, contre, abstentions, taux participation, seuil requis). Archiver on-chain si adopté.',
essential: true,
tip: 'La transparence des résultats est aussi importante que le résultat lui-même.',
ostrom: 'Principe 8 — Gouvernance emboîtée : résultats remontés aux niveaux supérieurs.',
},
{
num: 9,
name: 'Mise en application',
icon: 'i-lucide-play-circle',
actor: 'Porteur + implémenteurs',
duration: { min: '—', standard: 'Variable', major: 'Variable' },
description: 'Planifier l\'application effective de la décision. Désigner les responsables. Fixer des jalons d\'implémentation si complexe.',
essential: true,
tip: 'Une décision adoptée mais non implémentée érode la confiance dans le processus.',
},
{
num: 10,
name: 'Suivi et accountability',
icon: 'i-lucide-activity',
actor: 'Porteur + communauté',
duration: { min: '—', standard: 'Continu', major: 'Continu' },
description: 'Rapports réguliers sur l\'avancement. Signalement des écarts. Mécanisme de remontée si la décision produit des effets inattendus.',
essential: false,
tip: 'Intégrer dans le prochain cycle de gouvernance si des ajustements s\'imposent.',
ostrom: 'Principe 4 — Surveillance continue des comportements et résultats.',
},
{
num: 11,
name: 'Rétrospective',
icon: 'i-lucide-rotate-ccw',
actor: 'Cercle concerné',
duration: { min: '—', standard: '1-2h', major: '1-2j' },
description: 'Évaluer : le processus a-t-il bien fonctionné ? La décision produit-elle les effets attendus ? Quoi améliorer pour la prochaine fois ?',
essential: false,
tip: 'La rétrospective est le moteur d\'amélioration du protocole lui-même (méta-gouvernance).',
ostrom: 'Principe 7 — Reconnaissance externe de l\'organisation par des autorités supérieures.',
},
]
const showOstrom = ref(false)
const activeDecisionType = ref<'minor' | 'standard' | 'major'>('standard')
const decisionTypes = [
{ value: 'minor', label: 'Mineur', color: 'teal' },
{ value: 'standard', label: 'Standard', color: 'accent' },
{ value: 'major', label: 'Majeur', color: 'secondary' },
]
const essentialMilestones = computed(() =>
milestones.filter(m => m.essential),
)
const optionalMilestones = computed(() =>
milestones.filter(m => !m.essential),
)
const totalDuration = computed(() => {
const type = activeDecisionType.value
const durations = {
minor: '5-10 jours',
standard: '14-30 jours',
major: '45-90 jours',
}
return durations[type]
})
</script>
<template>
<div class="wm">
<!-- Header -->
<div class="wm__header">
<h3 class="wm__title">Jalons de protocole</h3>
<p class="wm__subtitle">
11 jalons, dont 7 indispensables. Durées recommandées selon le type de décision.
</p>
</div>
<!-- Decision type selector -->
<div class="wm__type-selector">
<button
v-for="dt in decisionTypes"
:key="dt.value"
class="wm__type-btn"
:class="[
`wm__type-btn--${dt.color}`,
{ 'wm__type-btn--active': activeDecisionType === dt.value },
]"
@click="activeDecisionType = dt.value as 'minor' | 'standard' | 'major'"
>
{{ dt.label }}
</button>
<span class="wm__total-duration"> {{ totalDuration }}</span>
</div>
<!-- Essential milestones -->
<div class="wm__section">
<div class="wm__section-label">
<span class="wm__section-badge wm__section-badge--essential">7 essentiels</span>
</div>
<div class="wm__milestones">
<div
v-for="m in essentialMilestones"
:key="m.num"
class="wm__milestone wm__milestone--essential"
>
<div class="wm__milestone-left">
<div class="wm__milestone-num">{{ m.num }}</div>
<div v-if="m.num < milestones.length" class="wm__milestone-line" />
</div>
<div class="wm__milestone-icon">
<UIcon :name="m.icon" />
</div>
<div class="wm__milestone-body">
<div class="wm__milestone-head">
<span class="wm__milestone-name">{{ m.name }}</span>
<span class="wm__milestone-duration">
{{ m.duration[activeDecisionType] || '—' }}
</span>
</div>
<p class="wm__milestone-desc">{{ m.description }}</p>
<div v-if="m.tip" class="wm__milestone-tip">
<UIcon name="i-lucide-lightbulb" />
{{ m.tip }}
</div>
</div>
</div>
</div>
</div>
<!-- Optional milestones -->
<div class="wm__section">
<div class="wm__section-label">
<span class="wm__section-badge wm__section-badge--optional">4 contextuels</span>
</div>
<div class="wm__milestones">
<div
v-for="m in optionalMilestones"
:key="m.num"
class="wm__milestone wm__milestone--optional"
>
<div class="wm__milestone-left">
<div class="wm__milestone-num wm__milestone-num--optional">{{ m.num }}</div>
</div>
<div class="wm__milestone-icon wm__milestone-icon--optional">
<UIcon :name="m.icon" />
</div>
<div class="wm__milestone-body">
<div class="wm__milestone-head">
<span class="wm__milestone-name">{{ m.name }}</span>
<span class="wm__milestone-duration">
{{ m.duration[activeDecisionType] || '—' }}
</span>
</div>
<p class="wm__milestone-desc">{{ m.description }}</p>
</div>
</div>
</div>
</div>
<!-- Ostrom toggle -->
<button class="wm__ostrom-toggle" @click="showOstrom = !showOstrom">
<UIcon name="i-lucide-book-open" />
<span>Principes Ostrom appliqués</span>
<UIcon :name="showOstrom ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
</button>
<Transition name="expand">
<div v-if="showOstrom" class="wm__ostrom">
<p class="wm__ostrom-intro">
Elinor Ostrom (Nobel 2009) a identifié 8 principes pour la gouvernance
durable des communs. Les jalons ci-dessus les incarnent.
</p>
<div class="wm__ostrom-items">
<div
v-for="m in milestones.filter(x => x.ostrom)"
:key="m.num"
class="wm__ostrom-item"
>
<span class="wm__ostrom-jalon">Jalon {{ m.num }}</span>
<span class="wm__ostrom-text">{{ m.ostrom }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.wm { display: flex; flex-direction: column; gap: 1rem; }
.wm__header { display: flex; flex-direction: column; gap: 0.25rem; }
.wm__title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.wm__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
/* Type selector */
.wm__type-selector {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.wm__type-btn {
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 700;
border-radius: 20px;
cursor: pointer;
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
transition: all 0.12s ease;
}
.wm__type-btn--accent.wm__type-btn--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.wm__type-btn--teal.wm__type-btn--active {
background: color-mix(in srgb, var(--mood-success) 20%, transparent);
color: var(--mood-success);
}
.wm__type-btn--secondary.wm__type-btn--active {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.wm__total-duration {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
margin-left: auto;
}
/* Section */
.wm__section { display: flex; flex-direction: column; gap: 0.5rem; }
.wm__section-label { display: flex; align-items: center; gap: 0.5rem; }
.wm__section-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 10px;
border-radius: 20px;
}
.wm__section-badge--essential {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.wm__section-badge--optional {
background: color-mix(in srgb, var(--mood-text-muted) 12%, transparent);
color: var(--mood-text-muted);
}
/* Milestones */
.wm__milestones { display: flex; flex-direction: column; gap: 0; }
.wm__milestone {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0;
}
.wm__milestone-left {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 1.375rem;
}
.wm__milestone-num {
width: 1.375rem;
height: 1.375rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.625rem;
font-weight: 800;
flex-shrink: 0;
z-index: 1;
}
.wm__milestone-num--optional {
background: color-mix(in srgb, var(--mood-text-muted) 20%, transparent);
color: var(--mood-text-muted);
}
.wm__milestone-line {
width: 2px;
flex: 1;
min-height: 1.25rem;
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
margin-top: 2px;
}
.wm__milestone-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
}
.wm__milestone-icon--optional {
background: color-mix(in srgb, var(--mood-text-muted) 10%, transparent);
color: var(--mood-text-muted);
}
.wm__milestone-body {
flex: 1;
min-width: 0;
padding-bottom: 0.625rem;
}
.wm__milestone-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.wm__milestone-name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.wm__milestone--optional .wm__milestone-name {
color: var(--mood-text-muted);
}
.wm__milestone-duration {
font-size: 0.625rem;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 1px 6px;
border-radius: 20px;
}
.wm__milestone-desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
margin: 0.125rem 0 0;
}
.wm__milestone-tip {
display: flex;
align-items: flex-start;
gap: 0.375rem;
margin-top: 0.375rem;
padding: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
border-radius: 8px;
font-size: 0.6875rem;
color: var(--mood-accent);
line-height: 1.5;
}
/* Ostrom */
.wm__ostrom-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
transition: color 0.12s ease;
text-align: left;
}
.wm__ostrom-toggle:hover { color: var(--mood-text); }
.wm__ostrom-toggle .i-lucide-book-open { color: var(--mood-accent); }
.wm__ostrom {
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wm__ostrom-intro {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.wm__ostrom-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wm__ostrom-item {
display: flex;
gap: 0.625rem;
font-size: 0.75rem;
line-height: 1.5;
}
.wm__ostrom-jalon {
font-weight: 700;
color: var(--mood-accent);
white-space: nowrap;
flex-shrink: 0;
}
.wm__ostrom-text { color: var(--mood-text-muted); }
/* Expand transition */
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
.expand-enter-to, .expand-leave-from { max-height: 1000px; opacity: 1; }
</style>

View File

@@ -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.

View File

@@ -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 },

View File

@@ -3,12 +3,37 @@
* Decisions — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boite a outils" affichant les protocoles de vote.
* et sidebar "Boîte à outils" affichant les protocoles de vote.
*/
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
// Toolbox state
const showConsentModal = ref(false)
const selectedMethod = ref<string | null>(null)
const consentSteps = [
'Présenter la proposition clairement (2 min)',
'Tour de clarification — questions de compréhension uniquement',
'Tour de réaction — chacun réagit brièvement',
'Porteur amende si nécessaire',
'Tour d\'objections — silence = consentement',
'Lever les objections valides par amendement',
'Adopter ou reporter',
]
function handleMethodSelect(method: string) {
selectedMethod.value = method
if (method.toLowerCase().includes('consentement')) {
showConsentModal.value = true
}
else if (method.toLowerCase().includes('avis')) {
// Navigate to advice process guide in mandates toolbox
navigateTo('/mandates')
}
}
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
@@ -28,7 +53,7 @@ onMounted(async () => {
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'draft', label: 'En prépa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
@@ -97,8 +122,8 @@ function formatDate(dateStr: string): string {
<template>
<SectionLayout
title="Decisions"
subtitle="Processus de decision collectifs"
title="Décisions"
subtitle="Processus de décision collectifs"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
@@ -111,7 +136,7 @@ function formatDate(dateStr: string): string {
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher une decision..."
placeholder="Rechercher une décision..."
/>
</div>
<select v-model="sortBy" class="sort-select">
@@ -149,7 +174,7 @@ function formatDate(dateStr: string): string {
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
<p>Aucune decision trouvee</p>
<p>Aucune décision trouvée</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
@@ -179,40 +204,107 @@ function formatDate(dateStr: string): string {
<span class="decision-card__type-badge">
{{ typeLabel(decision.decision_type) }}
</span>
<span
v-if="decision.decision_type === 'runtime_upgrade'"
class="decision-card__onchain-badge"
>
<UIcon name="i-lucide-link" class="text-xs" />
on-chain
</span>
<span class="decision-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ decision.steps.length }} etape{{ decision.steps.length !== 1 ? 's' : '' }}
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
</span>
<span class="decision-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(decision.created_at) }}
</span>
</div>
<!-- Protocol link for runtime_upgrade -->
<NuxtLink
v-if="decision.decision_type === 'runtime_upgrade'"
to="/protocols"
class="decision-card__protocol-link"
@click.stop
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
<span>Protocole : Soumission Runtime Upgrade</span>
<UIcon name="i-lucide-arrow-right" class="text-xs" />
</NuxtLink>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
Modalites de vote
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:bullets="['Applicable aux decisions', protocol.mode_params || 'Configuration standard']"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
<!-- Context mapper -->
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
<ContextMapper @use="handleMethodSelect" />
</ToolboxSection>
<!-- Vote inertiel WoT -->
<ToolboxVignette
title="Vote inertiel WoT"
:bullets="[
'Seuil adaptatif à la participation',
'Faible participation → quasi-unanimité',
'Formule g1vote — tracé on-chain',
]"
:actions="[
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
]"
/>
<!-- Consentement sociocratique -->
<ToolboxVignette
title="Consentement sociocratique"
:bullets="[
'Aucune objection grave = adopté',
'Rapide pour petits groupes',
'Distingue préférence et objection',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
]"
/>
<!-- Advice process -->
<ToolboxVignette
title="Processus d'avis (Laloux)"
:bullets="[
'Décisions urgentes : < 2h',
'Consultant experts + impactés',
'Responsabilise le porteur',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- Modal consent guide -->
<UModal v-model:open="showConsentModal">
<template #content>
<div class="decision-modal">
<h3 class="decision-modal__title">Consentement sociocratique</h3>
<p class="decision-modal__text">
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune
pas une simple préférence.
</p>
<div class="decision-modal__steps">
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
<div class="decision-modal__step-num">{{ i + 1 }}</div>
<div class="decision-modal__step-text">{{ step }}</div>
</div>
</div>
<p class="decision-modal__ref">Référence : "La Sociocracie" Gerard Endenburg, Brian Robertson (Holacracy)</p>
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
</div>
</template>
</UModal>
</template>
<style scoped>
@@ -333,6 +425,40 @@ function formatDate(dateStr: string): string {
color: var(--mood-accent);
}
.decision-card__onchain-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.decision-card__protocol-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 10%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
transition: transform 0.12s ease, box-shadow 0.12s ease;
width: fit-content;
}
.decision-card__protocol-link:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
@@ -402,17 +528,85 @@ function formatDate(dateStr: string): string {
transform: translateY(0);
}
.toolbox-section-title {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
/* Decision modal */
.decision-modal {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toolbox-empty-text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
@media (min-width: 640px) {
.decision-modal { padding: 2rem; gap: 1.25rem; }
}
.decision-modal__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.decision-modal__text {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.decision-modal__steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.decision-modal__step {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.decision-modal__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
}
.decision-modal__step-text {
font-size: 0.875rem;
color: var(--mood-text);
padding-top: 0.125rem;
line-height: 1.5;
}
.decision-modal__ref {
font-size: 0.75rem;
color: var(--mood-text-muted);
font-style: italic;
margin: 0;
}
.decision-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
align-self: flex-end;
transition: transform 0.1s ease;
}
.decision-modal__close:hover { transform: translateY(-1px); }
</style>

View File

@@ -1,4 +1,13 @@
<script setup lang="ts">
/**
* Document detail page — full structured view with:
* - Genesis block (source files, repos, forum synthesis, formula trigger)
* - Sectioned items grouped by section_tag
* - Mini vote boards per item
* - Inertia sliders per section
* - Permanent vote signage
* - Tuto overlay
*/
import type { DocumentItem } from '~/stores/documents'
const route = useRoute()
@@ -6,7 +15,6 @@ const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const archiving = ref(false)
onMounted(async () => {
@@ -23,11 +31,85 @@ watch(slug, async (newSlug) => {
}
})
// ─── Section grouping ──────────────────────────────────────────
interface Section {
tag: string
label: string
icon: string
inertiaPreset: string
items: DocumentItem[]
}
const SECTION_META: Record<string, { label: string; icon: string }> = {
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
}
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
const sections = computed((): Section[] => {
const grouped: Record<string, DocumentItem[]> = {}
const ungrouped: DocumentItem[] = []
for (const item of documents.items) {
const tag = item.section_tag
if (tag) {
if (!grouped[tag]) grouped[tag] = []
grouped[tag].push(item)
} else {
ungrouped.push(item)
}
}
const result: Section[] = []
for (const tag of SECTION_ORDER) {
if (grouped[tag]) {
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
const firstItem = grouped[tag][0]
result.push({
tag,
label: meta.label,
icon: meta.icon,
inertiaPreset: firstItem?.inertia_preset || 'standard',
items: grouped[tag],
})
}
}
// Ungrouped items
if (ungrouped.length > 0) {
result.push({
tag: '_other',
label: 'Autres',
icon: 'i-lucide-file-text',
inertiaPreset: 'standard',
items: ungrouped,
})
}
return result
})
const totalItems = computed(() => documents.items.length)
// ─── Helpers ───────────────────────────────────────────────────
const typeLabel = (docType: string) => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
@@ -55,12 +137,49 @@ async function archiveToSanctuary() {
archiving.value = false
}
}
// ─── Active section (scroll spy) ──────────────────────────────
const activeSection = ref<string | null>(null)
function scrollToSection(tag: string) {
// Expand the section if collapsed
if (collapsedSections.value[tag]) {
collapsedSections.value[tag] = false
}
nextTick(() => {
const el = document.getElementById(`section-${tag}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
}
})
}
// ─── Collapsible sections ────────────────────────────────────
// First 2 sections open by default, rest collapsed
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (newSections) => {
if (newSections.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
newSections.forEach((s, i) => {
map[s.tag] = i >= 2 // collapsed if index >= 2
})
collapsedSections.value = map
}
}, { immediate: true })
function toggleSection(tag: string) {
collapsedSections.value[tag] = !collapsedSections.value[tag]
}
</script>
<template>
<div class="space-y-6">
<div class="doc-page">
<!-- Back link -->
<div>
<div class="doc-page__nav">
<UButton
to="/documents"
variant="ghost"
@@ -94,31 +213,36 @@ async function archiveToSanctuary() {
<!-- Document detail -->
<template v-else-if="documents.current">
<!-- Header -->
<div>
<div class="flex items-start justify-between">
<!-- HEADER -->
<div class="doc-page__header">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
<h1 class="doc-page__title">
{{ documents.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2">
<div class="flex items-center gap-3 mt-2 flex-wrap">
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<StatusBadge :status="documents.current.status" type="document" />
<span class="text-sm text-gray-500 font-mono">
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
v{{ documents.current.version }}
</span>
<span class="text-sm" style="color: var(--mood-text-muted)">
{{ totalItems }} items
</span>
</div>
</div>
<!-- Archive button for authenticated users with active documents -->
<div v-if="auth.isAuthenticated && documents.current.status === 'active'" class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<DocumentTuto />
<UButton
label="Archiver dans le Sanctuaire"
v-if="auth.isAuthenticated && documents.current.status === 'active'"
label="Archiver"
icon="i-lucide-archive"
color="primary"
variant="soft"
size="sm"
:loading="archiving"
@click="archiveToSanctuary"
/>
@@ -126,71 +250,361 @@ async function archiveToSanctuary() {
</div>
<!-- Description -->
<p v-if="documents.current.description" class="mt-4 text-gray-600 dark:text-gray-400">
<p v-if="documents.current.description" class="doc-page__desc">
{{ documents.current.description }}
</p>
</div>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<!-- METADATA -->
<div class="doc-page__meta">
<div class="doc-page__meta-grid">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(documents.current.created_at) }}
</p>
<p class="doc-page__meta-label">Créé le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(documents.current.updated_at) }}
</p>
<p class="doc-page__meta-label">Mis à jour le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
</div>
<div>
<p class="text-gray-500">Nombre d'items</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ documents.current.items_count }}
</p>
</div>
<div>
<p class="text-gray-500">Ancrage IPFS</p>
<p class="doc-page__meta-label">Ancrage IPFS</p>
<div class="mt-1">
<IPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
</div>
<!-- Chain anchor info -->
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-2">
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
<div v-if="documents.current.chain_anchor">
<p class="doc-page__meta-label">Ancrage on-chain</p>
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</UCard>
</div>
<!-- Document items -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Contenu du document ({{ documents.items.length }} items)
</h2>
<!-- GENESIS BLOCK -->
<GenesisBlock
v-if="documents.current.genesis_json"
:genesis-json="documents.current.genesis_json"
/>
<div v-if="documents.items.length === 0" class="text-center py-8">
<UIcon name="i-lucide-file-plus" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun item dans ce document</p>
</div>
<!-- SECTION NAVIGATOR -->
<div v-if="sections.length > 1" class="doc-page__section-nav">
<button
v-for="section in sections"
:key="section.tag"
class="doc-page__section-pill"
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" class="text-xs" />
{{ section.label }}
<span class="doc-page__section-count">{{ section.items.length }}</span>
</button>
</div>
<div v-else class="space-y-4">
<ItemCard
v-for="item in documents.items"
:key="item.id"
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
<!-- SECTIONS WITH ITEMS -->
<div class="doc-page__sections">
<div
v-for="section in sections"
:key="section.tag"
:id="`section-${section.tag}`"
class="doc-page__section"
>
<!-- Section header (clickable toggle) -->
<button
class="doc-page__section-header"
@click="toggleSection(section.tag)"
>
<div class="flex items-center gap-2">
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
<h2 class="doc-page__section-title">
{{ section.label }}
</h2>
<UBadge variant="subtle" color="neutral" size="xs">
{{ section.items.length }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<InertiaSlider :preset="section.inertiaPreset" compact mini />
<UIcon
name="i-lucide-chevron-down"
class="doc-page__section-chevron"
:class="{ 'doc-page__section-chevron--open': !collapsedSections[section.tag] }"
/>
</div>
</button>
<!-- Protocol link for qualification section -->
<NuxtLink
v-if="section.tag === 'qualification' && !collapsedSections[section.tag]"
to="/protocols"
class="doc-page__protocol-link"
>
<UIcon name="i-lucide-git-branch" class="text-sm" />
<div>
<span class="doc-page__protocol-link-label">Protocole lié</span>
<span class="doc-page__protocol-link-name">Embarquement Forgeron</span>
</div>
<UIcon name="i-lucide-arrow-right" class="text-sm doc-page__protocol-link-arrow" />
</NuxtLink>
<!-- Items (collapsible) -->
<Transition name="section-collapse">
<div v-show="!collapsedSections[section.tag]" class="doc-page__section-items">
<EngagementCard
v-for="item in section.items"
:key="item.id"
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
</div>
</Transition>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__nav {
margin-bottom: -0.5rem;
}
/* Header */
.doc-page__header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.doc-page__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
line-height: 1.2;
}
@media (min-width: 640px) {
.doc-page__title {
font-size: 1.875rem;
}
}
.doc-page__desc {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin-top: 0.25rem;
}
/* Metadata */
.doc-page__meta {
padding: 1rem 1.25rem;
background: var(--mood-surface);
border-radius: 14px;
}
.doc-page__meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.doc-page__meta-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.doc-page__meta-label {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.doc-page__meta-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
/* Section navigator */
.doc-page__section-nav {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.doc-page__section-nav::-webkit-scrollbar {
display: none;
}
.doc-page__section-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
background: var(--mood-surface);
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.15s;
border: none;
}
.doc-page__section-pill:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
color: var(--mood-text);
}
.doc-page__section-pill--active {
background: var(--mood-accent);
color: white;
}
.doc-page__section-count {
font-size: 0.625rem;
font-weight: 800;
opacity: 0.7;
}
/* Sections */
.doc-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.doc-page__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
scroll-margin-top: 4rem;
}
.doc-page__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
width: 100%;
background: none;
cursor: pointer;
user-select: none;
transition: opacity 0.15s;
}
.doc-page__section-header:hover {
opacity: 0.85;
}
.doc-page__section-chevron {
font-size: 1rem;
color: var(--mood-text-muted);
transform: rotate(-90deg);
transition: transform 0.25s ease;
flex-shrink: 0;
}
.doc-page__section-chevron--open {
transform: rotate(0deg);
}
.doc-page__section-title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.doc-page__section-title {
font-size: 1.125rem;
}
}
.doc-page__section-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Protocol link */
.doc-page__protocol-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
border-radius: 14px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
color: var(--mood-tertiary, var(--mood-accent));
}
.doc-page__protocol-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.doc-page__protocol-link-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.doc-page__protocol-link-name {
display: block;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.doc-page__protocol-link-arrow {
margin-left: auto;
opacity: 0.3;
transition: opacity 0.12s;
}
.doc-page__protocol-link:hover .doc-page__protocol-link-arrow {
opacity: 1;
}
/* Section collapse transition */
.section-collapse-enter-active,
.section-collapse-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.section-collapse-enter-from,
.section-collapse-leave-to {
opacity: 0;
max-height: 0;
}
.section-collapse-enter-to,
.section-collapse-leave-from {
opacity: 1;
}
</style>

View File

@@ -3,7 +3,7 @@
* Documents de reference — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boite a outils" affichant les protocoles de vote.
* et sidebar "Boîte à outils" affichant les protocoles de vote.
*/
import type { DocumentCreate } from '~/stores/documents'
@@ -11,6 +11,41 @@ const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const inertiaLevels = [
{
id: 'light',
name: 'Léger',
color: 'teal',
params: 'B=0.05, G=0.1',
desc: 'Modification facile. Majorité simple suffit avec bonne participation.',
example: 'Clarifications rédactionnelles, notes de bas de page.',
},
{
id: 'standard',
name: 'Standard',
color: 'accent',
params: 'B=0.1, G=0.2',
desc: 'Seuil adaptatif standard. La formule g1vote dans son paramétrage habituel.',
example: 'Articles de fond, engagements opérationnels.',
},
{
id: 'strong',
name: 'Fort',
color: 'secondary',
params: 'B=0.15, G=0.3',
desc: 'Forte résistance. Faible participation → quasi-unanimité requise.',
example: 'Principes fondateurs, formules de vote, critères WoT.',
},
{
id: 'very-strong',
name: 'Très fort',
color: 'error',
params: 'B=0.2, G=0.4',
desc: 'Protection maximale. Seule une forte mobilisation peut modifier.',
example: 'Clause de licence, identité du projet, droits des membres.',
},
]
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
@@ -29,7 +64,7 @@ const creating = ref(false)
const newDocTypeOptions = [
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Reglement', value: 'reglement' },
{ label: 'Règlement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
@@ -48,7 +83,7 @@ onMounted(async () => {
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'draft', label: 'En prépa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
@@ -87,13 +122,12 @@ const filteredDocuments = computed(() => {
})
/** Toolbox vignettes from protocols. */
const toolboxTitle = 'Modalites de vote'
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
@@ -155,8 +189,8 @@ async function createDocument() {
<template>
<SectionLayout
title="Documents de reference"
subtitle="Textes fondateurs sous vote permanent de la communaute"
title="Documents de référence"
subtitle="Textes fondateurs sous vote permanent de la communauté"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
@@ -207,7 +241,7 @@ async function createDocument() {
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
<p>Aucun document trouve</p>
<p>Aucun document trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
@@ -252,23 +286,53 @@ async function createDocument() {
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
{{ toolboxTitle }}
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:bullets="['Applicable aux documents', protocol.mode_params || 'Configuration standard']"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
<!-- Inertia guide -->
<ToolboxSection title="Niveaux d'inertie" icon="i-lucide-sliders-horizontal">
<div class="inertia-guide">
<div v-for="level in inertiaLevels" :key="level.id" class="inertia-level">
<div class="inertia-level__header">
<span class="inertia-level__name" :class="`inertia-level__name--${level.color}`">
{{ level.name }}
</span>
<span class="inertia-level__params">{{ level.params }}</span>
</div>
<p class="inertia-level__desc">{{ level.desc }}</p>
<p class="inertia-level__example">{{ level.example }}</p>
</div>
</div>
<NuxtLink to="/protocols/formulas" class="toolbox-link-btn">
<UIcon name="i-lucide-calculator" />
Simuler les formules
</NuxtLink>
</ToolboxSection>
<!-- Structure document -->
<ToolboxVignette
title="Structure d'un document"
:bullets="[
'Items = clauses individuelles',
'Sections = groupes thématiques',
'Chaque clause : vote indépendant',
'Genesis block : traçabilité d\'origine',
]"
:actions="[
{ label: 'Nouveau doc', icon: 'i-lucide-file-plus', emit: 'new', primary: true },
]"
@action="e => e === 'new' && openNewDocModal()"
/>
<!-- Sanctuaire -->
<ToolboxVignette
title="Sanctuaire IPFS"
:bullets="[
'Document adopté → archivé on-chain',
'Hash IPFS + system.remark Duniter',
'Immuable, vérifiable, décentralisé',
]"
:actions="[
{ label: 'Sanctuaire', icon: 'i-lucide-archive', to: '/sanctuary', primary: true },
]"
/>
</template>
</SectionLayout>
@@ -277,7 +341,7 @@ async function createDocument() {
<template #content>
<div class="p-4 sm:p-6 space-y-4">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau document de reference
Nouveau document de référence
</h3>
<div class="space-y-4">
@@ -332,7 +396,7 @@ async function createDocument() {
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Decrivez brievement ce document..."
placeholder="Décrivez brièvement ce document..."
class="w-full"
/>
</div>
@@ -346,7 +410,7 @@ async function createDocument() {
@click="showNewDocModal = false"
/>
<UButton
label="Creer le document"
label="Créer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
@@ -466,18 +530,83 @@ async function createDocument() {
}
}
.toolbox-section-title {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
/* Inertia guide */
.inertia-guide {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toolbox-empty-text {
font-size: 0.8125rem;
.inertia-level {
background: var(--mood-surface);
border-radius: 10px;
padding: 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.inertia-level__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.inertia-level__name {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia-level__name--teal { color: var(--mood-success); }
.inertia-level__name--accent { color: var(--mood-accent); }
.inertia-level__name--secondary { color: var(--mood-secondary, var(--mood-accent)); }
.inertia-level__name--error { color: var(--mood-error); }
.inertia-level__params {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 1px 6px;
border-radius: 8px;
}
.inertia-level__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.inertia-level__example {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin: 0;
font-style: italic;
opacity: 0.8;
}
.toolbox-link-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
text-decoration: none;
cursor: pointer;
align-self: flex-start;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.toolbox-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */

View File

@@ -21,9 +21,20 @@ onMounted(async () => {
})
const entryCards = computed(() => [
{
key: 'decisions',
title: 'Décisions structurantes',
icon: 'i-lucide-scale',
to: '/decisions',
count: decisions.activeDecisions.length,
countLabel: `${decisions.activeDecisions.length} en cours`,
totalLabel: `${decisions.list.length} au total`,
description: 'Processus de décision collectifs',
color: 'var(--mood-secondary, var(--mood-accent))',
},
{
key: 'documents',
title: 'Documents',
title: 'Documents de référence',
icon: 'i-lucide-book-open',
to: '/documents',
count: documents.activeDocuments.length,
@@ -32,39 +43,28 @@ const entryCards = computed(() => [
description: 'Textes fondateurs sous vote permanent',
color: 'var(--mood-accent)',
},
{
key: 'decisions',
title: 'Decisions',
icon: 'i-lucide-scale',
to: '/decisions',
count: decisions.activeDecisions.length,
countLabel: `${decisions.activeDecisions.length} en cours`,
totalLabel: `${decisions.list.length} au total`,
description: 'Processus de decision collectifs',
color: 'var(--mood-secondary, var(--mood-accent))',
},
{
key: 'protocoles',
title: 'Protocoles',
icon: 'i-lucide-settings',
to: '/protocols',
count: protocols.protocols.length,
countLabel: `${protocols.protocols.length} modalite${protocols.protocols.length > 1 ? 's' : ''}`,
totalLabel: 'Boite a outils de vote + workflows',
description: 'Modalites de vote, formules, workflows n8n',
color: 'var(--mood-tertiary, var(--mood-accent))',
},
{
key: 'mandats',
title: 'Mandats',
title: 'Mandats et nominations',
icon: 'i-lucide-user-check',
to: '/mandates',
count: null,
countLabel: null,
totalLabel: null,
description: 'Missions deleguees avec nomination en binome',
description: 'Missions déléguées avec nomination en binôme',
color: 'var(--mood-success)',
},
{
key: 'protocoles',
title: 'Protocoles et fonctionnement',
icon: 'i-lucide-settings',
to: '/protocols',
count: 2,
countLabel: '2 protocoles',
totalLabel: `${protocols.protocols.length} modalités de vote`,
description: 'Modalités de vote, formules, workflows',
color: 'var(--mood-tertiary, var(--mood-accent))',
},
])
const recentDecisions = computed(() => {
@@ -81,7 +81,7 @@ function formatDate(dateStr: string): string {
if (diffHours < 1) {
const diffMinutes = Math.floor(diffMs / (1000 * 60))
return diffMinutes <= 1 ? 'A l\'instant' : `Il y a ${diffMinutes} min`
return diffMinutes <= 1 ? 'À l\'instant' : `Il y a ${diffMinutes} min`
}
if (diffHours < 24) {
return `Il y a ${Math.floor(diffHours)}h`
@@ -101,7 +101,7 @@ function formatDate(dateStr: string): string {
<span class="dash__title-g">ğ</span><span class="dash__title-paren">(</span>Decision<span class="dash__title-paren">)</span>
</h1>
<p class="dash__subtitle">
Decisions collectives pour la communaute Duniter / G1
Décisions collectives pour la communauté Duniter / G1
</p>
</div>
@@ -141,7 +141,7 @@ function formatDate(dateStr: string): string {
<div class="dash__connect-left">
<UIcon name="i-lucide-key-round" class="text-lg" />
<div>
<p class="dash__connect-text">Connectez-vous avec votre identite Duniter pour participer.</p>
<p class="dash__connect-text">Connectez-vous avec votre identité Duniter pour participer.</p>
<p class="dash__connect-hint">Signature Ed25519 · aucun mot de passe</p>
</div>
</div>
@@ -151,41 +151,33 @@ function formatDate(dateStr: string): string {
</NuxtLink>
</div>
<!-- Toolbox teaser -->
<div class="dash__toolbox">
<div class="dash__toolbox-head">
<UIcon name="i-lucide-wrench" class="text-lg" />
<h3>Boite a outils</h3>
<span class="dash__toolbox-count">{{ protocols.protocols.length }}</span>
<!-- Toolbox teaser (5th block, distinct look) -->
<NuxtLink to="/tools" class="dash__toolbox-card">
<div class="dash__toolbox-card-inner">
<div class="dash__toolbox-card-icon">
<UIcon name="i-lucide-wrench" class="text-xl" />
</div>
<div class="dash__toolbox-card-body">
<h3 class="dash__toolbox-card-title">Boîte à outils</h3>
<p class="dash__toolbox-card-desc">
Simulateur de formules, modules de vote, workflows
</p>
<div class="dash__toolbox-card-tags">
<span class="dash__toolbox-card-tag">Vote WoT</span>
<span class="dash__toolbox-card-tag">Inertie</span>
<span class="dash__toolbox-card-tag">Smith</span>
<span class="dash__toolbox-card-tag">Nuance</span>
</div>
</div>
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
</div>
<div class="dash__toolbox-tags">
<template v-if="protocols.protocols.length > 0">
<NuxtLink
v-for="protocol in protocols.protocols"
:key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="dash__tag"
>
{{ protocol.name }}
</NuxtLink>
</template>
<template v-else>
<span class="dash__tag">Vote WoT</span>
<span class="dash__tag">Vote nuance</span>
<span class="dash__tag">Vote permanent</span>
</template>
</div>
<NuxtLink to="/protocols" class="dash__toolbox-link">
Voir la boite a outils
<UIcon name="i-lucide-chevron-right" />
</NuxtLink>
</div>
</NuxtLink>
<!-- Recent activity -->
<div v-if="recentDecisions.length > 0" class="dash__activity">
<div class="dash__activity-head">
<UIcon name="i-lucide-activity" class="text-lg" />
<h3>Activite recente</h3>
<h3>Activité récente</h3>
</div>
<div class="dash__activity-list">
<NuxtLink
@@ -215,7 +207,7 @@ function formatDate(dateStr: string): string {
<template #content>
<div class="dash__formula-body">
<p class="dash__formula-desc">
Le seuil s'adapte a la participation : faible = quasi-unanimite ; forte = majorite simple.
Le seuil s'adapte à la participation : faible = quasi-unanimité ; forte = majorité simple.
</p>
<code class="dash__formula-code">
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
@@ -225,7 +217,7 @@ function formatDate(dateStr: string): string {
<span>B = base</span>
<span>W = taille WoT</span>
<span>T = votes</span>
<span>M = majorite</span>
<span>M = majorité</span>
<span>G = gradient</span>
</div>
<NuxtLink to="/protocols/formulas" class="dash__formula-link">
@@ -292,7 +284,7 @@ function formatDate(dateStr: string): string {
@media (min-width: 640px) {
.dash__entries {
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
@@ -460,73 +452,91 @@ function formatDate(dateStr: string): string {
transform: translateY(0);
}
/* --- Toolbox teaser --- */
.dash__toolbox {
background: var(--mood-surface);
/* --- Toolbox card (5th block, distinct) --- */
.dash__toolbox-card {
display: block;
text-decoration: none;
background: var(--mood-accent-soft);
border-radius: 16px;
padding: 1rem;
padding: 1.25rem;
transition: transform 0.15s ease, box-shadow 0.15s ease;
border-left: 4px solid var(--mood-accent);
}
@media (min-width: 640px) {
.dash__toolbox {
padding: 1.25rem;
}
.dash__toolbox-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.dash__toolbox-head {
.dash__toolbox-card-inner {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.dash__toolbox-card-icon {
width: 2.75rem;
height: 2.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-accent);
font-weight: 800;
font-size: 1.0625rem;
}
.dash__toolbox-head h3 { margin: 0; }
.dash__toolbox-count {
font-size: 0.75rem;
font-weight: 800;
background: var(--mood-accent-soft);
color: var(--mood-accent);
padding: 2px 8px;
border-radius: 20px;
justify-content: center;
border-radius: 14px;
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.dash__toolbox-tags {
.dash__toolbox-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.dash__toolbox-card-title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.dash__toolbox-card-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.4;
}
.dash__toolbox-card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.dash__tag {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 20px;
text-decoration: none;
transition: transform 0.1s ease;
}
.dash__tag:hover {
transform: translateY(-1px);
}
.dash__toolbox-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.75rem;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.dash__toolbox-card-tag {
display: inline-flex;
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-accent);
text-decoration: none;
background: var(--mood-surface);
border-radius: 20px;
}
.dash__toolbox-link:hover {
text-decoration: underline;
.dash__toolbox-card-arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
margin-top: 0.375rem;
transition: all 0.15s;
}
.dash__toolbox-card:hover .dash__toolbox-card-arrow {
opacity: 1;
color: var(--mood-accent);
transform: translateX(3px);
}
/* --- Activity --- */

View File

@@ -1,11 +1,65 @@
<script setup lang="ts">
const auth = useAuthStore()
const router = useRouter()
const { $api } = useApi()
const address = ref('')
const step = ref<'input' | 'challenge' | 'signing' | 'success'>('input')
const errorMessage = ref('')
// Dev profiles
interface DevProfile {
address: string
display_name: string
wot_status: string
is_smith: boolean
is_techcomm: boolean
}
const devProfiles = ref<DevProfile[]>([])
const devLoading = ref(false)
async function loadDevProfiles() {
try {
devProfiles.value = await $api<DevProfile[]>('/auth/dev/profiles')
} catch {
// Not in dev mode or endpoint unavailable
}
}
function statusLabel(p: DevProfile): string {
const parts: string[] = []
parts.push(p.wot_status === 'member' ? 'Membre WoT' : 'Observateur')
if (p.is_smith) parts.push('Forgeron')
if (p.is_techcomm) parts.push('ComTech')
return parts.join(' · ')
}
function statusColor(p: DevProfile): string {
if (p.is_techcomm) return 'var(--mood-info, #3b82f6)'
if (p.is_smith) return 'var(--mood-warning, #f59e0b)'
if (p.wot_status === 'member') return 'var(--mood-success, #22c55e)'
return 'var(--mood-text-muted, #888)'
}
async function loginAsProfile(p: DevProfile) {
devLoading.value = true
address.value = p.address
errorMessage.value = ''
step.value = 'challenge'
try {
step.value = 'signing'
await auth.login(p.address)
step.value = 'success'
setTimeout(() => router.push('/'), 800)
} catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur connexion dev'
step.value = 'input'
} finally {
devLoading.value = false
}
}
async function handleLogin() {
if (!address.value.trim()) {
errorMessage.value = 'Veuillez entrer votre adresse Duniter'
@@ -49,6 +103,7 @@ onMounted(() => {
if (auth.isAuthenticated) {
router.push('/')
}
loadDevProfiles()
})
</script>
@@ -121,6 +176,30 @@ onMounted(() => {
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<!-- Dev Mode Panel -->
<div v-if="devProfiles.length" class="dev-panel">
<div class="dev-panel__header">
<UIcon name="i-lucide-bug" />
<span>Mode Dev Connexion rapide</span>
</div>
<div class="dev-panel__profiles">
<button
v-for="p in devProfiles"
:key="p.address"
class="dev-profile"
:disabled="devLoading || step === 'success'"
@click="loginAsProfile(p)"
>
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
<div class="dev-profile__info">
<span class="dev-profile__name">{{ p.display_name }}</span>
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
</div>
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button>
</div>
</div>
<!-- Note -->
<p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique.
@@ -373,6 +452,93 @@ onMounted(() => {
cursor: not-allowed;
}
/* Dev panel */
.dev-panel {
border: 2px dashed var(--mood-warning, #f59e0b);
border-radius: 16px;
padding: 1rem;
background: rgba(245, 158, 11, 0.04);
}
.dev-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-warning, #f59e0b);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dev-panel__profiles {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dev-profile {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
text-align: left;
}
.dev-profile:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 12px var(--mood-shadow, rgba(0,0,0,0.08));
}
.dev-profile:active:not(:disabled) {
transform: translateY(0);
}
.dev-profile:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dev-profile__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
.dev-profile__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.dev-profile__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.dev-profile__status {
font-size: 0.6875rem;
color: var(--mood-text-muted);
font-weight: 600;
}
.dev-profile__addr {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
opacity: 0.6;
flex-shrink: 0;
}
/* Note */
.login-card__note {
text-align: center;

View File

@@ -3,8 +3,8 @@
* Mandats — page index.
*
* Utilise SectionLayout avec status filters, recherche,
* et sidebar "Boite a outils" affichant les protocoles de vote.
* Etat vide enrichi avec onboarding expliquant le concept de mandat.
* et sidebar "Boîte à outils" affichant les protocoles de vote.
* État vide enrichi avec onboarding expliquant le concept de mandat.
*/
import type { MandateCreate } from '~/stores/mandates'
@@ -25,9 +25,9 @@ const sortOptions = [
// Create mandate modal state
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comite technique', value: 'techcomm' },
{ label: 'Comité technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalise', value: 'custom' },
{ label: 'Personnalisé', value: 'custom' },
]
const newMandate = ref<MandateCreate>({
@@ -46,7 +46,7 @@ onMounted(async () => {
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'draft', label: 'En prépa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
@@ -95,9 +95,9 @@ const filteredMandates = computed(() => {
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'techcomm': return 'Comité technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
case 'custom': return 'Personnalisé'
default: return mandateType
}
}
@@ -133,7 +133,7 @@ async function handleCreate() {
<template>
<SectionLayout
title="Mandats"
subtitle="Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d'un binome."
subtitle="Un contexte, un objectif, une durée, une ou plusieurs nominations ; par défaut : nomination d'un binôme."
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
@@ -189,17 +189,17 @@ async function handleCreate() {
Qu'est-ce qu'un mandat ?
</h3>
<p class="mandate-onboarding__text">
Un mandat definit un contexte, un objectif et une duree pour une mission de gouvernance.
Il peut porter sur le comite technique, les forgerons, ou tout role specifique de la communaute.
Un mandat définit un contexte, un objectif et une durée pour une mission de gouvernance.
Il peut porter sur le comité technique, les forgerons, ou tout rôle spécifique de la communauté.
</p>
<p class="mandate-onboarding__text">
Par defaut, un mandat nomme un binome pour assurer la continuite.
Par défaut, un mandat nomme un binôme pour assurer la continuité.
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
</p>
<div class="mandate-onboarding__actions">
<UButton
v-if="auth.isAuthenticated"
label="Creer un premier mandat"
label="Créer un premier mandat"
icon="i-lucide-plus"
color="primary"
size="sm"
@@ -207,7 +207,7 @@ async function handleCreate() {
/>
<UButton
to="/protocols"
label="Decouvrir les protocoles"
label="Découvrir les protocoles"
variant="outline"
size="sm"
icon="i-lucide-wrench"
@@ -222,7 +222,7 @@ async function handleCreate() {
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
<p>Aucun mandat trouve</p>
<p>Aucun mandat trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
@@ -254,7 +254,7 @@ async function handleCreate() {
</span>
<span class="mandate-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ mandate.steps.length }} etape{{ mandate.steps.length !== 1 ? 's' : '' }}
{{ mandate.steps.length }} étape{{ mandate.steps.length !== 1 ? 's' : '' }}
</span>
<span v-if="mandate.mandatee_id" class="mandate-card__mandatee">
<UIcon name="i-lucide-user" class="text-xs" />
@@ -263,7 +263,7 @@ async function handleCreate() {
</div>
<div class="mandate-card__dates">
<span>Debut : {{ formatDate(mandate.starts_at) }}</span>
<span>Début : {{ formatDate(mandate.starts_at) }}</span>
<span>Fin : {{ formatDate(mandate.ends_at) }}</span>
</div>
</div>
@@ -272,23 +272,39 @@ async function handleCreate() {
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
Modalites de vote
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:bullets="['Applicable aux mandats', protocol.mode_params || 'Configuration standard']"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
<!-- Sociocratic election guide -->
<ToolboxSection title="Nomination & Élection" icon="i-lucide-users">
<SocioElection />
</ToolboxSection>
<!-- Mandat cycle -->
<ToolboxVignette
title="Cycle de mandat"
:bullets="[
'1. Ouverture + définition du rôle',
'2. Candidatures (auto ou par pairs)',
'3. Élection sociocratique',
'4. Période active + rapports',
'5. Renouvellement ou clôture',
]"
:actions="[
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
]"
@action="e => e === 'create' && (showCreateModal = true)"
/>
<!-- Révocation -->
<ToolboxVignette
title="Révocation"
:bullets="[
'Initiée par 3 membres ou plus',
'Vote communautaire ordinaire',
'Bilan de clôture obligatoire',
]"
:actions="[
{ label: 'Voir', icon: 'i-lucide-shield-off', emit: 'revoke' },
]"
/>
</template>
</SectionLayout>
@@ -341,7 +357,7 @@ async function handleCreate() {
/>
<UButton
type="submit"
label="Creer"
label="Créer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
@@ -538,20 +554,6 @@ async function handleCreate() {
margin-top: 0.5rem;
}
.toolbox-section-title {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mandate-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Protocoles & Fonctionnement — Boite a outils de vote.
* Protocoles & Fonctionnement — Boîte à outils de vote.
*
* Liste les protocoles de vote avec SectionLayout,
* sidebar n8n workflow + simulateur de formules.
@@ -30,14 +30,14 @@ onMounted(async () => {
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuance'
case 'nuanced': return 'Nuancé'
default: return voteType
}
}
const voteTypeOptions = [
{ label: 'Binaire (Pour/Contre)', value: 'binary' },
{ label: 'Nuance (6 niveaux)', value: 'nuanced' },
{ label: 'Nuancé (6 niveaux)', value: 'nuanced' },
]
const formulaOptions = computed(() => {
@@ -57,7 +57,7 @@ const statuses = computed(() => [
},
{
id: 'nuanced',
label: 'Nuance',
label: 'Nuancé',
count: protocols.protocols.filter(p => p.vote_type === 'nuanced').length,
cssClass: 'status-prepa',
},
@@ -109,11 +109,94 @@ async function createProtocol() {
}
}
/** Operational protocols (workflow templates). */
interface WorkflowStep {
label: string
actor: string
icon: string
type: string
}
interface LinkedRef {
label: string
icon: string
to: string
kind: 'document' | 'decision'
}
interface OperationalProtocol {
slug: string
name: string
description: string
category: string
icon: string
instancesLabel: string
linkedRefs: LinkedRef[]
steps: WorkflowStep[]
}
const operationalProtocols: OperationalProtocol[] = [
{
slug: 'election-sociocratique',
name: 'Élection sociocratique',
description: 'Processus d\'élection d\'un rôle par consentement : clarification du rôle, nominations silencieuses, argumentaire, levée d\'objections. Garantit légitimité et clarté.',
category: 'gouvernance',
icon: 'i-lucide-users',
instancesLabel: 'Tout renouvellement de rôle',
linkedRefs: [
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', kind: 'decision' },
],
steps: [
{ label: 'Clarifier le rôle', actor: 'Cercle', icon: 'i-lucide-clipboard-list', type: 'checklist' },
{ label: 'Nominations silencieuses', actor: 'Tous les membres', icon: 'i-lucide-pencil', type: 'checklist' },
{ label: 'Recueil & argumentaire', actor: 'Facilitateur', icon: 'i-lucide-list-checks', type: 'checklist' },
{ label: 'Objections & consentement', actor: 'Cercle', icon: 'i-lucide-shield-check', type: 'certification' },
{ label: 'Proclamation', actor: 'Facilitateur', icon: 'i-lucide-star', type: 'on_chain' },
],
},
{
slug: 'embarquement-forgeron',
name: 'Embarquement Forgeron',
description: 'Processus complet d\'intégration d\'un nouveau forgeron dans le réseau Duniter. Parcours en 5 jalons de la candidature à la mise en ligne du nœud validateur.',
category: 'onboarding',
icon: 'i-lucide-hammer',
instancesLabel: '~10-50 / an',
linkedRefs: [
{ label: 'Acte d\'engagement forgeron', icon: 'i-lucide-book-open', to: '/documents/engagement-forgeron', kind: 'document' },
],
steps: [
{ label: 'Candidature', actor: 'Aspirant forgeron', icon: 'i-lucide-user-plus', type: 'checklist' },
{ label: 'Nœud miroir', actor: 'Candidat', icon: 'i-lucide-server', type: 'on_chain' },
{ label: 'Évaluation technique', actor: 'Certificateur', icon: 'i-lucide-clipboard-check', type: 'checklist' },
{ label: 'Certification Smith (×3)', actor: 'Certificateurs', icon: 'i-lucide-stamp', type: 'certification' },
{ label: 'Go online', actor: 'Candidat', icon: 'i-lucide-wifi', type: 'on_chain' },
],
},
{
slug: 'soumission-runtime-upgrade',
name: 'Soumission Runtime Upgrade',
description: 'Protocole de soumission d\'une mise à jour du runtime Duniter V2 on-chain. Chaque upgrade suit un parcours strict en 5 étapes, de la qualification technique au suivi post-déploiement.',
category: 'on-chain',
icon: 'i-lucide-cpu',
instancesLabel: '~2-6 / an',
linkedRefs: [
{ label: 'Décision Runtime Upgrade', icon: 'i-lucide-scale', to: '/decisions', kind: 'decision' },
],
steps: [
{ label: 'Qualification', actor: 'Proposant', icon: 'i-lucide-file-check', type: 'checklist' },
{ label: 'Revue technique', actor: 'Comité technique', icon: 'i-lucide-search', type: 'checklist' },
{ label: 'Vote communautaire', actor: 'Communauté WoT', icon: 'i-lucide-vote', type: 'on_chain' },
{ label: 'Exécution on-chain', actor: 'Proposant', icon: 'i-lucide-zap', type: 'on_chain' },
{ label: 'Suivi post-upgrade', actor: 'Forgerons', icon: 'i-lucide-activity', type: 'checklist' },
],
},
]
/** n8n workflow demo items. */
const n8nWorkflows = [
{
name: 'Vote -> Notification',
description: 'Notifie les membres lorsqu\'un nouveau vote demarre ou se termine.',
description: 'Notifie les membres lorsqu\'un nouveau vote démarre ou se termine.',
icon: 'i-lucide-bell',
status: 'actif',
},
@@ -124,13 +207,13 @@ const n8nWorkflows = [
status: 'actif',
},
{
name: 'Decision -> Etape suivante',
description: 'Avance automatiquement une decision a l\'etape suivante apres validation.',
name: 'Décision → Étape suivante',
description: 'Avance automatiquement une décision à l\'étape suivante après validation.',
icon: 'i-lucide-git-branch',
status: 'demo',
},
{
name: 'Mandat expire -> Alerte',
name: 'Mandat expiré → Alerte',
description: 'Envoie une alerte 7 jours avant l\'expiration d\'un mandat.',
icon: 'i-lucide-alarm-clock',
status: 'demo',
@@ -141,7 +224,7 @@ const n8nWorkflows = [
<template>
<SectionLayout
title="Protocoles & Fonctionnement"
subtitle="Boite a outils de vote, formules de seuil, workflows automatises"
subtitle="Boîte à outils de vote, formules de seuil, workflows automatisés"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
@@ -183,7 +266,7 @@ const n8nWorkflows = [
<template v-else>
<div v-if="filteredProtocols.length === 0" class="proto-empty">
<UIcon name="i-lucide-settings" class="text-2xl" />
<p>Aucun protocole trouve</p>
<p>Aucun protocole trouvé</p>
</div>
<div v-else class="proto-list">
@@ -241,8 +324,8 @@ const n8nWorkflows = [
<thead>
<tr>
<th>Nom</th>
<th>Duree</th>
<th>Majorite</th>
<th>Durée</th>
<th>Majorité</th>
<th>B</th>
<th>G</th>
<th>Smith</th>
@@ -265,28 +348,83 @@ const n8nWorkflows = [
</div>
</template>
<!-- Operational protocols (always visible, frontend-only data) -->
<div class="proto-ops">
<h3 class="proto-ops__title">
<UIcon name="i-lucide-git-branch" class="text-sm" />
Protocoles opérationnels
<span class="proto-ops__count">{{ operationalProtocols.length }}</span>
</h3>
<div
v-for="op in operationalProtocols"
:key="op.slug"
class="proto-ops__card"
>
<div class="proto-ops__card-head">
<div class="proto-ops__card-icon">
<UIcon :name="op.icon" class="text-lg" />
</div>
<div class="proto-ops__card-info">
<h4 class="proto-ops__card-name">{{ op.name }}</h4>
<p class="proto-ops__card-desc">{{ op.description }}</p>
<span class="proto-ops__card-meta">{{ op.instancesLabel }}</span>
</div>
</div>
<!-- Linked references -->
<div v-if="op.linkedRefs.length > 0" class="proto-ops__refs">
<NuxtLink
v-for="ref in op.linkedRefs"
:key="ref.to"
:to="ref.to"
class="proto-ops__ref"
:class="`proto-ops__ref--${ref.kind}`"
>
<UIcon :name="ref.icon" class="text-xs" />
<span>{{ ref.label }}</span>
<UIcon name="i-lucide-arrow-right" class="text-xs proto-ops__ref-arrow" />
</NuxtLink>
</div>
<!-- Step timeline -->
<div class="proto-ops__timeline">
<div
v-for="(step, idx) in op.steps"
:key="idx"
class="proto-ops__step"
>
<div class="proto-ops__step-dot" :class="`proto-ops__step-dot--${step.type}`">
<UIcon :name="step.icon" class="text-xs" />
</div>
<div class="proto-ops__step-body">
<span class="proto-ops__step-label">{{ step.label }}</span>
<span class="proto-ops__step-actor">{{ step.actor }}</span>
</div>
<div v-if="idx < op.steps.length - 1" class="proto-ops__step-line" />
</div>
</div>
</div>
</div>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Workflow milestones -->
<ToolboxSection title="Jalons de protocole" icon="i-lucide-git-branch">
<WorkflowMilestones />
</ToolboxSection>
<!-- Simulateur -->
<ToolboxVignette
title="Simulateur de formules"
:bullets="['Testez WoT, Smith, TechComm', 'Ajustez les parametres en temps reel', 'Visualisez les seuils']"
:bullets="['WoT, Smith, TechComm', 'Paramètres en temps réel', 'Visualise les seuils']"
:actions="[
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
]"
/>
<!-- n8n Workflows -->
<div class="n8n-section">
<div class="n8n-section__head">
<UIcon name="i-lucide-workflow" class="text-xs" />
<span>Workflows n8n</span>
</div>
<p class="n8n-section__desc">
Automatisations reliees via MCP
</p>
<ToolboxSection title="Automatisations" icon="i-lucide-workflow">
<div class="n8n-workflows">
<div
v-for="wf in n8nWorkflows"
@@ -310,16 +448,14 @@ const n8nWorkflows = [
</div>
</div>
</div>
</div>
</ToolboxSection>
<!-- Meta-gouvernance -->
<ToolboxVignette
title="Meta-gouvernance"
:bullets="['Les formules sont soumises au vote', 'Modifier les seuils collectivement', 'Transparence totale']"
title="Méta-gouvernance"
:bullets="['Les formules sont soumises au vote', 'Seuils modifiables collectivement', 'Transparence totale']"
:actions="[
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
{ label: 'Démarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
]"
/>
</template>
@@ -366,7 +502,7 @@ const n8nWorkflows = [
<USelect
v-model="newProtocol.formula_config_id"
:items="formulaOptions"
placeholder="Selectionnez une formule..."
placeholder="Sélectionnez une formule..."
value-key="value"
/>
</div>
@@ -382,7 +518,7 @@ const n8nWorkflows = [
@click="createProtocol"
>
<UIcon v-if="creating" name="i-lucide-loader-2" class="animate-spin text-xs" />
<span>Creer</span>
<span>Créer</span>
</button>
</div>
</div>
@@ -706,31 +842,6 @@ const n8nWorkflows = [
font-family: inherit !important;
}
/* --- n8n Section --- */
.n8n-section {
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.n8n-section__head {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-tertiary, var(--mood-accent));
}
.n8n-section__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
}
.n8n-workflows {
display: flex;
flex-direction: column;
@@ -802,6 +913,195 @@ const n8nWorkflows = [
margin: 0;
}
/* --- Operational protocols --- */
.proto-ops {
margin-top: 1.5rem;
}
.proto-ops__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0 0 0.75rem;
}
.proto-ops__count {
font-size: 0.6875rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
padding: 2px 8px;
border-radius: 20px;
}
.proto-ops__card {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.proto-ops__card-head {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.proto-ops__card-icon {
width: 2.75rem;
height: 2.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.proto-ops__card-info {
flex: 1;
min-width: 0;
}
.proto-ops__card-name {
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.proto-ops__card-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0.125rem 0 0;
}
.proto-ops__card-meta {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-accent);
opacity: 0.7;
}
/* Linked references */
.proto-ops__refs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.proto-ops__ref {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.proto-ops__ref:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.proto-ops__ref--document {
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
color: var(--mood-accent);
}
.proto-ops__ref--decision {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 12%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.proto-ops__ref-arrow {
opacity: 0.4;
transition: opacity 0.12s;
}
.proto-ops__ref:hover .proto-ops__ref-arrow {
opacity: 1;
}
/* Timeline */
.proto-ops__timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-left: 0.25rem;
}
.proto-ops__step {
display: flex;
align-items: center;
gap: 0.625rem;
position: relative;
padding: 0.375rem 0;
}
.proto-ops__step-dot {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
z-index: 1;
}
.proto-ops__step-dot--on_chain {
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.proto-ops__step-dot--checklist {
background: color-mix(in srgb, var(--mood-warning) 15%, transparent);
color: var(--mood-warning);
}
.proto-ops__step-dot--certification {
background: color-mix(in srgb, var(--mood-secondary) 15%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.proto-ops__step-body {
display: flex;
flex-direction: column;
}
.proto-ops__step-label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.proto-ops__step-actor {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.proto-ops__step-line {
position: absolute;
left: calc(0.875rem - 1px);
top: calc(0.375rem + 1.75rem);
width: 2px;
height: calc(100% - 1.75rem + 0.375rem);
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
}
/* --- Modal --- */
.proto-modal {
padding: 1.25rem;

View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
/**
* Tools page — lists tools grouped by main section.
* Each section shows relevant tools for Documents, Decisions, Mandates, Protocols.
*/
interface Tool {
label: string
icon: string
description: string
to?: string
status: 'ready' | 'soon'
}
interface ToolSection {
key: string
title: string
icon: string
color: string
tools: Tool[]
}
const sections: ToolSection[] = [
{
key: 'documents',
title: 'Documents',
icon: 'i-lucide-book-open',
color: 'var(--mood-accent)',
tools: [
{ label: 'Modules', icon: 'i-lucide-puzzle', description: 'Structurer un document en sections et clauses modulaires', to: '/documents', status: 'ready' },
{ label: 'Votes permanents', icon: 'i-lucide-infinity', description: 'Chaque clause est sous vote permanent, modifiable à tout moment', status: 'ready' },
{ label: 'Inertie de remplacement', icon: 'i-lucide-sliders-horizontal', description: 'Régler la difficulté de modification par section (standard, haute, très haute)', to: '/protocols/formulas', status: 'ready' },
{ label: 'Contre-propositions', icon: 'i-lucide-pen-line', description: 'Soumettre un texte alternatif soumis au vote de la communauté', status: 'ready' },
{ label: 'Ancrage IPFS', icon: 'i-lucide-hard-drive', description: 'Archiver les documents validés sur IPFS avec preuve on-chain', status: 'soon' },
],
},
{
key: 'decisions',
title: 'Décisions',
icon: 'i-lucide-scale',
color: 'var(--mood-secondary, var(--mood-accent))',
tools: [
{ label: 'Vote majoritaire WoT', icon: 'i-lucide-check-circle', description: 'Seuil adaptatif par la toile de confiance, formule g1vote', to: '/protocols/formulas', status: 'ready' },
{ label: 'Vote quadratique', icon: 'i-lucide-square-stack', description: 'Pondération dégressive pour éviter la concentration de pouvoir', status: 'soon' },
{ label: 'Vote nuancé 6 niveaux', icon: 'i-lucide-bar-chart-3', description: 'De Tout à fait contre à Tout à fait pour, avec seuil de satisfaction', status: 'ready' },
{ label: 'Mandature', icon: 'i-lucide-user-check', description: 'Élection et nomination en binôme avec transparence', status: 'ready' },
{ label: 'Multi-critères', icon: 'i-lucide-layers', description: 'Combinaison WoT + Smith + TechComm, tous doivent passer', to: '/protocols/formulas', status: 'ready' },
],
},
{
key: 'mandats',
title: 'Mandats',
icon: 'i-lucide-user-check',
color: 'var(--mood-success)',
tools: [
{ label: 'Ouverture', icon: 'i-lucide-door-open', description: 'Définir une mission, son périmètre, sa durée et ses objectifs', status: 'ready' },
{ label: 'Nomination', icon: 'i-lucide-users', description: 'Élection en binôme : un titulaire + un suppléant', status: 'ready' },
{ label: 'Transparence', icon: 'i-lucide-eye', description: 'Rapports d\'activité périodiques soumis au vote', status: 'ready' },
{ label: 'Clôture', icon: 'i-lucide-lock', description: 'Fin de mandat avec bilan ou révocation anticipée par vote', status: 'ready' },
],
},
{
key: 'protocoles',
title: 'Protocoles',
icon: 'i-lucide-settings',
color: 'var(--mood-tertiary, var(--mood-accent))',
tools: [
{ label: 'Simulateur de formules', icon: 'i-lucide-calculator', description: 'Tester les paramètres de seuil WoT en temps réel', to: '/protocols/formulas', status: 'ready' },
{ label: 'Méta-gouvernance', icon: 'i-lucide-shield', description: 'Les formules elles-mêmes sont soumises au vote', status: 'ready' },
{ label: 'Workflows n8n', icon: 'i-lucide-workflow', description: 'Automatisations optionnelles (notifications, alertes, relances)', status: 'soon' },
{ label: 'Protocoles opérationnels', icon: 'i-lucide-git-branch', description: 'Processus multi-étapes réutilisables (embarquement, upgrade)', to: '/protocols', status: 'ready' },
],
},
]
</script>
<template>
<div class="tools-page">
<!-- Back link -->
<div class="tools-page__nav">
<UButton
to="/"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour à l'accueil"
size="sm"
/>
</div>
<!-- Header -->
<div class="tools-page__header">
<h1 class="tools-page__title">
<UIcon name="i-lucide-wrench" class="tools-page__title-icon" />
Boîte à outils
</h1>
<p class="tools-page__subtitle">
Tous les outils de décision collective, organisés par section
</p>
</div>
<!-- Tool sections -->
<div class="tools-page__sections">
<div
v-for="section in sections"
:key="section.key"
class="tools-section"
:style="{ '--section-color': section.color }"
>
<div class="tools-section__header">
<UIcon :name="section.icon" class="tools-section__icon" />
<h2 class="tools-section__title">{{ section.title }}</h2>
<span class="tools-section__count">{{ section.tools.length }}</span>
</div>
<div class="tools-section__grid">
<NuxtLink
v-for="tool in section.tools.filter(t => t.to)"
:key="tool.label"
:to="tool.to!"
class="tool-card"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
<UIcon name="i-lucide-chevron-right" class="tool-card__arrow" />
</NuxtLink>
<div
v-for="tool in section.tools.filter(t => !t.to)"
:key="tool.label"
class="tool-card"
:class="{ 'tool-card--soon': tool.status === 'soon' }"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
<span v-if="tool.status === 'soon'" class="tool-card__badge">bientôt</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tools-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.tools-page__nav {
margin-bottom: -0.5rem;
}
.tools-page__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tools-page__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
@media (min-width: 640px) {
.tools-page__title {
font-size: 1.875rem;
}
}
.tools-page__title-icon {
color: var(--mood-accent);
}
.tools-page__subtitle {
font-size: 0.9375rem;
color: var(--mood-text-muted);
font-weight: 500;
}
/* Sections */
.tools-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.tools-section__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tools-section__icon {
font-size: 1.125rem;
color: var(--section-color);
}
.tools-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.tools-section__count {
font-size: 0.6875rem;
font-weight: 700;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
padding: 2px 8px;
border-radius: 20px;
}
/* Tool cards */
.tools-section__grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tool-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border-radius: 14px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.tool-card--soon {
opacity: 0.6;
cursor: default;
}
.tool-card--soon:hover {
transform: none;
box-shadow: none;
}
.tool-card__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
font-size: 0.875rem;
}
.tool-card__body {
flex: 1;
min-width: 0;
}
.tool-card__head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.tool-card__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.tool-card__badge {
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.tool-card__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0.125rem 0 0;
}
.tool-card__arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
margin-top: 0.375rem;
transition: all 0.12s;
}
.tool-card:hover .tool-card__arrow {
opacity: 1;
color: var(--section-color);
}
</style>

View File

@@ -153,7 +153,7 @@ export const useAuthStore = defineStore('auth', {
*/
hydrateFromStorage() {
if (import.meta.client) {
const stored = localStorage.getItem('glibredecision_token')
const stored = localStorage.getItem('libredecision_token')
if (stored) {
this.token = stored
}
@@ -163,14 +163,14 @@ export const useAuthStore = defineStore('auth', {
/** @internal Persist token to localStorage */
_persistToken() {
if (import.meta.client && this.token) {
localStorage.setItem('glibredecision_token', this.token)
localStorage.setItem('libredecision_token', this.token)
}
},
/** @internal Clear token from localStorage */
_clearToken() {
if (import.meta.client) {
localStorage.removeItem('glibredecision_token')
localStorage.removeItem('libredecision_token')
}
},
},

View File

@@ -13,6 +13,9 @@ export interface DocumentItem {
current_text: string
voting_protocol_id: string | null
sort_order: number
section_tag: string | null
inertia_preset: string
is_permanent_vote: boolean
created_at: string
updated_at: string
}
@@ -27,6 +30,7 @@ export interface Document {
description: string | null
ipfs_cid: string | null
chain_anchor: string | null
genesis_json: string | null
created_at: string
updated_at: string
items_count: number

View File

@@ -21,11 +21,11 @@ export default defineNuxtConfig({
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ property: 'og:title', content: 'Glibredecision' },
{ property: 'og:title', content: 'libreDecision' },
{ property: 'og:description', content: 'Decisions collectives pour la communaute Duniter/G1' },
{ property: 'og:type', content: 'website' },
],
title: 'Glibredecision',
title: 'libreDecision',
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },

View File

@@ -1,5 +1,5 @@
{
"name": "glibredecision",
"name": "libredecision",
"version": "0.1.0",
"type": "module",
"private": true,

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 100" fill="currentColor">
<!-- Hexagram 48 — 井 Tsing — Le Puits (flat) -->
<rect x="5" y="5" width="49" height="5" rx="1"/>
<rect x="76" y="5" width="49" height="5" rx="1"/>
<rect x="5" y="22" width="120" height="5" rx="1"/>
<rect x="5" y="39" width="49" height="5" rx="1"/>
<rect x="76" y="39" width="49" height="5" rx="1"/>
<rect x="5" y="56" width="120" height="5" rx="1"/>
<rect x="5" y="73" width="120" height="5" rx="1"/>
<rect x="5" y="90" width="49" height="5" rx="1"/>
<rect x="76" y="90" width="49" height="5" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 100" fill="currentColor">
<!-- Hexagram 48 — 井 Tsing — Le Puits -->
<!-- K'an (Eau) / Souen (Bois) — signature Yvv -->
<!-- Proportions calées sur les avatars hexagrammes (ratio ~1.3:1) -->
<defs>
<filter id="emboss" x="-10%" y="-10%" width="120%" height="120%">
<feComponentTransfer in="SourceAlpha" result="inv">
<feFuncA type="table" tableValues="1 0"/>
</feComponentTransfer>
<feOffset in="inv" dx="1.5" dy="1.5" result="sOff"/>
<feGaussianBlur in="sOff" stdDeviation="1" result="sBlur"/>
<feFlood flood-color="#000" flood-opacity="0.3"/>
<feComposite in2="sBlur" operator="in" result="sDark"/>
<feComposite in="sDark" in2="SourceAlpha" operator="in" result="sClip"/>
<feOffset in="inv" dx="-1" dy="-1" result="hOff"/>
<feGaussianBlur in="hOff" stdDeviation="0.8" result="hBlur"/>
<feFlood flood-color="#fff" flood-opacity="0.4"/>
<feComposite in2="hBlur" operator="in" result="hLight"/>
<feComposite in="hLight" in2="SourceAlpha" operator="in" result="hClip"/>
<feMerge>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="sClip"/>
<feMergeNode in="hClip"/>
</feMerge>
</filter>
</defs>
<g filter="url(#emboss)">
<!-- Line 6 (top) — yin -->
<rect x="5" y="5" width="49" height="5" rx="1"/>
<rect x="76" y="5" width="49" height="5" rx="1"/>
<!-- Line 5 — yang -->
<rect x="5" y="22" width="120" height="5" rx="1"/>
<!-- Line 4 — yin -->
<rect x="5" y="39" width="49" height="5" rx="1"/>
<rect x="76" y="39" width="49" height="5" rx="1"/>
<!-- Line 3 — yang -->
<rect x="5" y="56" width="120" height="5" rx="1"/>
<!-- Line 2 — yang -->
<rect x="5" y="73" width="120" height="5" rx="1"/>
<!-- Line 1 (bottom) — yin -->
<rect x="5" y="90" width="49" height="5" rx="1"/>
<rect x="76" y="90" width="49" height="5" rx="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,4 +1,4 @@
# Recherche Forum Duniter -- Donnees de reference pour Glibredecision
# Recherche Forum Duniter -- Donnees de reference pour libreDecision
Date de recherche : 2026-02-28