Compare commits
10 Commits
8dc0dfd452
...
8201e73d7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8201e73d7c | ||
|
|
c19c1aa55e | ||
|
|
4212e847d4 | ||
|
|
f087fb95c9 | ||
|
|
a1fa31c3f9 | ||
|
|
3de07e8c17 | ||
|
|
21ceae4866 | ||
|
|
0b230483d9 | ||
|
|
62808b974d | ||
|
|
11e4a4d60a |
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
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()))
|
||||
|
||||
|
||||
1774
backend/seed.py
1774
backend/seed.py
File diff suppressed because it is too large
Load Diff
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
|
||||
@@ -190,6 +190,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>
|
||||
|
||||
@@ -577,6 +596,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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()
|
||||
@@ -28,7 +28,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 +97,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 +111,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 +149,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,38 +179,60 @@ function formatDate(dateStr: string): string {
|
||||
<span class="decision-card__type-badge">
|
||||
{{ typeLabel(decision.decision_type) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="decision.decision_type === 'runtime_upgrade'"
|
||||
class="decision-card__onchain-badge"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="text-xs" />
|
||||
on-chain
|
||||
</span>
|
||||
<span class="decision-card__steps">
|
||||
<UIcon name="i-lucide-layers" class="text-xs" />
|
||||
{{ decision.steps.length }} etape{{ decision.steps.length !== 1 ? 's' : '' }}
|
||||
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="decision-card__date">
|
||||
<UIcon name="i-lucide-clock" class="text-xs" />
|
||||
{{ formatDate(decision.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Protocol link for runtime_upgrade -->
|
||||
<NuxtLink
|
||||
v-if="decision.decision_type === 'runtime_upgrade'"
|
||||
to="/protocols"
|
||||
class="decision-card__protocol-link"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
<span>Protocole : Soumission Runtime Upgrade</span>
|
||||
<UIcon name="i-lucide-arrow-right" class="text-xs" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
Modalites de vote
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux decisions', protocol.mode_params || 'Configuration standard']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
<ToolboxVignette
|
||||
title="Vote majoritaire WoT"
|
||||
:bullets="['Seuil adaptatif à la participation', 'Formule g1vote inertielle']"
|
||||
:actions="[
|
||||
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Vote nuancé"
|
||||
:bullets="['6 niveaux de préférence', 'Seuil de satisfaction 80%']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-bar-chart-3', emit: 'nuance' },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Mandature"
|
||||
:bullets="['Élection en binôme', 'Transparence et révocation']"
|
||||
:actions="[
|
||||
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', primary: true },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
@@ -333,6 +355,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;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Document detail page — full structured view with:
|
||||
* - Genesis block (source files, repos, forum synthesis, formula trigger)
|
||||
* - Sectioned items grouped by section_tag
|
||||
* - Mini vote boards per item
|
||||
* - Inertia sliders per section
|
||||
* - Permanent vote signage
|
||||
* - Tuto overlay
|
||||
*/
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -6,7 +15,6 @@ const documents = useDocumentsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const archiving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -23,11 +31,85 @@ watch(slug, async (newSlug) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Section grouping ──────────────────────────────────────────
|
||||
|
||||
interface Section {
|
||||
tag: string
|
||||
label: string
|
||||
icon: string
|
||||
inertiaPreset: string
|
||||
items: DocumentItem[]
|
||||
}
|
||||
|
||||
const SECTION_META: Record<string, { label: string; icon: string }> = {
|
||||
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
|
||||
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
|
||||
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
|
||||
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
|
||||
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
|
||||
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
|
||||
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
|
||||
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
|
||||
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
|
||||
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
|
||||
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
||||
}
|
||||
|
||||
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
|
||||
|
||||
const sections = computed((): Section[] => {
|
||||
const grouped: Record<string, DocumentItem[]> = {}
|
||||
const ungrouped: DocumentItem[] = []
|
||||
|
||||
for (const item of documents.items) {
|
||||
const tag = item.section_tag
|
||||
if (tag) {
|
||||
if (!grouped[tag]) grouped[tag] = []
|
||||
grouped[tag].push(item)
|
||||
} else {
|
||||
ungrouped.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Section[] = []
|
||||
|
||||
for (const tag of SECTION_ORDER) {
|
||||
if (grouped[tag]) {
|
||||
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
|
||||
const firstItem = grouped[tag][0]
|
||||
result.push({
|
||||
tag,
|
||||
label: meta.label,
|
||||
icon: meta.icon,
|
||||
inertiaPreset: firstItem?.inertia_preset || 'standard',
|
||||
items: grouped[tag],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ungrouped items
|
||||
if (ungrouped.length > 0) {
|
||||
result.push({
|
||||
tag: '_other',
|
||||
label: 'Autres',
|
||||
icon: 'i-lucide-file-text',
|
||||
inertiaPreset: 'standard',
|
||||
items: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalItems = computed(() => documents.items.length)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
const typeLabel = (docType: string) => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
case 'engagement': return 'Engagement'
|
||||
case 'reglement': return 'Reglement'
|
||||
case 'reglement': return 'Règlement'
|
||||
case 'constitution': return 'Constitution'
|
||||
default: return docType
|
||||
}
|
||||
@@ -55,12 +137,49 @@ async function archiveToSanctuary() {
|
||||
archiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Active section (scroll spy) ──────────────────────────────
|
||||
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
function scrollToSection(tag: string) {
|
||||
// Expand the section if collapsed
|
||||
if (collapsedSections.value[tag]) {
|
||||
collapsedSections.value[tag] = false
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`section-${tag}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
activeSection.value = tag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Collapsible sections ────────────────────────────────────
|
||||
// First 2 sections open by default, rest collapsed
|
||||
|
||||
const collapsedSections = ref<Record<string, boolean>>({})
|
||||
|
||||
watch(sections, (newSections) => {
|
||||
if (newSections.length > 0 && Object.keys(collapsedSections.value).length === 0) {
|
||||
const map: Record<string, boolean> = {}
|
||||
newSections.forEach((s, i) => {
|
||||
map[s.tag] = i >= 2 // collapsed if index >= 2
|
||||
})
|
||||
collapsedSections.value = map
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleSection(tag: string) {
|
||||
collapsedSections.value[tag] = !collapsedSections.value[tag]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="doc-page">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<div class="doc-page__nav">
|
||||
<UButton
|
||||
to="/documents"
|
||||
variant="ghost"
|
||||
@@ -94,31 +213,36 @@ async function archiveToSanctuary() {
|
||||
|
||||
<!-- Document detail -->
|
||||
<template v-else-if="documents.current">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div class="doc-page__header">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<h1 class="doc-page__title">
|
||||
{{ documents.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(documents.current.doc_type) }}
|
||||
</UBadge>
|
||||
<StatusBadge :status="documents.current.status" type="document" />
|
||||
<span class="text-sm text-gray-500 font-mono">
|
||||
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
|
||||
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
|
||||
v{{ documents.current.version }}
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--mood-text-muted)">
|
||||
{{ totalItems }} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive button for authenticated users with active documents -->
|
||||
<div v-if="auth.isAuthenticated && documents.current.status === 'active'" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<DocumentTuto />
|
||||
<UButton
|
||||
label="Archiver dans le Sanctuaire"
|
||||
v-if="auth.isAuthenticated && documents.current.status === 'active'"
|
||||
label="Archiver"
|
||||
icon="i-lucide-archive"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="archiving"
|
||||
@click="archiveToSanctuary"
|
||||
/>
|
||||
@@ -126,71 +250,361 @@ async function archiveToSanctuary() {
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="documents.current.description" class="mt-4 text-gray-600 dark:text-gray-400">
|
||||
<p v-if="documents.current.description" class="doc-page__desc">
|
||||
{{ documents.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<!-- ═══ METADATA ═══ -->
|
||||
<div class="doc-page__meta">
|
||||
<div class="doc-page__meta-grid">
|
||||
<div>
|
||||
<p class="text-gray-500">Cree le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.created_at) }}
|
||||
</p>
|
||||
<p class="doc-page__meta-label">Créé le</p>
|
||||
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.updated_at) }}
|
||||
</p>
|
||||
<p class="doc-page__meta-label">Mis à jour le</p>
|
||||
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Nombre d'items</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ documents.current.items_count }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Ancrage IPFS</p>
|
||||
<p class="doc-page__meta-label">Ancrage IPFS</p>
|
||||
<div class="mt-1">
|
||||
<IPFSLink :cid="documents.current.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor info -->
|
||||
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
|
||||
<div v-if="documents.current.chain_anchor">
|
||||
<p class="doc-page__meta-label">Ancrage on-chain</p>
|
||||
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Document items -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Contenu du document ({{ documents.items.length }} items)
|
||||
</h2>
|
||||
<!-- ═══ GENESIS BLOCK ═══ -->
|
||||
<GenesisBlock
|
||||
v-if="documents.current.genesis_json"
|
||||
:genesis-json="documents.current.genesis_json"
|
||||
/>
|
||||
|
||||
<div v-if="documents.items.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-file-plus" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun item dans ce document</p>
|
||||
</div>
|
||||
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
||||
<div v-if="sections.length > 1" class="doc-page__section-nav">
|
||||
<button
|
||||
v-for="section in sections"
|
||||
:key="section.tag"
|
||||
class="doc-page__section-pill"
|
||||
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
|
||||
@click="scrollToSection(section.tag)"
|
||||
>
|
||||
<UIcon :name="section.icon" class="text-xs" />
|
||||
{{ section.label }}
|
||||
<span class="doc-page__section-count">{{ section.items.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ItemCard
|
||||
v-for="item in documents.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:document-slug="slug"
|
||||
:show-actions="auth.isAuthenticated"
|
||||
@propose="handlePropose"
|
||||
/>
|
||||
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
||||
<div class="doc-page__sections">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.tag"
|
||||
:id="`section-${section.tag}`"
|
||||
class="doc-page__section"
|
||||
>
|
||||
<!-- Section header (clickable toggle) -->
|
||||
<button
|
||||
class="doc-page__section-header"
|
||||
@click="toggleSection(section.tag)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
|
||||
<h2 class="doc-page__section-title">
|
||||
{{ section.label }}
|
||||
</h2>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ section.items.length }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<InertiaSlider :preset="section.inertiaPreset" compact mini />
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="doc-page__section-chevron"
|
||||
:class="{ 'doc-page__section-chevron--open': !collapsedSections[section.tag] }"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Protocol link for qualification section -->
|
||||
<NuxtLink
|
||||
v-if="section.tag === 'qualification' && !collapsedSections[section.tag]"
|
||||
to="/protocols"
|
||||
class="doc-page__protocol-link"
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-sm" />
|
||||
<div>
|
||||
<span class="doc-page__protocol-link-label">Protocole lié</span>
|
||||
<span class="doc-page__protocol-link-name">Embarquement Forgeron</span>
|
||||
</div>
|
||||
<UIcon name="i-lucide-arrow-right" class="text-sm doc-page__protocol-link-arrow" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Items (collapsible) -->
|
||||
<Transition name="section-collapse">
|
||||
<div v-show="!collapsedSections[section.tag]" class="doc-page__section-items">
|
||||
<EngagementCard
|
||||
v-for="item in section.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:document-slug="slug"
|
||||
:show-actions="auth.isAuthenticated"
|
||||
@propose="handlePropose"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.doc-page__nav {
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.doc-page__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.doc-page__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__title {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.doc-page__meta {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.doc-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__meta-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__meta-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.doc-page__meta-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
/* Section navigator */
|
||||
.doc-page__section-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.doc-page__section-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-page__section-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.doc-page__section-pill:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.doc-page__section-pill--active {
|
||||
background: var(--mood-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.doc-page__section-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.doc-page__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.doc-page__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
scroll-margin-top: 4rem;
|
||||
}
|
||||
|
||||
.doc-page__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
width: 100%;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.doc-page__section-header:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.doc-page__section-chevron {
|
||||
font-size: 1rem;
|
||||
color: var(--mood-text-muted);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-page__section-chevron--open {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.doc-page__section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__section-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Protocol link */
|
||||
.doc-page__protocol-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 8%, var(--mood-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
color: var(--mood-tertiary, var(--mood-accent));
|
||||
}
|
||||
|
||||
.doc-page__protocol-link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.doc-page__protocol-link-label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.doc-page__protocol-link-name {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.doc-page__protocol-link-arrow {
|
||||
margin-left: auto;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.doc-page__protocol-link:hover .doc-page__protocol-link-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Section collapse transition */
|
||||
.section-collapse-enter-active,
|
||||
.section-collapse-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-collapse-enter-from,
|
||||
.section-collapse-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.section-collapse-enter-to,
|
||||
.section-collapse-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,7 +29,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 +48,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 +87,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 +154,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 +206,7 @@ async function createDocument() {
|
||||
style="color: var(--mood-text-muted);"
|
||||
>
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
|
||||
<p>Aucun document trouve</p>
|
||||
<p>Aucun document trouvé</p>
|
||||
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
|
||||
Essayez de modifier vos filtres
|
||||
</p>
|
||||
@@ -252,23 +251,27 @@ async function createDocument() {
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
{{ toolboxTitle }}
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux documents', protocol.mode_params || 'Configuration standard']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
<ToolboxVignette
|
||||
title="Modules"
|
||||
:bullets="['Structurer en sections et clauses', 'Vote indépendant par clause']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-puzzle', emit: 'modules' },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Votes permanents"
|
||||
:bullets="['Chaque clause est modifiable', 'Seuil adaptatif WoT']"
|
||||
:actions="[
|
||||
{ label: 'Formules', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Inertie de remplacement"
|
||||
:bullets="['4 niveaux de difficulté', 'Protège les textes fondamentaux']"
|
||||
:actions="[
|
||||
{ label: 'Simuler', icon: 'i-lucide-sliders-horizontal', to: '/protocols/formulas', primary: true },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
@@ -277,7 +280,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 +335,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 +349,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"
|
||||
|
||||
@@ -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: protocols.protocols.length,
|
||||
countLabel: `${protocols.protocols.length} modalité${protocols.protocols.length > 1 ? 's' : ''}`,
|
||||
totalLabel: 'Boîte à outils de vote + workflows',
|
||||
description: 'Modalités de vote, formules, workflows',
|
||||
color: 'var(--mood-tertiary, var(--mood-accent))',
|
||||
},
|
||||
])
|
||||
|
||||
const recentDecisions = computed(() => {
|
||||
@@ -81,7 +81,7 @@ function formatDate(dateStr: string): string {
|
||||
|
||||
if (diffHours < 1) {
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
return diffMinutes <= 1 ? 'A l\'instant' : `Il y a ${diffMinutes} min`
|
||||
return diffMinutes <= 1 ? 'À l\'instant' : `Il y a ${diffMinutes} min`
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `Il y a ${Math.floor(diffHours)}h`
|
||||
@@ -101,7 +101,7 @@ function formatDate(dateStr: string): string {
|
||||
<span class="dash__title-g">ğ</span><span class="dash__title-paren">(</span>Decision<span class="dash__title-paren">)</span>
|
||||
</h1>
|
||||
<p class="dash__subtitle">
|
||||
Decisions collectives pour la communaute Duniter / G1
|
||||
Décisions collectives pour la communauté Duniter / G1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,7 @@ function formatDate(dateStr: string): string {
|
||||
<div class="dash__connect-left">
|
||||
<UIcon name="i-lucide-key-round" class="text-lg" />
|
||||
<div>
|
||||
<p class="dash__connect-text">Connectez-vous avec votre identite Duniter pour participer.</p>
|
||||
<p class="dash__connect-text">Connectez-vous avec votre identité Duniter pour participer.</p>
|
||||
<p class="dash__connect-hint">Signature Ed25519 · aucun mot de passe</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,41 +151,33 @@ function formatDate(dateStr: string): string {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Toolbox teaser -->
|
||||
<div class="dash__toolbox">
|
||||
<div class="dash__toolbox-head">
|
||||
<UIcon name="i-lucide-wrench" class="text-lg" />
|
||||
<h3>Boite a outils</h3>
|
||||
<span class="dash__toolbox-count">{{ protocols.protocols.length }}</span>
|
||||
<!-- Toolbox teaser (5th block, distinct look) -->
|
||||
<NuxtLink to="/tools" class="dash__toolbox-card">
|
||||
<div class="dash__toolbox-card-inner">
|
||||
<div class="dash__toolbox-card-icon">
|
||||
<UIcon name="i-lucide-wrench" class="text-xl" />
|
||||
</div>
|
||||
<div class="dash__toolbox-card-body">
|
||||
<h3 class="dash__toolbox-card-title">Boîte à outils</h3>
|
||||
<p class="dash__toolbox-card-desc">
|
||||
Simulateur de formules, modules de vote, workflows
|
||||
</p>
|
||||
<div class="dash__toolbox-card-tags">
|
||||
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
||||
<span class="dash__toolbox-card-tag">Inertie</span>
|
||||
<span class="dash__toolbox-card-tag">Smith</span>
|
||||
<span class="dash__toolbox-card-tag">Nuance</span>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
||||
</div>
|
||||
<div class="dash__toolbox-tags">
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<NuxtLink
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:to="`/protocols/${protocol.id}`"
|
||||
class="dash__tag"
|
||||
>
|
||||
{{ protocol.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dash__tag">Vote WoT</span>
|
||||
<span class="dash__tag">Vote nuance</span>
|
||||
<span class="dash__tag">Vote permanent</span>
|
||||
</template>
|
||||
</div>
|
||||
<NuxtLink to="/protocols" class="dash__toolbox-link">
|
||||
Voir la boite a outils
|
||||
<UIcon name="i-lucide-chevron-right" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<div v-if="recentDecisions.length > 0" class="dash__activity">
|
||||
<div class="dash__activity-head">
|
||||
<UIcon name="i-lucide-activity" class="text-lg" />
|
||||
<h3>Activite recente</h3>
|
||||
<h3>Activité récente</h3>
|
||||
</div>
|
||||
<div class="dash__activity-list">
|
||||
<NuxtLink
|
||||
@@ -215,7 +207,7 @@ function formatDate(dateStr: string): string {
|
||||
<template #content>
|
||||
<div class="dash__formula-body">
|
||||
<p class="dash__formula-desc">
|
||||
Le seuil s'adapte a la participation : faible = quasi-unanimite ; forte = majorite simple.
|
||||
Le seuil s'adapte à la participation : faible = quasi-unanimité ; forte = majorité simple.
|
||||
</p>
|
||||
<code class="dash__formula-code">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
@@ -225,7 +217,7 @@ function formatDate(dateStr: string): string {
|
||||
<span>B = base</span>
|
||||
<span>W = taille WoT</span>
|
||||
<span>T = votes</span>
|
||||
<span>M = majorite</span>
|
||||
<span>M = majorité</span>
|
||||
<span>G = gradient</span>
|
||||
</div>
|
||||
<NuxtLink to="/protocols/formulas" class="dash__formula-link">
|
||||
@@ -292,7 +284,7 @@ function formatDate(dateStr: string): string {
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dash__entries {
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -460,73 +452,91 @@ function formatDate(dateStr: string): string {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* --- Toolbox teaser --- */
|
||||
.dash__toolbox {
|
||||
background: var(--mood-surface);
|
||||
/* --- Toolbox card (5th block, distinct) --- */
|
||||
.dash__toolbox-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
border-left: 4px solid var(--mood-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dash__toolbox {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.dash__toolbox-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.dash__toolbox-head {
|
||||
.dash__toolbox-card-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dash__toolbox-card-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--mood-accent);
|
||||
font-weight: 800;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.dash__toolbox-head h3 { margin: 0; }
|
||||
|
||||
.dash__toolbox-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.dash__toolbox-tags {
|
||||
.dash__toolbox-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dash__toolbox-card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dash__toolbox-card-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dash__toolbox-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.dash__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.dash__tag:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dash__toolbox-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dash__toolbox-card-tag {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
text-decoration: none;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.dash__toolbox-link:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
.dash__toolbox-card-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.3;
|
||||
margin-top: 0.375rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dash__toolbox-card:hover .dash__toolbox-card-arrow {
|
||||
opacity: 1;
|
||||
color: var(--mood-accent);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* --- Activity --- */
|
||||
|
||||
@@ -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,23 +272,34 @@ async function handleCreate() {
|
||||
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
Modalites de vote
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:bullets="['Applicable aux mandats', protocol.mode_params || 'Configuration standard']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
<ToolboxVignette
|
||||
title="Ouverture"
|
||||
:bullets="['Définir mission et périmètre', 'Durée et objectifs clairs']"
|
||||
:actions="[
|
||||
{ label: 'Créer', icon: 'i-lucide-door-open', emit: 'create', primary: true },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Nomination"
|
||||
:bullets="['Élection en binôme', 'Titulaire + suppléant']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-users', emit: 'nomination' },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Transparence"
|
||||
:bullets="['Rapports d\'activité', 'Soumis au vote communautaire']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', emit: 'transparence' },
|
||||
]"
|
||||
/>
|
||||
<ToolboxVignette
|
||||
title="Cloture"
|
||||
:bullets="['Fin de mandat ou révocation', 'Bilan et transmission']"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-lock', emit: 'cloture' },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
@@ -341,7 +352,7 @@ async function handleCreate() {
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Creer"
|
||||
label="Créer"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
:loading="creating"
|
||||
|
||||
@@ -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,76 @@ 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: '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 +189,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 +206,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 +248,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">
|
||||
@@ -229,6 +294,65 @@ const n8nWorkflows = [
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Operational protocols (workflow templates) -->
|
||||
<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>
|
||||
|
||||
<!-- Formulas table -->
|
||||
<div class="proto-formulas">
|
||||
<h3 class="proto-formulas__title">
|
||||
@@ -241,8 +365,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>
|
||||
@@ -270,7 +394,7 @@ const n8nWorkflows = [
|
||||
<!-- Simulateur -->
|
||||
<ToolboxVignette
|
||||
title="Simulateur de formules"
|
||||
:bullets="['Testez WoT, Smith, TechComm', 'Ajustez les parametres en temps reel', 'Visualisez les seuils']"
|
||||
:bullets="['Testez WoT, Smith, TechComm', 'Ajustez les paramètres en temps réel', 'Visualisez les seuils']"
|
||||
:actions="[
|
||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
||||
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||
@@ -284,7 +408,7 @@ const n8nWorkflows = [
|
||||
<span>Workflows n8n</span>
|
||||
</div>
|
||||
<p class="n8n-section__desc">
|
||||
Automatisations reliees via MCP
|
||||
Automatisations reliées via MCP
|
||||
</p>
|
||||
|
||||
<div class="n8n-workflows">
|
||||
@@ -314,12 +438,12 @@ const n8nWorkflows = [
|
||||
|
||||
<!-- Meta-gouvernance -->
|
||||
<ToolboxVignette
|
||||
title="Meta-gouvernance"
|
||||
title="Méta-gouvernance"
|
||||
:bullets="['Les formules sont soumises au vote', 'Modifier les seuils 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 +490,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 +506,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>
|
||||
@@ -802,6 +926,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>
|
||||
@@ -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
|
||||
|
||||
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 |
Reference in New Issue
Block a user