Restructure Engagement Forgeron + fix GenesisBlock + InertiaSlider
- Seed: restructure Engagement Forgeron (51→59 items) avec 3 nouvelles sections: Engagements fondamentaux (EF1-EF3), Engagements techniques (ET1-ET3), Qualification (Q0-Q1) liée au protocole Embarquement - Seed: ajout protocole Embarquement Forgeron (5 jalons: candidature, miroir, évaluation, certification Smith, mise en ligne) - GenesisBlock: fix lisibilité — fond mood-surface teinté accent au lieu de mood-text inversé, texte mood-aware au lieu de rgba blanc hardcodé - InertiaSlider: mini affiche "Inertie" sous le curseur, compact en width:fit-content pour s'adapter au label - Frontend: ajout section qualification dans SECTION_META/SECTION_ORDER - Pages, composants et tests des sprints précédents Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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.description = description
|
||||||
doc.ipfs_cid = None
|
doc.ipfs_cid = None
|
||||||
doc.chain_anchor = None
|
doc.chain_anchor = None
|
||||||
|
doc.genesis_json = None
|
||||||
doc.created_at = datetime.now(timezone.utc)
|
doc.created_at = datetime.now(timezone.utc)
|
||||||
doc.updated_at = datetime.now(timezone.utc)
|
doc.updated_at = datetime.now(timezone.utc)
|
||||||
doc.items = []
|
doc.items = []
|
||||||
@@ -68,6 +69,9 @@ def _make_item_mock(
|
|||||||
item.current_text = current_text
|
item.current_text = current_text
|
||||||
item.voting_protocol_id = None
|
item.voting_protocol_id = None
|
||||||
item.sort_order = sort_order
|
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.created_at = datetime.now(timezone.utc)
|
||||||
item.updated_at = datetime.now(timezone.utc)
|
item.updated_at = datetime.now(timezone.utc)
|
||||||
return item
|
return item
|
||||||
@@ -135,8 +139,8 @@ class TestDocumentOutSchema:
|
|||||||
|
|
||||||
expected_fields = {
|
expected_fields = {
|
||||||
"id", "slug", "title", "doc_type", "version", "status",
|
"id", "slug", "title", "doc_type", "version", "status",
|
||||||
"description", "ipfs_cid", "chain_anchor", "created_at",
|
"description", "ipfs_cid", "chain_anchor", "genesis_json",
|
||||||
"updated_at", "items_count",
|
"created_at", "updated_at", "items_count",
|
||||||
}
|
}
|
||||||
assert expected_fields.issubset(set(data.keys()))
|
assert expected_fields.issubset(set(data.keys()))
|
||||||
|
|
||||||
|
|||||||
1033
backend/seed.py
1033
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
|
||||||
@@ -72,22 +72,22 @@ const renderedHtml = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.md-rendered {
|
.md-rendered {
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
line-height: 1.65;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-rendered :deep(.md-list) {
|
.md-rendered :deep(.md-list) {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
padding-left: 1.25em;
|
padding-left: 1.25em;
|
||||||
margin: 0.25em 0;
|
margin: 0.125em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-rendered :deep(.md-list li) {
|
.md-rendered :deep(.md-list li) {
|
||||||
padding: 0.05em 0;
|
padding: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-rendered :deep(.md-para) {
|
.md-rendered :deep(.md-para) {
|
||||||
margin: 0.2em 0;
|
margin: 0.1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-rendered :deep(.md-code) {
|
.md-rendered :deep(.md-code) {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const steps = [
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<UButton
|
<UButton
|
||||||
label="Comment ça marche ?"
|
|
||||||
icon="i-lucide-circle-help"
|
icon="i-lucide-circle-help"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -257,17 +257,17 @@ function navigateToItem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.engagement-card__body {
|
.engagement-card__body {
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 0.5rem 1rem 0.625rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.7;
|
line-height: 1.5;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.engagement-card__body {
|
.engagement-card__body {
|
||||||
font-size: 1rem;
|
font-size: 0.9375rem;
|
||||||
line-height: 1.75;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ function navigateToItem() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.engagement-card__inertia {
|
.engagement-card__inertia {
|
||||||
padding: 0.375rem 1rem;
|
padding: 0.25rem 1rem 0.5rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,6 @@ const genesis = computed((): GenesisData | null => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'rejected': return 'error'
|
|
||||||
case 'in_progress': return 'warning'
|
|
||||||
case 'reference': return 'info'
|
|
||||||
default: return 'neutral'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
const statusLabel = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'rejected': return 'Rejetée'
|
case 'rejected': return 'Rejetée'
|
||||||
@@ -67,6 +58,15 @@ const statusLabel = (status: string) => {
|
|||||||
default: return status
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,18 +81,17 @@ const statusLabel = (status: string) => {
|
|||||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wide" style="color: var(--mood-accent)">
|
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
|
||||||
Bloc de genèse
|
Bloc de genèse
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs" style="color: var(--mood-text-muted)">
|
<p class="text-xs genesis-text-muted">
|
||||||
Sources, références et formule de dépôt
|
Sources, références et formule de dépôt
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-lg"
|
class="text-lg genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -108,13 +107,12 @@ const statusLabel = (status: string) => {
|
|||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-sm"
|
class="text-sm genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.source" class="genesis-section__content">
|
<div v-if="sectionOpen.source" class="genesis-section__content">
|
||||||
<div class="genesis-card">
|
<div class="genesis-card">
|
||||||
<p class="font-medium text-sm" style="color: var(--mood-text)">
|
<p class="font-medium text-sm genesis-text">
|
||||||
{{ genesis.source_document.title }}
|
{{ genesis.source_document.title }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col gap-1 mt-2">
|
<div class="flex flex-col gap-1 mt-2">
|
||||||
@@ -150,8 +148,7 @@ const statusLabel = (status: string) => {
|
|||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-sm"
|
class="text-sm genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
||||||
@@ -164,10 +161,10 @@ const statusLabel = (status: string) => {
|
|||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="genesis-card genesis-card--tool"
|
class="genesis-card genesis-card--tool"
|
||||||
>
|
>
|
||||||
<span class="text-xs font-semibold capitalize" style="color: var(--mood-text)">
|
<span class="text-xs font-semibold capitalize genesis-text">
|
||||||
{{ name.replace(/_/g, ' ') }}
|
{{ name.replace(/_/g, ' ') }}
|
||||||
</span>
|
</span>
|
||||||
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
|
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,8 +179,7 @@ const statusLabel = (status: string) => {
|
|||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-sm"
|
class="text-sm genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
||||||
@@ -197,19 +193,17 @@ const statusLabel = (status: string) => {
|
|||||||
class="genesis-card genesis-card--forum"
|
class="genesis-card genesis-card--forum"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="text-xs font-medium" style="color: var(--mood-text)">
|
<span class="text-xs font-medium genesis-text">
|
||||||
{{ topic.title }}
|
{{ topic.title }}
|
||||||
</span>
|
</span>
|
||||||
<UBadge
|
<span
|
||||||
:color="statusColor(topic.status)"
|
class="genesis-status shrink-0"
|
||||||
variant="subtle"
|
:class="statusClass(topic.status)"
|
||||||
size="xs"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
>
|
||||||
{{ statusLabel(topic.status) }}
|
{{ statusLabel(topic.status) }}
|
||||||
</UBadge>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
|
<span v-if="topic.posts" class="text-xs genesis-text-muted">
|
||||||
{{ topic.posts }} messages
|
{{ topic.posts }} messages
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -226,13 +220,12 @@ const statusLabel = (status: string) => {
|
|||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-sm"
|
class="text-sm genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.process" class="genesis-section__content">
|
<div v-if="sectionOpen.process" class="genesis-section__content">
|
||||||
<div class="genesis-card">
|
<div class="genesis-card">
|
||||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
|
<p class="text-xs leading-relaxed genesis-text">
|
||||||
{{ genesis.formula_trigger }}
|
{{ genesis.formula_trigger }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,8 +241,7 @@ const statusLabel = (status: string) => {
|
|||||||
</h4>
|
</h4>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
class="text-sm"
|
class="text-sm genesis-muted-icon"
|
||||||
style="color: var(--mood-text-muted)"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
||||||
@@ -259,8 +251,8 @@ const statusLabel = (status: string) => {
|
|||||||
:key="c.name"
|
:key="c.name"
|
||||||
class="genesis-contributor"
|
class="genesis-contributor"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-xs" style="color: var(--mood-text)">{{ c.name }}</span>
|
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
|
||||||
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
|
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,10 +264,10 @@ const statusLabel = (status: string) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.genesis-block {
|
.genesis-block {
|
||||||
background: var(--mood-surface);
|
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;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.genesis-block__header {
|
.genesis-block__header {
|
||||||
@@ -290,7 +282,15 @@ const statusLabel = (status: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genesis-block__header:hover {
|
.genesis-block__header:hover {
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
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 {
|
.genesis-block__icon {
|
||||||
@@ -300,7 +300,7 @@ const statusLabel = (status: string) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||||
color: var(--mood-accent);
|
color: var(--mood-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ const statusLabel = (status: string) => {
|
|||||||
.genesis-section {
|
.genesis-section {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: color-mix(in srgb, var(--mood-accent) 2%, transparent);
|
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.genesis-section__toggle {
|
.genesis-section__toggle {
|
||||||
@@ -330,7 +330,7 @@ const statusLabel = (status: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genesis-section__toggle:hover {
|
.genesis-section__toggle:hover {
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.genesis-section__title {
|
.genesis-section__title {
|
||||||
@@ -345,6 +345,10 @@ const statusLabel = (status: string) => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genesis-section__toggle .text-sm {
|
||||||
|
color: var(--mood-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.genesis-section__content {
|
.genesis-section__content {
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 0.75rem 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -352,7 +356,13 @@ const statusLabel = (status: string) => {
|
|||||||
.genesis-card {
|
.genesis-card {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
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 {
|
.genesis-card--tool {
|
||||||
@@ -363,8 +373,12 @@ const statusLabel = (status: string) => {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genesis-card--tool .text-xs {
|
||||||
|
color: var(--mood-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.genesis-card--tool:hover {
|
.genesis-card--tool:hover {
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.genesis-card--forum {
|
.genesis-card--forum {
|
||||||
@@ -376,7 +390,7 @@ const statusLabel = (status: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genesis-card--forum:hover {
|
.genesis-card--forum:hover {
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.genesis-link {
|
.genesis-link {
|
||||||
@@ -398,7 +412,64 @@ const statusLabel = (status: string) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
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 */
|
/* Expand/collapse transition */
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
preset: string
|
preset: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
mini?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
compact: false,
|
compact: false,
|
||||||
|
mini: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface InertiaLevel {
|
interface InertiaLevel {
|
||||||
@@ -105,7 +107,7 @@ const allCurves = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="inertia" :class="{ 'inertia--compact': compact }">
|
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
|
||||||
<!-- Slider track -->
|
<!-- Slider track -->
|
||||||
<div class="inertia__track">
|
<div class="inertia__track">
|
||||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
||||||
@@ -124,7 +126,12 @@ const allCurves = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Label row -->
|
<!-- Label row -->
|
||||||
<div class="inertia__info">
|
<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 }">
|
<span class="inertia__label" :style="{ color: level.color }">
|
||||||
{{ level.label }}
|
{{ level.label }}
|
||||||
</span>
|
</span>
|
||||||
@@ -223,6 +230,23 @@ const allCurves = computed(() => {
|
|||||||
|
|
||||||
.inertia--compact {
|
.inertia--compact {
|
||||||
gap: 0.25rem;
|
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 {
|
.inertia__track {
|
||||||
@@ -295,6 +319,13 @@ const allCurves = computed(() => {
|
|||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inertia__label--mini {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inertia__params {
|
.inertia__params {
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
|||||||
@@ -194,23 +194,27 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
<div class="toolbox-section-title">
|
<ToolboxVignette
|
||||||
Modalites de vote
|
title="Vote majoritaire WoT"
|
||||||
</div>
|
:bullets="['Seuil adaptatif a la participation', 'Formule g1vote inertielle']"
|
||||||
<template v-if="protocols.protocols.length > 0">
|
:actions="[
|
||||||
<ToolboxVignette
|
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||||
v-for="protocol in protocols.protocols"
|
]"
|
||||||
:key="protocol.id"
|
/>
|
||||||
:title="protocol.name"
|
<ToolboxVignette
|
||||||
:bullets="['Applicable aux decisions', protocol.mode_params || 'Configuration standard']"
|
title="Vote nuance"
|
||||||
:actions="[
|
:bullets="['6 niveaux de preference', 'Seuil de satisfaction 80%']"
|
||||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
:actions="[
|
||||||
]"
|
{ label: 'Voir', icon: 'i-lucide-bar-chart-3', emit: 'nuance' },
|
||||||
/>
|
]"
|
||||||
</template>
|
/>
|
||||||
<p v-else class="toolbox-empty-text">
|
<ToolboxVignette
|
||||||
Aucun protocole configure
|
title="Mandature"
|
||||||
</p>
|
:bullets="['Election en binome', 'Transparence et revocation']"
|
||||||
|
:actions="[
|
||||||
|
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', primary: true },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SectionLayout>
|
</SectionLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const SECTION_META: Record<string, { label: string; icon: string }> = {
|
|||||||
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
|
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
|
||||||
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
|
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
|
||||||
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
|
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' },
|
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
|
||||||
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
|
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
|
||||||
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
|
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
|
||||||
@@ -52,7 +55,7 @@ const SECTION_META: Record<string, { label: string; icon: string }> = {
|
|||||||
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
|
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
|
||||||
|
|
||||||
const sections = computed((): Section[] => {
|
const sections = computed((): Section[] => {
|
||||||
const grouped: Record<string, DocumentItem[]> = {}
|
const grouped: Record<string, DocumentItem[]> = {}
|
||||||
@@ -140,11 +143,36 @@ async function archiveToSanctuary() {
|
|||||||
const activeSection = ref<string | null>(null)
|
const activeSection = ref<string | null>(null)
|
||||||
|
|
||||||
function scrollToSection(tag: string) {
|
function scrollToSection(tag: string) {
|
||||||
const el = document.getElementById(`section-${tag}`)
|
// Expand the section if collapsed
|
||||||
if (el) {
|
if (collapsedSections.value[tag]) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
collapsedSections.value[tag] = false
|
||||||
activeSection.value = tag
|
|
||||||
}
|
}
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -280,8 +308,11 @@ function scrollToSection(tag: string) {
|
|||||||
:id="`section-${section.tag}`"
|
:id="`section-${section.tag}`"
|
||||||
class="doc-page__section"
|
class="doc-page__section"
|
||||||
>
|
>
|
||||||
<!-- Section header -->
|
<!-- Section header (clickable toggle) -->
|
||||||
<div class="doc-page__section-header">
|
<button
|
||||||
|
class="doc-page__section-header"
|
||||||
|
@click="toggleSection(section.tag)"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
|
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
|
||||||
<h2 class="doc-page__section-title">
|
<h2 class="doc-page__section-title">
|
||||||
@@ -291,20 +322,29 @@ function scrollToSection(tag: string) {
|
|||||||
{{ section.items.length }}
|
{{ section.items.length }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<InertiaSlider :preset="section.inertiaPreset" compact class="max-w-48" />
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- Items -->
|
<!-- Items (collapsible) -->
|
||||||
<div class="doc-page__section-items">
|
<Transition name="section-collapse">
|
||||||
<EngagementCard
|
<div v-show="!collapsedSections[section.tag]" class="doc-page__section-items">
|
||||||
v-for="item in section.items"
|
<EngagementCard
|
||||||
:key="item.id"
|
v-for="item in section.items"
|
||||||
:item="item"
|
:key="item.id"
|
||||||
:document-slug="slug"
|
:item="item"
|
||||||
:show-actions="auth.isAuthenticated"
|
:document-slug="slug"
|
||||||
@propose="handlePropose"
|
:show-actions="auth.isAuthenticated"
|
||||||
/>
|
@propose="handlePropose"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -449,6 +489,27 @@ function scrollToSection(tag: string) {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
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 {
|
.doc-page__section-title {
|
||||||
@@ -469,4 +530,22 @@ function scrollToSection(tag: string) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ const filteredDocuments = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Toolbox vignettes from protocols. */
|
/** Toolbox vignettes from protocols. */
|
||||||
const toolboxTitle = 'Modalites de vote'
|
|
||||||
|
|
||||||
const typeLabel = (docType: string): string => {
|
const typeLabel = (docType: string): string => {
|
||||||
switch (docType) {
|
switch (docType) {
|
||||||
@@ -252,23 +251,27 @@ async function createDocument() {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
<div class="toolbox-section-title">
|
<ToolboxVignette
|
||||||
{{ toolboxTitle }}
|
title="Modules"
|
||||||
</div>
|
:bullets="['Structurer en sections et clauses', 'Vote independant par clause']"
|
||||||
<template v-if="protocols.protocols.length > 0">
|
:actions="[
|
||||||
<ToolboxVignette
|
{ label: 'Voir', icon: 'i-lucide-puzzle', emit: 'modules' },
|
||||||
v-for="protocol in protocols.protocols"
|
]"
|
||||||
:key="protocol.id"
|
/>
|
||||||
:title="protocol.name"
|
<ToolboxVignette
|
||||||
:bullets="['Applicable aux documents', protocol.mode_params || 'Configuration standard']"
|
title="Votes permanents"
|
||||||
:actions="[
|
:bullets="['Chaque clause est modifiable', 'Seuil adaptatif WoT']"
|
||||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
:actions="[
|
||||||
]"
|
{ label: 'Formules', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||||
/>
|
]"
|
||||||
</template>
|
/>
|
||||||
<p v-else class="toolbox-empty-text">
|
<ToolboxVignette
|
||||||
Aucun protocole configure
|
title="Inertie de remplacement"
|
||||||
</p>
|
:bullets="['4 niveaux de difficulte', 'Protege les textes fondamentaux']"
|
||||||
|
:actions="[
|
||||||
|
{ label: 'Simuler', icon: 'i-lucide-sliders-horizontal', to: '/protocols/formulas', primary: true },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SectionLayout>
|
</SectionLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,6 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const entryCards = computed(() => [
|
const entryCards = computed(() => [
|
||||||
{
|
|
||||||
key: 'documents',
|
|
||||||
title: 'Documents',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: '/documents',
|
|
||||||
count: documents.activeDocuments.length,
|
|
||||||
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
|
|
||||||
totalLabel: `${documents.list.length} au total`,
|
|
||||||
description: 'Textes fondateurs sous vote permanent',
|
|
||||||
color: 'var(--mood-accent)',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'decisions',
|
key: 'decisions',
|
||||||
title: 'Decisions',
|
title: 'Decisions',
|
||||||
@@ -44,15 +33,15 @@ const entryCards = computed(() => [
|
|||||||
color: 'var(--mood-secondary, var(--mood-accent))',
|
color: 'var(--mood-secondary, var(--mood-accent))',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'protocoles',
|
key: 'documents',
|
||||||
title: 'Protocoles',
|
title: 'Documents',
|
||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-book-open',
|
||||||
to: '/protocols',
|
to: '/documents',
|
||||||
count: protocols.protocols.length,
|
count: documents.activeDocuments.length,
|
||||||
countLabel: `${protocols.protocols.length} modalite${protocols.protocols.length > 1 ? 's' : ''}`,
|
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
|
||||||
totalLabel: 'Boite a outils de vote + workflows',
|
totalLabel: `${documents.list.length} au total`,
|
||||||
description: 'Modalites de vote, formules, workflows n8n',
|
description: 'Textes fondateurs sous vote permanent',
|
||||||
color: 'var(--mood-tertiary, var(--mood-accent))',
|
color: 'var(--mood-accent)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandats',
|
key: 'mandats',
|
||||||
@@ -65,6 +54,17 @@ const entryCards = computed(() => [
|
|||||||
description: 'Missions deleguees avec nomination en binome',
|
description: 'Missions deleguees avec nomination en binome',
|
||||||
color: 'var(--mood-success)',
|
color: 'var(--mood-success)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
color: 'var(--mood-tertiary, var(--mood-accent))',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const recentDecisions = computed(() => {
|
const recentDecisions = computed(() => {
|
||||||
@@ -151,35 +151,27 @@ function formatDate(dateStr: string): string {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbox teaser -->
|
<!-- Toolbox teaser (5th block, distinct look) -->
|
||||||
<div class="dash__toolbox">
|
<NuxtLink to="/tools" class="dash__toolbox-card">
|
||||||
<div class="dash__toolbox-head">
|
<div class="dash__toolbox-card-inner">
|
||||||
<UIcon name="i-lucide-wrench" class="text-lg" />
|
<div class="dash__toolbox-card-icon">
|
||||||
<h3>Boite a outils</h3>
|
<UIcon name="i-lucide-wrench" class="text-xl" />
|
||||||
<span class="dash__toolbox-count">{{ protocols.protocols.length }}</span>
|
</div>
|
||||||
|
<div class="dash__toolbox-card-body">
|
||||||
|
<h3 class="dash__toolbox-card-title">Boite a 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>
|
||||||
<div class="dash__toolbox-tags">
|
</NuxtLink>
|
||||||
<template v-if="protocols.protocols.length > 0">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="protocol in protocols.protocols"
|
|
||||||
:key="protocol.id"
|
|
||||||
:to="`/protocols/${protocol.id}`"
|
|
||||||
class="dash__tag"
|
|
||||||
>
|
|
||||||
{{ protocol.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="dash__tag">Vote WoT</span>
|
|
||||||
<span class="dash__tag">Vote nuance</span>
|
|
||||||
<span class="dash__tag">Vote permanent</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<NuxtLink to="/protocols" class="dash__toolbox-link">
|
|
||||||
Voir la boite a outils
|
|
||||||
<UIcon name="i-lucide-chevron-right" />
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent activity -->
|
<!-- Recent activity -->
|
||||||
<div v-if="recentDecisions.length > 0" class="dash__activity">
|
<div v-if="recentDecisions.length > 0" class="dash__activity">
|
||||||
@@ -292,7 +284,7 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.dash__entries {
|
.dash__entries {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,73 +452,91 @@ function formatDate(dateStr: string): string {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Toolbox teaser --- */
|
/* --- Toolbox card (5th block, distinct) --- */
|
||||||
.dash__toolbox {
|
.dash__toolbox-card {
|
||||||
background: var(--mood-surface);
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
border-radius: 16px;
|
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-card:hover {
|
||||||
.dash__toolbox {
|
transform: translateY(-3px);
|
||||||
padding: 1.25rem;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: center;
|
||||||
color: var(--mood-accent);
|
border-radius: 14px;
|
||||||
font-weight: 800;
|
background: var(--mood-accent);
|
||||||
font-size: 1.0625rem;
|
color: var(--mood-accent-text);
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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;
|
gap: 0.375rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.875rem;
|
}
|
||||||
|
|
||||||
|
.dash__toolbox-card-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--mood-accent);
|
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 --- */
|
/* --- Activity --- */
|
||||||
|
|||||||
@@ -272,23 +272,34 @@ async function handleCreate() {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
<div class="toolbox-section-title">
|
<ToolboxVignette
|
||||||
Modalites de vote
|
title="Ouverture"
|
||||||
</div>
|
:bullets="['Definir mission et perimetre', 'Duree et objectifs clairs']"
|
||||||
<template v-if="protocols.protocols.length > 0">
|
:actions="[
|
||||||
<ToolboxVignette
|
{ label: 'Creer', icon: 'i-lucide-door-open', emit: 'create', primary: true },
|
||||||
v-for="protocol in protocols.protocols"
|
]"
|
||||||
:key="protocol.id"
|
/>
|
||||||
:title="protocol.name"
|
<ToolboxVignette
|
||||||
:bullets="['Applicable aux mandats', protocol.mode_params || 'Configuration standard']"
|
title="Nomination"
|
||||||
:actions="[
|
:bullets="['Election en binome', 'Titulaire + suppleant']"
|
||||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
:actions="[
|
||||||
]"
|
{ label: 'Voir', icon: 'i-lucide-users', emit: 'nomination' },
|
||||||
/>
|
]"
|
||||||
</template>
|
/>
|
||||||
<p v-else class="toolbox-empty-text">
|
<ToolboxVignette
|
||||||
Aucun protocole configure
|
title="Transparence"
|
||||||
</p>
|
:bullets="['Rapports d\'activite', 'Soumis au vote communautaire']"
|
||||||
|
:actions="[
|
||||||
|
{ label: 'Voir', icon: 'i-lucide-eye', emit: 'transparence' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<ToolboxVignette
|
||||||
|
title="Cloture"
|
||||||
|
:bullets="['Fin de mandat ou revocation', 'Bilan et transmission']"
|
||||||
|
:actions="[
|
||||||
|
{ label: 'Voir', icon: 'i-lucide-lock', emit: 'cloture' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SectionLayout>
|
</SectionLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,35 @@ async function createProtocol() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Operational protocols (workflow templates). */
|
||||||
|
interface WorkflowStep {
|
||||||
|
label: string
|
||||||
|
actor: string
|
||||||
|
icon: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationalProtocols = [
|
||||||
|
{
|
||||||
|
slug: 'embarquement-forgeron',
|
||||||
|
name: 'Embarquement Forgeron',
|
||||||
|
description: 'Processus complet d\'intégration d\'un nouveau forgeron dans le réseau Duniter.',
|
||||||
|
category: 'onboarding',
|
||||||
|
icon: 'i-lucide-hammer',
|
||||||
|
instancesLabel: '~10-50 / an',
|
||||||
|
steps: [
|
||||||
|
{ label: 'Invitation on-chain', actor: 'Smith existant', icon: 'i-lucide-send', type: 'on_chain' },
|
||||||
|
{ label: 'Acceptation', actor: 'Candidat', icon: 'i-lucide-check', type: 'on_chain' },
|
||||||
|
{ label: 'Session keys', actor: 'Candidat', icon: 'i-lucide-key', type: 'on_chain' },
|
||||||
|
{ label: 'Checklist aspirant', actor: 'Candidat', icon: 'i-lucide-clipboard-check', type: 'checklist' },
|
||||||
|
{ label: 'Certification 1', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' },
|
||||||
|
{ label: 'Certification 2', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' },
|
||||||
|
{ label: 'Certification 3', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' },
|
||||||
|
{ label: 'Go online', actor: 'Candidat', icon: 'i-lucide-wifi', type: 'on_chain' },
|
||||||
|
] as WorkflowStep[],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
/** n8n workflow demo items. */
|
/** n8n workflow demo items. */
|
||||||
const n8nWorkflows = [
|
const n8nWorkflows = [
|
||||||
{
|
{
|
||||||
@@ -229,6 +258,50 @@ const n8nWorkflows = [
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Operational protocols (workflow templates) -->
|
||||||
|
<div class="proto-ops">
|
||||||
|
<h3 class="proto-ops__title">
|
||||||
|
<UIcon name="i-lucide-git-branch" class="text-sm" />
|
||||||
|
Protocoles operationnels
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Formulas table -->
|
||||||
<div class="proto-formulas">
|
<div class="proto-formulas">
|
||||||
<h3 class="proto-formulas__title">
|
<h3 class="proto-formulas__title">
|
||||||
@@ -802,6 +875,152 @@ const n8nWorkflows = [
|
|||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 --- */
|
/* --- Modal --- */
|
||||||
.proto-modal {
|
.proto-modal {
|
||||||
padding: 1.25rem;
|
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 a tout moment', status: 'ready' },
|
||||||
|
{ label: 'Inertie de remplacement', icon: 'i-lucide-sliders-horizontal', description: 'Regler la difficulte de modification par section (standard, haute, tres haute)', to: '/protocols/formulas', status: 'ready' },
|
||||||
|
{ label: 'Contre-propositions', icon: 'i-lucide-pen-line', description: 'Soumettre un texte alternatif soumis au vote de la communaute', status: 'ready' },
|
||||||
|
{ label: 'Ancrage IPFS', icon: 'i-lucide-hard-drive', description: 'Archiver les documents valides sur IPFS avec preuve on-chain', status: 'soon' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'decisions',
|
||||||
|
title: 'Decisions',
|
||||||
|
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: 'Ponderation degresssive pour eviter la concentration de pouvoir', status: 'soon' },
|
||||||
|
{ label: 'Vote nuance 6 niveaux', icon: 'i-lucide-bar-chart-3', description: 'De Tout a fait contre a Tout a fait pour, avec seuil de satisfaction', status: 'ready' },
|
||||||
|
{ label: 'Mandature', icon: 'i-lucide-user-check', description: 'Election et nomination en binome avec transparence', status: 'ready' },
|
||||||
|
{ label: 'Multi-criteres', 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: 'Definir une mission, son perimetre, sa duree et ses objectifs', status: 'ready' },
|
||||||
|
{ label: 'Nomination', icon: 'i-lucide-users', description: 'Election en binome : un titulaire + un suppleant', status: 'ready' },
|
||||||
|
{ label: 'Transparence', icon: 'i-lucide-eye', description: 'Rapports d\'activite periodiques soumis au vote', status: 'ready' },
|
||||||
|
{ label: 'Cloture', icon: 'i-lucide-lock', description: 'Fin de mandat avec bilan ou revocation anticipee 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 parametres de seuil WoT en temps reel', to: '/protocols/formulas', status: 'ready' },
|
||||||
|
{ label: 'Meta-gouvernance', icon: 'i-lucide-shield', description: 'Les formules elles-memes sont soumises au vote', status: 'ready' },
|
||||||
|
{ label: 'Workflows n8n', icon: 'i-lucide-workflow', description: 'Automatisations optionnelles (notifications, alertes, relances)', status: 'soon' },
|
||||||
|
{ label: 'Protocoles operationnels', icon: 'i-lucide-git-branch', description: 'Processus multi-etapes reutilisables (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 a 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" />
|
||||||
|
Boite a outils
|
||||||
|
</h1>
|
||||||
|
<p class="tools-page__subtitle">
|
||||||
|
Tous les outils de decision collective, organises 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">bientot</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>
|
||||||
Reference in New Issue
Block a user