Composants engagement: GenesisBlock, InertiaSlider, MiniVoteBoard, EngagementCard, DocumentTuto

Backend: genesis_json sur Document, section_tag/inertia_preset/is_permanent_vote sur DocumentItem
Frontend: 5 nouveaux composants pour vue detail document enrichie
- GenesisBlock: sources, outils, synthese forum, contributeurs (depliable)
- InertiaSlider: visualisation inertie 4 niveaux avec params formule G/M
- MiniVoteBoard: tableau vote compact (barre seuil, pour/contre, participation)
- EngagementCard: carte item enrichie integrant vote + inertie + actions
- DocumentTuto: modal pedagogique vote permanent/inertie/seuils
Seed et page [slug] enrichis pour exploiter les nouveaux champs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-03-02 07:59:05 +01:00
parent 11e4a4d60a
commit 62808b974d
10 changed files with 2116 additions and 120 deletions

View File

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

View File

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

View File

@@ -2,9 +2,17 @@
Sources: Sources:
- Engagement Forgeron v2.0.0: https://forum.monnaie-libre.fr/t/33165 - Engagement Forgeron v2.0.0: https://forum.monnaie-libre.fr/t/33165
- Engagement Certification (Licence G1): monnaie-libre.fr/licence-g1/ - Engagement Certification (Licence G1 v0.3.0):
https://git.duniter.org/documents/g1_monetary_license/-/raw/master/g1_monetary_license_fr.rst
- Charte 1.0 (rejected): https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066
- Licence v0.4.0 (in progress): https://forum.monnaie-libre.fr/t/32375
- Runtime Upgrade process template - Runtime Upgrade process template
Genesis references:
- Licence repo: https://git.duniter.org/documents/g1_monetary_license
- g1vote: https://git.duniter.org/tools/g1vote-view
- g1vote live: https://g1vote-view-237903.pages.duniter.org/
Idempotent: checks if data already exists before inserting. Idempotent: checks if data already exists before inserting.
""" """
@@ -12,6 +20,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -61,13 +70,47 @@ def fake_signature(payload: str) -> str:
async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig]: async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig]:
configs: dict[str, dict] = { configs: dict[str, dict] = {
"Standard Licence G1": { "Standard Licence G1": {
"description": "Formule standard pour la Licence G1 : vote binaire WoT.", "description": "Formule standard pour la Licence G1 : vote binaire WoT. Inertie standard.",
"duration_days": 30, "duration_days": 30,
"majority_pct": 50, "majority_pct": 50,
"base_exponent": 0.1, "base_exponent": 0.1,
"gradient_exponent": 0.2, "gradient_exponent": 0.2,
"constant_base": 0.0, "constant_base": 0.0,
}, },
"Inertie basse (Annexes)": {
"description": (
"Formule a inertie basse pour les annexes et recommandations. "
"Gradient faible = plus facile a remplacer meme a faible participation."
),
"duration_days": 30,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.1,
"constant_base": 0.0,
},
"Inertie haute (Formule)": {
"description": (
"Formule a inertie haute pour la section formule elle-meme. "
"Gradient eleve = necessite une forte mobilisation pour changer."
),
"duration_days": 30,
"majority_pct": 60,
"base_exponent": 0.1,
"gradient_exponent": 0.4,
"constant_base": 0.0,
},
"Inertie tres haute (Meta-reglage)": {
"description": (
"Formule a inertie maximale pour le reglage de l'inertie elle-meme. "
"Quasi-unanimite requise a toute participation. Protection contre "
"la modification des regles de modification."
),
"duration_days": 30,
"majority_pct": 66,
"base_exponent": 0.1,
"gradient_exponent": 0.6,
"constant_base": 0.0,
},
"Forgeron avec Smith": { "Forgeron avec Smith": {
"description": "Vote forgeron avec critere Smith sub-WoT.", "description": "Vote forgeron avec critere Smith sub-WoT.",
"duration_days": 30, "duration_days": 30,
@@ -170,112 +213,599 @@ async def seed_voting_protocols(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: Engagement Certification (Licence G1 - obligations certificateur) # Seed: Engagement Certification (Acte d'engagement certification)
#
# Full structured document built from:
# - Licence G1 v0.3.0 (in force)
# - Charte 1.0 (rejected proposal, topic 31066)
# - Licence v0.4.0 (in progress, topic 32375)
# - Yvv's "Acte d'engagement" position
# - Checklist certification (topic 32412)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
GENESIS_CERTIFICATION = {
"source_document": {
"title": "Licence de la monnaie libre G1 v0.3.0",
"url": "https://git.duniter.org/documents/g1_monetary_license/-/raw/master/g1_monetary_license_fr.rst",
"repo": "https://git.duniter.org/documents/g1_monetary_license",
},
"reference_tools": {
"g1vote_repo": "https://git.duniter.org/tools/g1vote-view",
"g1vote_live": "https://g1vote-view-237903.pages.duniter.org/",
"cesium": "https://g1.duniter.org",
"gecko": "https://gecko.music-all.org",
},
"forum_synthesis": [
{
"title": "Proposition Charte 1.0 (rejetee)",
"url": "https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066",
"status": "rejected",
"posts": 70,
},
{
"title": "Preparation licence v0.4.0",
"url": "https://forum.monnaie-libre.fr/t/preparation-dune-proposition-devolution-de-la-licence-1/32375",
"status": "in_progress",
"posts": 38,
},
{
"title": "Checklist de certification (annexe licence)",
"url": "https://forum.monnaie-libre.fr/t/prepa-checklist-de-certification-annexe-licence-1/32412",
"status": "in_progress",
"posts": 16,
},
{
"title": "Regles de modifications (annexe licence)",
"url": "https://forum.monnaie-libre.fr/t/prepa-regles-de-modifications-annexe-licence-1/32409",
"status": "in_progress",
"posts": 9,
},
{
"title": "Vote nuance licence",
"url": "https://forum.monnaie-libre.fr/t/processus-de-validation-licence-par-vote-nuance/31729",
"status": "reference",
},
],
"formula_trigger": (
"Quand un item atteint le seuil d'adoption (formule WoT), "
"le texte de remplacement est integre au document officiel. "
"Le hash IPFS du document mis a jour est ancre on-chain via system.remark. "
"Les applications (Cesium, Gecko) pointent vers le depot git officiel "
"qui est synchronise avec l'etat valide par les votes."
),
"contributors": [
{"name": "1000i100", "role": "Pilote principal, redacteur"},
{"name": "Natha", "role": "Co-redactrice"},
{"name": "Pini", "role": "Co-initiateur"},
{"name": "Yvv", "role": "Contributions structurelles, concepteur 'Acte d'engagement'"},
{"name": "elois", "role": "Dev g1vote, contributions techniques"},
],
}
ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
# ===================================================================
# INTRODUCTION
# ===================================================================
{ {
"position": "C1", "position": "I1",
"item_type": "clause", "item_type": "preamble",
"title": "Transmission de la licence", "title": "Preambule",
"sort_order": 1, "sort_order": 1,
"section_tag": "introduction",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Toute operation de certification d'un nouveau membre de la monnaie libre G1 " "Le present acte d'engagement definit les obligations reciproques "
"doit prealablement s'accompagner de la transmission de cette licence " "des membres de la toile de confiance de la monnaie libre G1. "
"de la monnaie libre G1 au certifie." "Cet acte est de fait l'unique relation contractuelle de notre "
"toile fiduciaire. Toute certification doit s'accompagner de la "
"transmission de ce document, dont le certificateur doit s'assurer "
"qu'il a ete etudie, compris et accepte par le certifie."
), ),
}, },
{ {
"position": "C2", "position": "I2",
"item_type": "clause", "item_type": "preamble",
"title": "Connaissance suffisante du certifie", "title": "Les deux garanties reciproques",
"sort_order": 2, "sort_order": 2,
"section_tag": "introduction",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Certifier n'est pas uniquement s'assurer que vous avez rencontre la personne, " "La certification repose sur deux garanties reciproques :\n\n"
"c'est assurer a la communaute G1 que vous connaissez suffisamment bien la " "**1.** Derriere une cle publique creatrice de monnaie "
"personne que vous vous appretez a certifier." "se trouve un **etre humain vivant**.\n\n"
"**2.** Derriere cet etre humain se trouve **une seule et unique** "
"cle publique creatrice de monnaie.\n\n"
"La certification est un acte technique et fiduciaire, "
"pas un acte d'adhesion morale ou de sympathie."
), ),
}, },
# ===================================================================
# TITRE 1 : ENGAGEMENTS FONDAMENTAUX (sur l'honneur)
# ===================================================================
{ {
"position": "C3", "position": "T1",
"item_type": "clause", "item_type": "section",
"title": "Contact par plusieurs moyens", "title": "Engagements fondamentaux",
"sort_order": 3, "sort_order": 3,
"section_tag": "fondamental",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Connaitre la personne par plusieurs moyens de contact differents " "Les engagements fondamentaux ci-dessous constituent le socle "
"(physique, electronique, etc.) permettant de verifier son identite " "irreductible de l'acte d'engagement. Ils sont pris sur l'honneur "
"et de maintenir un lien de confiance dans la duree." "par tout membre de la toile de confiance."
), ),
}, },
{ {
"position": "C4", "position": "E1",
"item_type": "clause", "item_type": "clause",
"title": "Ne jamais certifier seul", "title": "Unicite du compte",
"sort_order": 4, "sort_order": 4,
"section_tag": "fondamental",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Ne certifiez jamais seul, mais accompagne d'au moins un autre membre " "Je m'engage sur l'honneur a n'avoir et n'avoir jamais "
"de la TdC G1, pour garantir un double controle et eviter les " "qu'un seul et unique compte cocreateur de monnaie G1."
"certifications abusives."
), ),
}, },
{ {
"position": "C5", "position": "E2",
"item_type": "verification", "item_type": "clause",
"title": "Verification des certifications existantes", "title": "Certification responsable",
"sort_order": 5, "sort_order": 5,
"section_tag": "fondamental",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Avant toute certification, assurez-vous de verifier si le compte du " "Je m'engage sur l'honneur a ne certifier que des personnes "
"certifie a deja recu une ou plusieurs certifications, et de qui " "physiques qui respectent scrupuleusement ces deux presents "
"elles proviennent." "engagements fondamentaux."
), ),
}, },
# ===================================================================
# TITRE 2 : ENGAGEMENTS TECHNIQUES
# ===================================================================
{ {
"position": "C6", "position": "T2",
"item_type": "verification", "item_type": "section",
"title": "Verification de maitrise du compte", "title": "Engagements techniques",
"sort_order": 6, "sort_order": 6,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Verifier la maitrise du compte par un transfert test : envoyer " "Les engagements techniques definissent les obligations "
"quelques G1 et demander un renvoi, afin de s'assurer que la personne " "pratiques et verifiables du certificateur pour garantir "
"controle bien sa cle privee." "la qualite de la toile de confiance."
), ),
}, },
{ {
"position": "C7", "position": "E3",
"item_type": "verification", "item_type": "clause",
"title": "Verification de la licence", "title": "Connaissance suffisante",
"sort_order": 7, "sort_order": 7,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"Verifiez que vos contacts ont bien etudie et compris la licence G1 " "Je me suis assure de connaitre suffisamment la personne "
"a jour avant de proceder a la certification." "qui gere cette cle publique. Connaitre suffisamment ne signifie "
"pas « avoir vu » ; c'est assurer a la communaute G1 que je "
"pourrai la contacter facilement et etre en mesure de reperer "
"un double-compte ou tout autre probleme."
), ),
}, },
{ {
"position": "C8", "position": "E4",
"item_type": "verification", "item_type": "clause",
"title": "Document de revocation", "title": "Verification personnelle de la cle",
"sort_order": 8, "sort_order": 8,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"D'avoir bien verifie avec la personne concernee qu'elle a bien genere " "J'ai personnellement verifie que c'est bien cette cle publique "
"son document Duniter de revocation de compte, et qu'elle le conserve " "que je m'apprete a certifier, en la comparant avec la personne "
"concernee et non par un intermediaire."
),
},
{
"position": "E5",
"item_type": "clause",
"title": "Joignabilite reciproque",
"sort_order": 9,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Je suis joignable rapidement et facilement par mes certifieurs, "
"et je peux joindre rapidement et facilement les personnes que "
"je certifie, par plusieurs moyens de communication differents "
"et independants (physique, telephone, email, messagerie, etc.)."
),
},
{
"position": "E6",
"item_type": "clause",
"title": "Document de revocation",
"sort_order": 10,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"J'ai verifie avec la personne certifiee qu'elle a bien genere "
"son document de revocation de compte et qu'elle le conserve "
"en lieu sur." "en lieu sur."
), ),
}, },
{ {
"position": "C9", "position": "E7",
"item_type": "clause", "item_type": "clause",
"title": "Rencontre physique ou multi-canal", "title": "Rencontre physique ou verification multi-canaux",
"sort_order": 9, "sort_order": 11,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": ( "current_text": (
"De rencontrer la personne physiquement, OU de verifier a distance " "J'ai rencontre la personne physiquement (preferable), **OU** "
"le lien personne / cle publique par plusieurs moyens de communication " "j'ai verifie a distance le lien personne / cle publique par "
"differents et independants." "plusieurs moyens de communication differents et independants : "
"courrier + reseau social + forum + email + visio + telephone."
),
},
# ===================================================================
# TITRE 3 : CONSEILS ET BONNES PRATIQUES
# ===================================================================
{
"position": "T3",
"item_type": "section",
"title": "Conseils et bonnes pratiques",
"sort_order": 12,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Les pratiques suivantes sont fortement recommandees pour "
"garantir la qualite et la securite de la toile de confiance."
),
},
{
"position": "E8",
"item_type": "clause",
"title": "Ne jamais certifier seul",
"sort_order": 13,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Ne certifiez jamais seul, mais accompagne d'au moins un autre "
"membre de la toile de confiance G1, pour garantir un double "
"controle."
),
},
{
"position": "E9",
"item_type": "clause",
"title": "Verification des certifications existantes",
"sort_order": 14,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Avant toute certification, verifiez si le compte a deja "
"recu des certifications et de qui elles proviennent. "
"Contactez les certifieurs existants en cas de doute. "
"Si un certifieurs existant ne connait pas la personne, "
"alertez immediatement les experts de la communaute."
),
},
{
"position": "E10",
"item_type": "clause",
"title": "Verification de maitrise du compte",
"sort_order": 15,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Verifiez la maitrise du compte par un transfert-test : "
"envoyez quelques G1 et demandez le renvoi, afin de vous "
"assurer que la personne controle bien sa cle privee."
),
},
{
"position": "E11",
"item_type": "clause",
"title": "Transmission et comprehension du document",
"sort_order": 16,
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
"Verifiez que vos contacts ont bien etudie et compris "
"le present acte d'engagement dans sa version a jour "
"avant de proceder a la certification."
),
},
# ===================================================================
# CONCLUSION
# ===================================================================
{
"position": "K1",
"item_type": "preamble",
"title": "Regles abregees de la toile de confiance",
"sort_order": 17,
"section_tag": "conclusion",
"inertia_preset": "standard",
"current_text": (
"**Parametres protocolaires en vigueur :**\n\n"
"- Stock de **100 certifications** possibles\n"
"- 1 certification emissible tous les **5 jours**\n"
"- Nouveau compte valide si **>= 5 certifications** recues en **2 mois**\n"
"- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n"
"- Sentinelle : membre ayant recu et emis >= Y[N] certifs "
"(Y = ceil(N^(1/5)))\n"
"- Certifications actives valables **2 ans**\n"
"- Renouvellement de l'accord tous les **12 mois**"
),
},
{
"position": "K2",
"item_type": "preamble",
"title": "Monnaie G1",
"sort_order": 18,
"section_tag": "conclusion",
"inertia_preset": "standard",
"current_text": (
"**Parametres monetaires :**\n\n"
"- 1 Dividende Universel (DU) par personne par jour\n"
"- Reevaluation a chaque equinoxe : "
"`DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n"
"- DU(0) = 10.00 G1"
),
},
# ===================================================================
# ANNEXE 1 : INTEGRATION LOGICIELLE
# ===================================================================
{
"position": "X1",
"item_type": "section",
"title": "Annexe 1 : Integration logicielle",
"sort_order": 19,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"Les obligations pour les logiciels implementant la certification G1."
),
},
{
"position": "X1.1",
"item_type": "clause",
"title": "Depot de reference",
"sort_order": 20,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"Le texte de reference de l'acte d'engagement certification est "
"heberge dans le depot git officiel : "
"https://git.duniter.org/documents/g1_monetary_license\n\n"
"Les applications Cesium, Gecko et toute application de "
"certification doivent pointer vers ce depot pour afficher "
"la version en vigueur."
),
},
{
"position": "X1.2",
"item_type": "clause",
"title": "Obligation de transmission",
"sort_order": 21,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"Tout logiciel G1 permettant la certification doit transmettre "
"le present acte d'engagement au certifie et en afficher les "
"parametres du bloc 0 de la blockchain Duniter."
),
},
{
"position": "X1.3",
"item_type": "clause",
"title": "Logiciel auditable",
"sort_order": 22,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"Tout logiciel utilise pour la certification ou la creation "
"monetaire doit etre publie sous licence libre, afin de "
"permettre son audit par la communaute."
),
},
# ===================================================================
# ANNEXE 2 : QUESTIONS A LA CERTIFICATION (checklist logicielle)
# ===================================================================
{
"position": "X2",
"item_type": "section",
"title": "Annexe 2 : Questions a la certification",
"sort_order": 23,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"Liste de questions a presenter dans les logiciels de certification. "
"Inspiree de la checklist de la Charte 1.0 et des discussions "
"forum (topic 32412)."
),
},
{
"position": "X2.1",
"item_type": "verification",
"title": "Questions piege (reponse attendue : NON)",
"sort_order": 24,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"**Si OUI a l'une de ces questions, la certification doit etre refusee.**\n\n"
"- La personne m'a contacte uniquement pour obtenir ma certification\n"
"- Je certifie sous la pression ou pour faire plaisir\n"
"- Je n'ai aucun moyen de verifier l'identite de la personne\n"
"- La personne refuse de me donner plusieurs moyens de contact\n"
"- Je soupçonne que la personne possede deja un compte membre\n"
"- Je ne connais aucun de ses autres certifieurs\n"
"- La personne ne comprend pas ce qu'est la monnaie libre\n"
"- La personne n'a pas lu le present acte d'engagement"
),
},
{
"position": "X2.2",
"item_type": "verification",
"title": "Questions identite (reponse attendue : OUI)",
"sort_order": 25,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"**Si NON a l'une de ces questions, la certification doit etre refusee.**\n\n"
"- Je connais cette personne suffisamment pour la recontacter\n"
"- J'ai verifie personnellement le lien personne / cle publique\n"
"- La personne est une personne physique vivante (pas une entite morale)\n"
"- Je pourrais reconnaitre cette personne si je la croisais\n"
"- J'ai au moins 2 moyens de contact differents pour cette personne"
),
},
{
"position": "X2.3",
"item_type": "verification",
"title": "Questions securite (reponse attendue : OUI)",
"sort_order": 26,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
"**Si NON, la certification est deconseille.**\n\n"
"- La personne a genere son document de revocation\n"
"- La personne maitrise effectivement son compte "
"(test de transfert effectue)\n"
"- La personne sait ou retrouver le present acte d'engagement "
"dans sa version a jour"
),
},
# ===================================================================
# SECTION SPECIALE : FORMULE DE VOTE
# ===================================================================
{
"position": "F1",
"item_type": "section",
"title": "Formule de vote",
"sort_order": 27,
"section_tag": "formule",
"inertia_preset": "high",
"current_text": (
"La formule qui regit l'adoption ou le rejet de chaque modification "
"du present document. Reference : g1vote "
"(https://g1vote-view-237903.pages.duniter.org/)."
),
},
{
"position": "F1.1",
"item_type": "rule",
"title": "Formule du seuil WoT",
"sort_order": 28,
"section_tag": "formule",
"inertia_preset": "high",
"current_text": (
"**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\n"
"Ou :\n"
"- **C** = constante de base (plancher fixe)\n"
"- **B** = exposant de base (B^W tend vers 0)\n"
"- **W** = taille de la toile de confiance (electeurs eligibles)\n"
"- **T** = total des votes exprimes (pour + contre)\n"
"- **M** = ratio de majorite cible (M = majority_pct / 100)\n"
"- **G** = exposant du gradient d'inertie\n\n"
"**Comportement :** Faible participation → quasi-unanimite requise. "
"Forte participation → majorite simple M suffit."
),
},
{
"position": "F1.2",
"item_type": "rule",
"title": "Parametres par defaut",
"sort_order": 29,
"section_tag": "formule",
"inertia_preset": "high",
"current_text": (
"Parametres de reference pour les engagements (inertie standard) :\n\n"
"| Parametre | Code | Valeur |\n"
"|-----------|------|--------|\n"
"| Duree | D | 30 jours (permanent) |\n"
"| Majorite | M | 50% |\n"
"| Base | B | 0.1 |\n"
"| Gradient | G | 0.2 |\n"
"| Constante | C | 0.0 |\n\n"
"Mode compact : **D30M50B.1G.2**"
),
},
{
"position": "F1.3",
"item_type": "rule",
"title": "Processus de depot officiel",
"sort_order": 30,
"section_tag": "formule",
"inertia_preset": "high",
"current_text": (
"Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n"
"1. Le texte de remplacement est integre au document officiel\n"
"2. Le hash IPFS du document mis a jour est calcule\n"
"3. Le hash est ancre on-chain via `system.remark`\n"
"4. Le depot git officiel est synchronise\n"
"5. Les applications (Cesium, Gecko) mettent a jour automatiquement"
),
},
# ===================================================================
# SECTION SPECIALE : REGLAGE DE L'INERTIE
# ===================================================================
{
"position": "N1",
"item_type": "section",
"title": "Reglage de l'inertie",
"sort_order": 31,
"section_tag": "inertie",
"inertia_preset": "very_high",
"current_text": (
"Le reglage de l'inertie definit la difficulte de remplacement "
"de chaque section du document. Ce reglage est lui-meme soumis "
"a l'inertie la plus elevee, pour empecher la modification "
"des regles de modification."
),
},
{
"position": "N1.1",
"item_type": "rule",
"title": "Niveaux d'inertie",
"sort_order": 32,
"section_tag": "inertie",
"inertia_preset": "very_high",
"current_text": (
"| Niveau | Gradient G | Majorite M | Application |\n"
"|--------|-----------|------------|-------------|\n"
"| **Basse** | 0.1 | 50% | Annexes, recommandations |\n"
"| **Standard** | 0.2 | 50% | Engagements fondamentaux et techniques |\n"
"| **Haute** | 0.4 | 60% | Formule de vote |\n"
"| **Tres haute** | 0.6 | 66% | Reglage de l'inertie |\n\n"
"Plus le gradient est eleve, plus il faut de participation "
"et de consensus pour qu'une modification soit adoptee."
),
},
# ===================================================================
# SECTION SPECIALE : ORDONNANCEMENT
# ===================================================================
{
"position": "O1",
"item_type": "section",
"title": "Ordonnancement du document",
"sort_order": 33,
"section_tag": "ordonnancement",
"inertia_preset": "high",
"current_text": (
"L'ordre de presentation des items dans le document est "
"lui-meme soumis au vote. Toute proposition de reorganisation "
"doit atteindre le seuil d'adoption avec l'inertie haute."
), ),
}, },
] ]
async def seed_document_engagement_certification(session: AsyncSession) -> Document: async def seed_document_engagement_certification(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
doc, created = await get_or_create( doc, created = await get_or_create(
session, session,
Document, Document,
@@ -286,15 +816,33 @@ async def seed_document_engagement_certification(session: AsyncSession) -> Docum
version="1.0.0", version="1.0.0",
status="active", status="active",
description=( description=(
"Obligations des certificateurs de la toile de confiance Duniter. " "Acte d'engagement des certificateurs de la toile de confiance G1. "
"Chaque clause est soumise au vote permanent de la communaute." "Document modulaire sous vote permanent : chaque item peut etre "
"remplace par une alternative qui atteint le seuil d'adoption. "
"Construit a partir de la Licence G1 v0.3.0, des discussions "
"communautaires et de la position 'Acte d'engagement' (Yvv)."
), ),
genesis_json=genesis,
) )
print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}") print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
if created: if created:
# Map inertia presets to voting protocols
inertia_protocol_map = {
"low": protocols.get("Vote WoT standard"),
"standard": protocols.get("Vote WoT standard"),
"high": protocols.get("Vote WoT standard"),
"very_high": protocols.get("Vote WoT standard"),
}
for item_data in ENGAGEMENT_CERTIFICATION_ITEMS: for item_data in ENGAGEMENT_CERTIFICATION_ITEMS:
item = DocumentItem(document_id=doc.id, **item_data) preset = item_data.get("inertia_preset", "standard")
protocol = inertia_protocol_map.get(preset)
item = DocumentItem(
document_id=doc.id,
voting_protocol_id=protocol.id if protocol else None,
**item_data,
)
session.add(item) session.add(item)
await session.flush() await session.flush()
print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created") print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created")
@@ -903,7 +1451,7 @@ async def run_seed():
protocols = await seed_voting_protocols(session, formulas) protocols = await seed_voting_protocols(session, formulas)
print("\n[3/7] Document: Acte d'engagement certification...") print("\n[3/7] Document: Acte d'engagement certification...")
await seed_document_engagement_certification(session) await seed_document_engagement_certification(session, protocols)
print("\n[4/7] Document: Acte d'engagement forgeron v2.0.0...") print("\n[4/7] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session) doc_forgeron = await seed_document_engagement_forgeron(session)

View File

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

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* EngagementCard — Enhanced item card with inline mini vote board,
* inertia indicator, and action buttons.
*
* Replaces the basic ItemCard for the document detail view.
*/
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
showVoteBoard?: boolean
}>(), {
showActions: false,
showVoteBoard: true,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const isSection = computed(() => props.item.item_type === 'section')
const isPreamble = computed(() => props.item.item_type === 'preamble')
const itemTypeIcon = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'i-lucide-shield-check'
case 'rule': return 'i-lucide-scale'
case 'verification': return 'i-lucide-check-circle'
case 'preamble': return 'i-lucide-scroll-text'
case 'section': return 'i-lucide-layout-list'
default: return 'i-lucide-file-text'
}
})
const itemTypeLabel = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'Engagement'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Titre'
default: return props.item.item_type
}
})
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<!-- Section header (visual separator, not a card) -->
<div v-if="isSection" class="engagement-section">
<div class="engagement-section__line" />
<div class="engagement-section__content">
<h3 class="engagement-section__title">
{{ item.title }}
</h3>
<p class="engagement-section__text">
{{ item.current_text }}
</p>
<InertiaSlider
:preset="item.inertia_preset"
compact
class="mt-2 max-w-xs"
/>
</div>
</div>
<!-- Regular item card -->
<div
v-else
class="engagement-card"
:class="{
'engagement-card--preamble': isPreamble,
}"
>
<!-- Card header -->
<div class="engagement-card__header" @click="navigateToItem">
<div class="flex items-center gap-2.5 min-w-0">
<div class="engagement-card__position">
{{ item.position }}
</div>
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
<span v-if="item.title" class="engagement-card__title">
{{ item.title }}
</span>
<UBadge variant="subtle" color="neutral" size="xs" class="shrink-0">
{{ itemTypeLabel }}
</UBadge>
</div>
</div>
<!-- Item text -->
<div class="engagement-card__body" @click="navigateToItem">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Mini vote board -->
<div v-if="showVoteBoard" class="engagement-card__vote">
<MiniVoteBoard
:votes-for="10"
:votes-against="1"
:wot-size="7224"
:is-permanent="item.is_permanent_vote"
:inertia-preset="item.inertia_preset"
/>
</div>
<!-- Inertia indicator -->
<div class="engagement-card__inertia">
<InertiaSlider
:preset="item.inertia_preset"
compact
/>
</div>
<!-- Actions -->
<div v-if="showActions" class="engagement-card__actions">
<UButton
label="Proposer une alternative"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
<UButton
label="Voter"
icon="i-lucide-vote"
variant="soft"
color="success"
size="xs"
@click.stop="navigateToItem"
/>
</div>
</div>
</template>
<style scoped>
/* Section separator */
.engagement-section {
display: flex;
gap: 1rem;
padding: 1.5rem 0 0.5rem;
}
.engagement-section__line {
width: 4px;
background: var(--mood-accent);
border-radius: 2px;
flex-shrink: 0;
}
.engagement-section__content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.engagement-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
.engagement-section__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
/* Card */
.engagement-card {
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
transition: box-shadow 0.15s, transform 0.15s;
}
.engagement-card:hover {
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
transform: translateY(-1px);
}
.engagement-card--preamble {
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
}
.engagement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem 0;
cursor: pointer;
}
.engagement-card__position {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.625rem;
padding: 0 0.5rem;
border-radius: 8px;
background: var(--mood-accent);
color: white;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.engagement-card__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.engagement-card__body {
padding: 0.5rem 1rem 0.75rem;
cursor: pointer;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--mood-text);
}
.engagement-card__vote {
padding: 0 1rem;
}
.engagement-card__inertia {
padding: 0.5rem 1rem;
}
.engagement-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
}
</style>

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
/**
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
* for a reference document. Collapsible by default.
*/
const props = defineProps<{
genesisJson: string
}>()
const expanded = ref(false)
interface GenesisData {
source_document: {
title: string
url: string
repo: string
}
reference_tools: Record<string, string>
forum_synthesis: Array<{
title: string
url: string
status: string
posts?: number
}>
formula_trigger: string
contributors: Array<{
name: string
role: string
}>
}
const genesis = computed((): GenesisData | null => {
try {
return JSON.parse(props.genesisJson)
} catch {
return null
}
})
const 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) => {
switch (status) {
case 'rejected': return 'Rejetee'
case 'in_progress': return 'En cours'
case 'reference': return 'Reference'
default: return status
}
}
</script>
<template>
<div v-if="genesis" class="genesis-block">
<!-- Header (always visible) -->
<button
class="genesis-block__header"
@click="expanded = !expanded"
>
<div class="flex items-center gap-3">
<div class="genesis-block__icon">
<UIcon name="i-lucide-file-archive" class="text-lg" />
</div>
<div class="text-left">
<h3 class="text-sm font-bold uppercase tracking-wide" style="color: var(--mood-accent)">
Bloc de genese
</h3>
<p class="text-xs" style="color: var(--mood-text-muted)">
Sources, references et formule de depot
</p>
</div>
</div>
<UIcon
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-lg"
style="color: var(--mood-text-muted)"
/>
</button>
<!-- Expandable content -->
<Transition name="genesis-expand">
<div v-if="expanded" class="genesis-block__body">
<!-- Source document -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-file-text" />
Document source
</h4>
<div class="genesis-card">
<p class="font-medium text-sm" style="color: var(--mood-text)">
{{ genesis.source_document.title }}
</p>
<div class="flex flex-col gap-1 mt-2">
<a
:href="genesis.source_document.url"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-external-link" class="text-xs" />
Texte officiel
</a>
<a
:href="genesis.source_document.repo"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
Depot git
</a>
</div>
</div>
</div>
<!-- Reference tools -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de reference
</h4>
<div class="grid grid-cols-2 gap-2">
<a
v-for="(url, name) in genesis.reference_tools"
:key="name"
:href="url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--tool"
>
<span class="text-xs font-semibold capitalize" style="color: var(--mood-text)">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
</a>
</div>
</div>
<!-- Forum synthesis -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthese des discussions
</h4>
<div class="flex flex-col gap-2">
<a
v-for="topic in genesis.forum_synthesis"
:key="topic.url"
:href="topic.url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--forum"
>
<div class="flex items-start justify-between gap-2">
<span class="text-xs font-medium" style="color: var(--mood-text)">
{{ topic.title }}
</span>
<UBadge
:color="statusColor(topic.status)"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ statusLabel(topic.status) }}
</UBadge>
</div>
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
{{ topic.posts }} messages
</span>
</a>
</div>
</div>
<!-- Formula trigger -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de depot
</h4>
<div class="genesis-card">
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
{{ genesis.formula_trigger }}
</p>
</div>
</div>
<!-- Contributors -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-users" />
Contributeurs
</h4>
<div class="flex flex-wrap gap-2">
<div
v-for="c in genesis.contributors"
:key="c.name"
class="genesis-contributor"
>
<span class="font-semibold text-xs" style="color: var(--mood-text)">{{ c.name }}</span>
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.genesis-block {
background: var(--mood-surface);
border-radius: 16px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
}
.genesis-block__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.25rem;
cursor: pointer;
background: none;
transition: background 0.15s;
}
.genesis-block__header:hover {
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
}
.genesis-block__icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
color: var(--mood-accent);
}
.genesis-block__body {
padding: 0 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.genesis-section__title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin-bottom: 0.5rem;
}
.genesis-card {
padding: 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
}
.genesis-card--tool {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--tool:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
}
.genesis-card--forum {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--forum:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
}
.genesis-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-accent);
text-decoration: none;
font-weight: 500;
}
.genesis-link:hover {
text-decoration: underline;
}
.genesis-contributor {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
}
/* Expand/collapse transition */
.genesis-expand-enter-active,
.genesis-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.genesis-expand-enter-from,
.genesis-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* Inertia slider — displays the inertia preset level for a section.
* Read-only indicator (voting on the preset uses the standard vote flow).
* Shows the formula parameters underneath.
*/
const props = withDefaults(defineProps<{
preset: string
compact?: boolean
}>(), {
compact: false,
})
interface InertiaLevel {
label: string
gradient: number
majority: number
color: string
position: number // 0-100 for slider position
description: string
}
const LEVELS: Record<string, InertiaLevel> = {
low: {
label: 'Basse',
gradient: 0.1,
majority: 50,
color: '#22c55e',
position: 10,
description: 'Facile a remplacer',
},
standard: {
label: 'Standard',
gradient: 0.2,
majority: 50,
color: '#3b82f6',
position: 37,
description: 'Equilibre participation/consensus',
},
high: {
label: 'Haute',
gradient: 0.4,
majority: 60,
color: '#f59e0b',
position: 63,
description: 'Forte mobilisation requise',
},
very_high: {
label: 'Tres haute',
gradient: 0.6,
majority: 66,
color: '#ef4444',
position: 90,
description: 'Quasi-unanimite requise',
},
}
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
</script>
<template>
<div class="inertia" :class="{ 'inertia--compact': compact }">
<!-- Slider track -->
<div class="inertia__track">
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
<div
class="inertia__thumb"
:style="{ left: `${level.position}%`, borderColor: level.color }"
/>
<!-- Level marks -->
<div
v-for="(lvl, key) in LEVELS"
:key="key"
class="inertia__mark"
:class="{ 'inertia__mark--active': key === preset }"
:style="{ left: `${lvl.position}%` }"
/>
</div>
<!-- Label row -->
<div class="inertia__info">
<span class="inertia__label" :style="{ color: level.color }">
{{ level.label }}
</span>
<span v-if="!compact" class="inertia__params">
G={{ level.gradient }} M={{ level.majority }}%
</span>
</div>
<!-- Description (not in compact mode) -->
<p v-if="!compact" class="inertia__desc">
{{ level.description }}
</p>
</div>
</template>
<style scoped>
.inertia {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.inertia--compact {
gap: 0.25rem;
}
.inertia__track {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
}
.inertia--compact .inertia__track {
height: 4px;
}
.inertia__fill {
position: absolute;
inset: 0;
border-radius: 3px;
transition: width 0.3s ease;
}
.inertia__fill {
right: auto;
}
.inertia__thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--mood-bg);
border: 3px solid;
transition: left 0.3s ease;
z-index: 2;
}
.inertia--compact .inertia__thumb {
width: 10px;
height: 10px;
border-width: 2px;
}
.inertia__mark {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
z-index: 1;
}
.inertia__mark--active {
background: transparent;
}
.inertia__info {
display: flex;
align-items: center;
justify-content: space-between;
}
.inertia__label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia--compact .inertia__label {
font-size: 0.625rem;
}
.inertia__params {
font-size: 0.625rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.inertia__desc {
font-size: 0.6875rem;
color: var(--mood-text-muted);
line-height: 1.3;
}
</style>

View File

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

View File

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

View File

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