Compare commits
13 Commits
8dc0dfd452
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9ed11cd4 | ||
|
|
290548703d | ||
|
|
316d205593 | ||
|
|
8201e73d7c | ||
|
|
c19c1aa55e | ||
|
|
4212e847d4 | ||
|
|
f087fb95c9 | ||
|
|
a1fa31c3f9 | ||
|
|
3de07e8c17 | ||
|
|
21ceae4866 | ||
|
|
0b230483d9 | ||
|
|
62808b974d | ||
|
|
11e4a4d60a |
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Glibredecision
|
||||
# libreDecision
|
||||
|
||||
Plateforme de decisions collectives pour la communaute Duniter/G1.
|
||||
|
||||
|
||||
52
CONTRIBUTING.md
Normal file
52
CONTRIBUTING.md
Normal 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
32
README.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
856
backend/app/tests/test_doc_protocol_integration.py
Normal file
856
backend/app/tests/test_doc_protocol_integration.py
Normal 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
|
||||
@@ -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()))
|
||||
|
||||
|
||||
1776
backend/seed.py
1776
backend/seed.py
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
239
docs/content/dev/10.spike-workflow-engine.md
Normal file
239
docs/content/dev/10.spike-workflow-engine.md
Normal 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
|
||||
1101
docs/content/dev/11.governance-modalities.md
Normal file
1101
docs/content/dev/11.governance-modalities.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Decisions
|
||||
description: Guide des processus decisionnels sur Glibredecision
|
||||
description: Guide des processus decisionnels sur libreDecision
|
||||
---
|
||||
|
||||
# Decisions
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Mandats
|
||||
description: Guide des mandats sur Glibredecision
|
||||
description: Guide des mandats sur libreDecision
|
||||
---
|
||||
|
||||
# Mandats
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -99,8 +99,11 @@ function isActive(to: string) {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Center: Mood switcher (desktop) -->
|
||||
<!-- 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 {
|
||||
|
||||
@@ -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">
|
||||
<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 class="section__toolbox-head">
|
||||
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
|
||||
<span>{{ toolboxTitle }}</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-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>
|
||||
272
frontend/app/components/WorkspaceSelector.vue
Normal file
272
frontend/app/components/WorkspaceSelector.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +50,12 @@ function handleAction(action: ToolboxAction) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vignette">
|
||||
<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>
|
||||
@@ -64,25 +72,60 @@ function handleAction(action: ToolboxAction) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
|
||||
126
frontend/app/components/documents/DocumentTuto.vue
Normal file
126
frontend/app/components/documents/DocumentTuto.vue
Normal 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>
|
||||
303
frontend/app/components/documents/EngagementCard.vue
Normal file
303
frontend/app/components/documents/EngagementCard.vue
Normal 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>
|
||||
489
frontend/app/components/documents/GenesisBlock.vue
Normal file
489
frontend/app/components/documents/GenesisBlock.vue
Normal 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>
|
||||
419
frontend/app/components/documents/InertiaSlider.vue
Normal file
419
frontend/app/components/documents/InertiaSlider.vue
Normal 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>
|
||||
240
frontend/app/components/documents/MiniVoteBoard.vue
Normal file
240
frontend/app/components/documents/MiniVoteBoard.vue
Normal 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>
|
||||
659
frontend/app/components/toolbox/ContextMapper.vue
Normal file
659
frontend/app/components/toolbox/ContextMapper.vue
Normal 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 + (1−M)·(1−(T/W)^G))·max(0,T−C)\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 + (1−M)·(1−(T/W)^G))·max(0,T−C)\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 + (1−M)·(1−(T/W)^G))·max(0,T−C)',
|
||||
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>
|
||||
666
frontend/app/components/toolbox/SocioElection.vue
Normal file
666
frontend/app/components/toolbox/SocioElection.vue
Normal 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>
|
||||
90
frontend/app/components/toolbox/ToolboxSection.vue
Normal file
90
frontend/app/components/toolbox/ToolboxSection.vue
Normal 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>
|
||||
551
frontend/app/components/toolbox/WorkflowMilestones.vue
Normal file
551
frontend/app/components/toolbox/WorkflowMilestones.vue
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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">
|
||||
<!-- Context mapper -->
|
||||
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
|
||||
<ContextMapper @use="handleMethodSelect" />
|
||||
</ToolboxSection>
|
||||
|
||||
<!-- Vote inertiel WoT -->
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux decisions', protocol.mode_params || 'Configuration standard']"
|
||||
title="Vote inertiel WoT"
|
||||
:bullets="[
|
||||
'Seuil adaptatif à la participation',
|
||||
'Faible participation → quasi-unanimité',
|
||||
'Formule g1vote — tracé on-chain',
|
||||
]"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
{ 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>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -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 -->
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div class="doc-page__header">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-start justify-between">
|
||||
<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,63 +250,107 @@ 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ItemCard
|
||||
v-for="item in documents.items"
|
||||
<!-- ═══ GENESIS BLOCK ═══ -->
|
||||
<GenesisBlock
|
||||
v-if="documents.current.genesis_json"
|
||||
:genesis-json="documents.current.genesis_json"
|
||||
/>
|
||||
|
||||
<!-- ═══ 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>
|
||||
|
||||
<!-- ═══ 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"
|
||||
@@ -190,7 +358,253 @@ async function archiveToSanctuary() {
|
||||
@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>
|
||||
|
||||
@@ -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,24 +286,54 @@ async function createDocument() {
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
{{ toolboxTitle }}
|
||||
<!-- 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>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<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
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux documents', protocol.mode_params || 'Configuration standard']"
|
||||
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: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
{ 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>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- New document modal -->
|
||||
@@ -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 --- */
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dash__toolbox {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
border-left: 4px solid var(--mood-accent);
|
||||
}
|
||||
|
||||
.dash__toolbox-head {
|
||||
.dash__toolbox-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.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 --- */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,24 +272,40 @@ async function handleCreate() {
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
Modalites de vote
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<!-- Sociocratic election guide -->
|
||||
<ToolboxSection title="Nomination & Élection" icon="i-lucide-users">
|
||||
<SocioElection />
|
||||
</ToolboxSection>
|
||||
|
||||
<!-- Mandat cycle -->
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux mandats', protocol.mode_params || 'Configuration standard']"
|
||||
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: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
{ 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>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- Create mandate modal -->
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
332
frontend/app/pages/tools.vue
Normal file
332
frontend/app/pages/tools.vue
Normal 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>
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "glibredecision",
|
||||
"name": "libredecision",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
12
frontend/public/hexagram-tsing-flat.svg
Normal file
12
frontend/public/hexagram-tsing-flat.svg
Normal 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 |
46
frontend/public/hexagram-tsing.svg
Normal file
46
frontend/public/hexagram-tsing.svg
Normal 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 |
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user