v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* /creer — onboarding en 3 étapes (layout bare) : le premier usage EST le
* tutoriel. Étape 1 identité, étape 2 gabarit, étape 3 personnes.
* Atterrit sur / où la première décision « Adopter notre Pacte » attend.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import type { TemplateId } from '~/data/templates'
import { CREATE_SIGNATURE, FIRST_DECISION, OPENING_DREAM } from '~/lexicon'
import { COLOR_CHOICES } from '~/components/onboarding/OnboardingIdentity.vue'
import { TEMPLATE_ICONS } from '~/components/onboarding/OnboardingTemplates.vue'
definePageMeta({ layout: 'bare' })
const store = useCollectiveStore()
const step = ref<1 | 2 | 3>(1)
const name = ref('')
const color = ref(COLOR_CHOICES[0]!.hex)
const template = ref<TemplateId | null>(null)
const meName = ref('')
const members = ref<string[]>([])
const busy = ref(false)
const issues = ref<string[]>([])
const STEP_TITLES: Record<1 | 2 | 3, string> = {
1: 'Donne-lui un nom, une couleur, une ambiance.',
2: 'Choisis un point de départ — tout reste amendable par décision.',
3: 'Toi, puis les premières personnes autour de la table.',
}
const canContinue = computed(() => {
if (busy.value) return false
if (step.value === 1) return name.value.trim().length > 0
if (step.value === 2) return template.value !== null
return meName.value.trim().length > 0
})
function next() {
if (!canContinue.value) return
issues.value = []
if (step.value < 3) step.value++
else void create()
}
function back() {
if (busy.value || step.value === 1) return
issues.value = []
step.value--
}
/** « Les Jardins du Canal » → « les-jardins-du-canal ». */
function slugify(text: string): string {
return (
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'collectif'
)
}
function keepBlockingIssues(all: { level: string; message: string }[]): boolean {
const blocking = all.filter(i => i.level === 'error').map(i => i.message)
issues.value = blocking
return blocking.length > 0
}
async function create() {
if (!template.value || busy.value) return
busy.value = true
const result = await store.createFromTemplate(template.value, {
name: name.value.trim(),
slug: slugify(name.value),
color: color.value,
icon: TEMPLATE_ICONS[template.value],
meName: meName.value.trim(),
memberNames: members.value,
})
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
async function explore(seed: SeedName) {
if (busy.value) return
busy.value = true
issues.value = []
const result = await store.loadSeed(seed)
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key !== 'Enter') return
const target = ev.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(target.tagName)) return
next()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<!-- ld-v2 -->
<div class="creer">
<header class="creer__head">
<h1 class="creer__dream">{{ OPENING_DREAM }}</h1>
<p class="creer__step-title">{{ STEP_TITLES[step] }}</p>
</header>
<OnboardingIdentity
v-if="step === 1"
v-model:name="name"
v-model:color="color"
@next="next"
/>
<OnboardingTemplates
v-else-if="step === 2"
v-model="template"
@next="next"
@seed="explore"
/>
<OnboardingMembers
v-else
v-model:me-name="meName"
v-model:members="members"
/>
<ul v-if="issues.length" class="creer__issues">
<li v-for="msg in issues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
<footer class="creer__nav">
<button v-if="step > 1" type="button" class="ld-btn ld-btn--quiet" :disabled="busy" @click="back">
<UIcon name="i-lucide-arrow-left" /> Retour
</button>
<span v-else class="creer__spacer" />
<span class="creer__dots" role="status" :aria-label="`Étape ${step} sur 3`">
<span
v-for="n in 3"
:key="n"
class="creer__dot"
:class="{ 'creer__dot--active': step === n, 'creer__dot--done': step > n }"
/>
</span>
<button type="button" class="ld-btn" :disabled="!canContinue" @click="next">
<template v-if="step < 3">
Continuer <UIcon name="i-lucide-arrow-right" />
</template>
<template v-else>
<UIcon name="i-lucide-sparkles" /> Fonder le collectif
</template>
</button>
</footer>
<p v-if="step === 3" class="creer__hint">
La première décision t'attendra : « {{ FIRST_DECISION }} ».
</p>
<p v-if="step === 3" class="creer__signature">{{ CREATE_SIGNATURE }}</p>
</div>
</template>
<style scoped>
.creer {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2.25rem);
width: min(100%, 56rem);
margin-inline: auto;
padding-bottom: 3.5rem;
}
.creer__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.creer__dream {
margin: 0;
font-size: clamp(1.6rem, 5vw, 2.6rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.creer__step-title {
margin: 0;
font-size: clamp(0.9375rem, 2.5vw, 1.0625rem);
color: var(--mood-text-muted);
}
.creer__issues {
margin: 0;
padding: 0.875rem 1.1rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.9rem;
font-weight: 600;
}
.creer__issues li {
display: flex;
align-items: center;
gap: 0.45rem;
}
.creer__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.creer__spacer {
min-width: 5.5rem;
}
.creer__dots {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.creer__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--mood-text-muted);
opacity: 0.3;
transition: transform 0.12s ease, opacity 0.12s ease, background 0.12s ease;
}
.creer__dot--active {
background: var(--mood-accent);
opacity: 1;
transform: scale(1.25);
}
.creer__dot--done {
background: var(--mood-accent);
opacity: 0.55;
}
.creer__hint {
margin: -0.75rem 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.creer__signature {
margin: 0;
text-align: center;
font-size: 0.875rem;
font-style: italic;
opacity: 0.65;
color: var(--mood-accent);
}
@media (max-width: 480px) {
.creer__nav {
flex-wrap: wrap;
justify-content: center;
}
.creer__dots {
order: -1;
width: 100%;
justify-content: center;
}
.creer__spacer { display: none; }
}
</style>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
/**
* « /decider — Le chemin » : une phrase → un chemin exécutable en un tap.
* Le moteur pur recalcule à chaque geste ; tout est expliqué en une phrase,
* tout est contournable (alourdir libre, alléger motivé). Chemin nominal :
* phrase + Entrée (au Fil) puis LE bouton — 2 gestes.
*/
import type { ApplyPathEdits } from '~/stores/decisions'
import type {
ChainKind, Decision, DecisionRoute, Id, ParamSpec, Reversibility,
TriageInput, Verdict, Weight,
} from '~/types/domain'
import { computeConcerned } from '~/engine'
import {
ADOPTED_STAMP, ADOPTED_TOAST, METHOD_LABELS,
CAPTURE_PLACEHOLDER, ROUTE_LABELS, WHY_DISCLOSURE,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
type AltKey = 'parametric' | 'record' | 'binary'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
// ── State of the tunnel ──
const title = ref('')
const scope = ref<Decision['scope']>({ selfOnly: true, circleIds: [], personIds: [] })
const reversibility = ref<Reversibility>('easy')
const weight = ref<Weight>('light')
const urgent = ref(false)
const amendsClauseId = ref<Id | undefined>()
const parentId = ref<Id | undefined>()
const chainKind = ref<ChainKind | undefined>()
const baselineNote = ref('')
const resourceNote = ref('')
const resourceAmount = ref<number | null>(null)
const resourceUnit = ref('heures')
const chosenAlt = ref<AltKey | null>(null)
const decidedHow = ref('')
const overrideNote = ref('')
const spec = ref<ParamSpec | undefined>()
const reviewChecked = ref(false)
const engraveChecked = ref(false)
const mandateAttached = ref(false)
const error = ref('')
const stamping = ref(false)
// ── Prefill from the query (?titre ?clause ?parent ?chain ?mandat) ──
const q = route.query
if (typeof q.titre === 'string') title.value = q.titre
if (typeof q.clause === 'string') amendsClauseId.value = q.clause
if (typeof q.parent === 'string') {
const prefill = decisions.reopen(q.parent)
if (!('ok' in prefill)) {
parentId.value = prefill.parentDecisionId
chainKind.value = prefill.chainKind
if (title.value.length === 0) title.value = prefill.title
scope.value = prefill.scope
reversibility.value = prefill.reversibility
weight.value = prefill.weight
if (prefill.amendsClauseId) amendsClauseId.value = prefill.amendsClauseId
}
}
if (typeof q.chain === 'string') chainKind.value = q.chain as ChainKind
if (typeof q.mandat === 'string') {
const mandate = col.mandates.find(m => m.id === q.mandat)
if (mandate) {
scope.value = { selfOnly: false, circleIds: [...mandate.domain.circleIds], personIds: [] }
}
}
// ── The live path — the pure engine recomputes at every gesture ──
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
const tags = computed(() => [...title.value.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()))
const engineInput = computed<TriageInput>(() => ({
title: title.value,
tags: tags.value,
scope: scope.value,
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
...(amendsClauseId.value ? { amendsClauseId: amendsClauseId.value } : {}),
}))
const chemin = computed<Verdict | null>(() => {
const res = decisions.runTriage(engineInput.value)
return 'ok' in res ? null : res
})
watch(() => chemin.value?.reviewRequired, v => { reviewChecked.value = v === true }, { immediate: true })
watch(() => chemin.value?.engravingSuggested, v => { engraveChecked.value = v === true }, { immediate: true })
// ── Concernés pré-calculés (plancher) ──
const stack = computed(() => {
if (scope.value.selfOnly || !col.me) return []
const active = col.mandates.filter(m => m.status === 'active')
return computeConcerned(scope.value, col.circles, active, col.me.id).flatMap((entry) => {
const person = col.people.find(p => p.id === entry.personId)
return person ? [{ person, origin: 'computed' as const, reason: entry.reason }] : []
})
})
// ── Route effective & asymétrie de dérogation ──
const ROUTE_ORDER: Record<DecisionRoute, number> = {
record: 0, solo: 0, mandate: 1, transmit: 1, advice: 2, collective: 3,
}
const effectiveRoute = computed<DecisionRoute>(() => {
if (!chemin.value) return 'solo'
if (!chosenAlt.value) return chemin.value.route
return chosenAlt.value === 'record' ? 'record' : 'collective'
})
const overridden = computed(() => chemin.value !== null && effectiveRoute.value !== chemin.value.route)
const lightening = computed(
() => chemin.value !== null && ROUTE_ORDER[effectiveRoute.value] < ROUTE_ORDER[chemin.value.route],
)
const overrideLabel = computed(() => {
if (chosenAlt.value === 'parametric') return METHOD_LABELS.parametric
if (chosenAlt.value === 'binary') return METHOD_LABELS.binary
if (chosenAlt.value === 'record') return ROUTE_LABELS.record
return ''
})
// ── Frise : durée / seuil (si connus) ──
const protocol = computed(() => {
if (effectiveRoute.value !== 'collective') return undefined
let id = chemin.value?.protocolId
if (chosenAlt.value === 'parametric') id = col.settings?.protocolByRange.parametric ?? id
if (chosenAlt.value === 'binary') id = col.protocols.find(p => p.method === 'binary')?.id ?? id
return col.protocols.find(p => p.id === id)
})
const framingPlanned = computed(
() => chemin.value?.framingDays !== undefined && effectiveRoute.value === 'collective',
)
const durationLabel = computed(() => {
const v = chemin.value
if (!v || effectiveRoute.value === 'solo' || effectiveRoute.value === 'record') return ''
if (framingPlanned.value) return `${v.framingDays} jours de formulation`
if (v.windowHours !== undefined && effectiveRoute.value === v.route) return `fenêtre de ${v.windowHours} h`
return protocol.value ? `${protocol.value.durationDays} jours de vote` : ''
})
const thresholdLabel = computed(() => {
const p = protocol.value
if (!p) return ''
if (p.method === 'consent') return 'zéro objection maintenue'
return METHOD_LABELS[p.method]
})
// ── Bouton principal ──
const mainLabel = computed(() => {
switch (effectiveRoute.value) {
case 'advice': return 'Demande leur avis'
case 'transmit': return 'Transmets'
case 'record': return 'Consigne'
case 'collective': return framingPlanned.value ? 'Ouvre la formulation' : 'Ouvre le vote'
default: return 'Décide'
}
})
const blockReason = computed(() =>
lightening.value && overrideNote.value.trim().length === 0
? 'Alléger se motive — écris pourquoi, ta note s\'affichera.'
: '',
)
const disabled = computed(
() => title.value.trim().length === 0 || blockReason.value !== '' || chemin.value === null,
)
const engagesOpen = computed(
() => (weight.value === 'binding' || weight.value === 'structural') && !scope.value.selfOnly,
)
const baselineSuggested = computed(() => ['advice', 'collective'].includes(effectiveRoute.value))
const suggestion = computed(() => chemin.value?.suggestion)
const reviewLocked = computed(
() => chemin.value?.reviewRequired === true && effectiveRoute.value !== 'solo',
)
// ── Valider : capture → applyPath → fiche (ou Fil si adopté sur-le-champ) ──
async function validate() {
const v = chemin.value
if (!v || disabled.value) return
const created = decisions.capture(title.value)
if ('ok' in created) { error.value = created.reason; return }
if (parentId.value) {
created.parentDecisionId = parentId.value
created.chainKind = chainKind.value ?? 'revision'
}
const edits: ApplyPathEdits = {
scope: {
selfOnly: scope.value.selfOnly,
circleIds: [...scope.value.circleIds],
personIds: [...scope.value.personIds],
},
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
visibility: scope.value.selfOnly ? 'private' : 'scope',
}
if (effectiveRoute.value !== v.route) edits.route = effectiveRoute.value
if (amendsClauseId.value) edits.amendsClauseId = amendsClauseId.value
if (baselineSuggested.value && baselineNote.value.trim()) edits.baselineNote = baselineNote.value.trim()
if (resourceNote.value.trim().length > 0 || typeof resourceAmount.value === 'number') {
edits.resources = {
note: resourceNote.value.trim(),
...(typeof resourceAmount.value === 'number'
? { amount: resourceAmount.value, unit: resourceUnit.value }
: {}),
}
}
if (chosenAlt.value === 'record' && decidedHow.value.trim()) edits.decidedHow = decidedHow.value.trim()
if (chosenAlt.value === 'parametric') {
if (spec.value) edits.paramSpec = spec.value
const pid = col.settings?.protocolByRange.parametric
if (pid) edits.protocolId = pid
}
if (chosenAlt.value === 'binary') {
const pid = col.protocols.find(p => p.method === 'binary')?.id
if (pid) edits.protocolId = pid
}
if (lightening.value) edits.overrideNote = overrideNote.value.trim()
if (mandateAttached.value && suggestion.value?.kind === 'claim-mandate') {
edits.createsMandate = {
title: `Mandat — ${title.value.trim()}`,
domainCircleIds: [...scope.value.circleIds],
domainTags: [...tags.value],
durationDays: 90,
reportEveryDays: 30,
}
}
const applied = decisions.applyPath(created, { ...v, reviewRequired: reviewChecked.value }, edits)
if ('ok' in applied) { error.value = applied.reason; return }
if (engraveChecked.value && applied.status === 'adopted') await decisions.engrave(applied.id)
if (applied.status === 'adopted'
&& (effectiveRoute.value === 'solo' || effectiveRoute.value === 'record' || v.conservatoryChain)) {
stamping.value = true
setTimeout(() => navigateTo('/'), 1100)
return
}
await navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="decider">
<div v-if="!col.current" class="ld-card decider__none">
<p>Aucun collectif actif — ouvre ou crée un collectif d'abord.</p>
<NuxtLink to="/creer" class="ld-btn">Créer un collectif</NuxtLink>
</div>
<template v-else>
<input
v-model="title"
type="text"
class="decider__title"
:placeholder="CAPTURE_PLACEHOLDER"
:aria-label="CAPTURE_PLACEHOLDER"
>
<CheminQ0
:title="title"
:linked-clause-id="amendsClauseId"
@contest="amendsClauseId = $event"
/>
<CheminChips
v-model:scope="scope"
v-model:reversibility="reversibility"
v-model:weight="weight"
v-model:urgent="urgent"
v-model:resource-note="resourceNote"
v-model:resource-amount="resourceAmount"
v-model:resource-unit="resourceUnit"
v-model:baseline-note="baselineNote"
:stack="stack"
:engages-open="engagesOpen"
:baseline-suggested="baselineSuggested"
/>
<template v-if="chemin">
<CheminPathCard
:path="chemin"
:route-shown="effectiveRoute"
:overridden="overridden"
:override-label="overrideLabel"
:stack="stack"
:self-only="scope.selfOnly"
:duration-label="durationLabel"
:threshold-label="thresholdLabel"
:main-label="mainLabel"
v-model:review-checked="reviewChecked"
v-model:engrave-checked="engraveChecked"
:review-locked="reviewLocked"
:disabled="disabled"
:block-reason="blockReason"
@validate="validate"
/>
<p v-if="error" class="decider__error">{{ error }}</p>
<CheminAlternatives
v-model:chosen="chosenAlt"
v-model:decided-how="decidedHow"
v-model:override-note="overrideNote"
v-model:spec="spec"
:alternatives="chemin.alternatives"
:parametric-hint="chemin.parametricHint === true"
:lightening="lightening"
/>
<details class="decider__why">
<summary>{{ WHY_DISCLOSURE }}</summary>
<p>Règle {{ chemin.rule }} — {{ chemin.explanation }}</p>
<p>
Le chemin est recommandé par le Pacte de ton collectif, jamais imposé :
alourdir est libre en un tap, alléger se motive et s'affiche à tous.
</p>
</details>
<CheminSuggestion
v-if="suggestion"
v-model:mandate-attached="mandateAttached"
:suggestion="suggestion"
/>
</template>
</template>
<!-- Micro-célébration — tampon bref puis retour au Fil -->
<Transition name="stamp">
<div v-if="stamping" class="decider__overlay">
<span class="ld-stamp decider__stamp">井 {{ ADOPTED_STAMP }}</span>
<p class="decider__toast">{{ ADOPTED_TOAST }}</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.decider {
width: 100%;
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.decider__none {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
}
.decider__title {
width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: clamp(1.125rem, 3.5vw, 1.375rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.decider__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.decider__why {
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.decider__why summary {
cursor: pointer;
font-weight: 700;
padding: 0.25rem;
border-radius: var(--r-input);
width: fit-content;
}
.decider__why summary:hover { color: var(--mood-text); }
.decider__why p { margin: 0.5rem 0 0 0.25rem; line-height: 1.5; }
.decider__overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--mood-bg) 88%, transparent);
}
.decider__stamp { font-size: 1.5rem; padding: 0.75rem 1.5rem; }
.decider__toast { margin: 0; font-size: 1rem; font-weight: 600; }
.stamp-enter-active { transition: opacity 0.12s ease; }
.stamp-enter-from { opacity: 0; }
.stamp-enter-active .decider__stamp {
animation: stamp-in 0.12s ease;
}
@keyframes stamp-in {
from { transform: rotate(-10deg) scale(1.4); opacity: 0; }
to { transform: rotate(-10deg) scale(1); opacity: 1; }
}
</style>
@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* /decisions/[id] — la fiche décision, URL permanente de l'objet pivot.
* Progressive disclosure par état : timeline + périmètre toujours ;
* s'instruire (formulation/vote), fenêtre, formulation, éléments de dossier,
* session, vigueur, chaînage. Imprimable en PV A4 ; gravure locale.
*/
import {
ADOPTED_STAMP, BASELINE_ARROW, ENGAGES_LABEL, INERTIA_LABELS,
REOPEN_HANDLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE, WEIGHT_LABELS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, displayStatusLabel, dateFr } from '~/components/decisions/decisionUi'
const route = useRoute()
const col = useCollectiveStore()
const decisionId = computed(() => route.params.id as string)
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const shown = computed(() =>
decision.value ? displayStatus(decision.value, session.value) : 'draft')
// ── Origine : protocole et inertie (poignée « Remettre en question ») ──
const originProtocol = computed(() =>
decision.value?.protocolId
? col.protocols.find(p => p.id === decision.value!.protocolId)?.name
: undefined)
const originInertia = computed(() => {
const clauseId = decision.value?.amendsClauseId
if (!clauseId) return undefined
const clause = col.clauses.find(c => c.id === clauseId)
return clause ? INERTIA_LABELS[clause.inertia] : undefined
})
const originLine = computed(() => {
const parts: string[] = []
if (originProtocol.value) parts.push(originProtocol.value)
if (originInertia.value) parts.push(originInertia.value)
return parts.length > 0
? `Le protocole et l'inertie d'origine s'appliquent : ${parts.join(' — ')}.`
: 'Le protocole et l\'inertie d\'origine s\'appliquent.'
})
// ── Blocs selon l'état ──
const showInstruct = computed(() =>
decision.value?.status === 'framing' || decision.value?.status === 'voting')
const showWindow = computed(() =>
decision.value?.status === 'objection' || decision.value?.status === 'advice')
const hasElements = computed(() =>
col.decisions.some(d =>
d.parentDecisionId === decisionId.value && d.chainKind === 'element'))
const framingDays = computed(() => col.settings?.triage.framingDays ?? 14)
// ── Gestes ──
async function splitDossier() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=element`)
}
async function reopen() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=revision`)
}
</script>
<template>
<!-- ld-v2 -->
<article v-if="decision" class="fiche">
<!-- ── En-tête ── -->
<header class="fiche__head">
<div class="fiche__status-row">
<span class="status-pill" :class="`status-${shown}`">
{{ displayStatusLabel(shown) }}
</span>
<span v-if="decision.status === 'adopted'" class="ld-stamp">
井 {{ ADOPTED_STAMP }}
</span>
<span v-if="decision.engraving" class="fiche__well" title="gravée">井</span>
</div>
<h1 class="fiche__title">{{ decision.title }}</h1>
<p v-if="decision.baselineNote" class="fiche__baseline">
<span class="fiche__baseline-arrow">{{ BASELINE_ARROW }}</span>
<span>{{ decision.baselineNote }}</span>
</p>
<div class="fiche__meta">
<span class="fiche__route" :style="{ color: `var(--route-${decision.route})` }">
<UIcon :name="ROUTE_ICONS[decision.route]" />
<span>{{ ROUTE_LABELS[decision.route] }}</span>
</span>
<span class="fiche__chip">{{ WEIGHT_LABELS[decision.weight] }}</span>
<span v-if="decision.urgent" class="fiche__urgent">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_BADGE }}</span>
</span>
</div>
<p v-if="decision.decidedHow" class="fiche__how">
Comment ça s'est décidé : « {{ decision.decidedHow }} »
</p>
<div v-if="decision.routeOverridden && decision.overrideNote" class="fiche__banner">
<UIcon name="i-lucide-feather" />
<span><strong>Chemin allégé</strong> — « {{ decision.overrideNote }} »</span>
</div>
<div v-if="decision.scopeKeptNote" class="fiche__banner">
<UIcon name="i-lucide-circle-dot" />
<span><strong>Périmètre maintenu</strong> — « {{ decision.scopeKeptNote }} »</span>
</div>
</header>
<!-- ── Actions + fiche de preuve ── -->
<DecisionProof :decision="decision" />
<!-- ── Cycle de vie + périmètre ── -->
<div class="fiche__grid">
<div class="ld-card fiche__card">
<h2 class="fiche__card-title">Cycle de vie</h2>
<DecisionTimeline :decision="decision" :session="session" />
</div>
<div class="ld-card fiche__card">
<DecisionPerimeter :decision="decision" />
</div>
</div>
<!-- ── S'instruire ── -->
<div v-if="showInstruct" class="ld-card fiche__card">
<DecisionInstruct :decision="decision" />
</div>
<!-- ── Fenêtre ── -->
<div v-if="showWindow" class="ld-card fiche__card">
<DecisionWindow :decision="decision" />
</div>
<!-- ── Formulation ── -->
<div v-if="decision.status === 'framing'" class="ld-card fiche__card">
<h2 class="fiche__card-title">Formulation</h2>
<p class="fiche__framing-phrase">
{{ framingDays }} jours pour s'instruire et formuler des contre-propositions.
</p>
<LdCountdown v-if="decision.windowEndsAt" :ends-at="decision.windowEndsAt" />
<div v-if="decision.weight === 'structural'" class="no-print">
<button type="button" class="ld-btn ld-btn--ghost" @click="splitDossier()">
<UIcon name="i-lucide-scissors" />
<span>Découper en micro-décisions</span>
</button>
</div>
</div>
<!-- ── Éléments du dossier ── -->
<div v-if="hasElements" class="ld-card fiche__card">
<DecisionElements :decision="decision" />
</div>
<!-- ── Session ── -->
<div v-if="session" class="ld-card fiche__card">
<DecisionSession :decision="decision" />
</div>
<!-- ── Vigueur ── -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card">
<DecisionVigor :decision="decision" />
</div>
<!-- ── Remettre en question ── -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card fiche__reopen no-print">
<div>
<p class="fiche__reopen-title">{{ REOPEN_HANDLE }}</p>
<p class="fiche__reopen-line">{{ originLine }}</p>
</div>
<button type="button" class="ld-btn ld-btn--ghost" @click="reopen()">
<UIcon name="i-lucide-rotate-ccw" />
<span>{{ REOPEN_HANDLE }}</span>
</button>
</div>
<!-- ── Chaînage ── -->
<div class="fiche__chain-wrap">
<DecisionChain :decision="decision" />
</div>
<!-- ── Pied de PV (impression) ── -->
<p class="print-only fiche__pv-foot">
{{ ENGAGES_LABEL }} : {{ decision.resources?.note ?? '—' }}
— fiche imprimée le {{ dateFr(new Date().toISOString()) }} · libreDecision
</p>
</article>
<div v-else class="ld-card fiche__missing">
<p>Cette décision est introuvable dans ce collectif.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Retour au registre</NuxtLink>
</div>
</template>
<style scoped>
.fiche {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.fiche__head { display: flex; flex-direction: column; gap: 0.625rem; }
.fiche__status-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.fiche__well {
font-size: 1.25rem;
font-weight: 800;
color: var(--mood-status-vigueur);
transform: rotate(-10deg);
}
.fiche__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.875rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.fiche__baseline {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
font-size: 0.9375rem;
}
.fiche__baseline-arrow {
font-weight: 800;
font-size: 0.8125rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.fiche__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.fiche__route {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 800;
font-size: 0.9375rem;
}
.fiche__chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__urgent {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__how {
margin: 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.fiche__banner {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--r-input);
font-size: 0.9375rem;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
box-shadow: inset 0 0 0 1.5px var(--mood-status-fenetre);
}
.fiche__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.fiche__grid { grid-template-columns: 1fr 1fr; }
}
.fiche__card {
padding: 1.25rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fiche__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.fiche__framing-phrase { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.fiche__reopen {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.fiche__reopen-title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
.fiche__reopen-line {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.fiche__pv-foot { font-size: 0.75rem; color: #333333; }
.fiche__missing {
max-width: 52rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
@media print {
.fiche__grid { grid-template-columns: 1fr 1fr; }
.fiche__card { padding: 0.5rem 0; }
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
// <!-- ld-v2 --> La salle de vote — le geste démocratique central : instruit,
// puis prononcé. Cinq modalités, un seul lieu ; la clôture due est constatée
// au chargement, l'adoption s'applique d'elle-même et se fête sobrement.
import { wotThreshold } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
ADOPTED_STAMP, ADOPTED_TOAST, ELECTION_RULE, SECRET_DISPLAY, STATUS_LABELS,
VOTE_PRIVACY, WHO_VOTES, WORKSHOP_MODE,
} from '~/lexicon'
import type { Id, VoteSession } from '~/types/domain'
const route = useRoute()
const col = useCollectiveStore()
const store = useDecisionsStore()
const decisionId = computed(() => String(route.params.id))
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed<VoteSession | undefined>(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const protocol = computed(() => col.protocols.find(p => p.id === session.value?.protocolId))
const method = computed(() => protocol.value?.method)
const secret = computed(() => protocol.value?.ballot === 'secret')
// ── Qui vote — la liste arrêtée ──
const eligibleIds = computed<Id[]>(() => session.value?.corpusPersonIds ?? [])
const voterEntries = computed(() =>
col.people.filter(p => eligibleIds.value.includes(p.id)).map(person => ({ person })))
const arrestedDate = computed(() => session.value
? new Date(session.value.opensAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
: '')
// ── Mode atelier — saisir pour quelqu'un, en présence ──
const workshop = ref(false)
const asId = ref<Id | ''>('')
onMounted(() => { workshop.value = !!localStorage.getItem('ld2-workshop') })
const votingAs = computed<Id | undefined>(() =>
workshop.value && asId.value && asId.value !== col.me?.id ? asId.value : undefined)
const voterId = computed(() => votingAs.value ?? col.me?.id)
const inList = computed(() => voterId.value !== undefined && eligibleIds.value.includes(voterId.value))
const canAct = computed(() => session.value?.status === 'open' && inList.value)
const isSteward = computed(() => {
const d = decision.value
const me = col.me
if (!d || !me) return false
return d.stewardIds.length > 0 ? d.stewardIds.includes(me.id) : d.authorId === me.id
})
// ── Clôture due, constatée au chargement ──
const toast = ref('')
function celebrate() {
toast.value = ADOPTED_TOAST
setTimeout(() => { toast.value = '' }, 5000)
}
onMounted(() => {
const s = session.value
if (s && s.status === 'open' && s.closesAt <= col.now()) {
const closed = store.closeSession(s)
if (!('ok' in closed) && closed.outcome === 'adopted') celebrate()
}
})
const displayStatus = computed(() => {
if (session.value?.status === 'frozen') return { css: 'status-frozen', label: 'figé' }
const s = decision.value?.status ?? 'voting'
return { css: `status-${s}`, label: STATUS_LABELS[s] }
})
// ── Affiche de session (impression A4) ──
const closesAtLong = computed(() => session.value
? new Date(session.value.closesAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
: '')
const posterRule = computed(() => {
const f = protocol.value?.formula
if (!f) return ''
switch (method.value) {
case 'consent': return 'Zéro objection maintenue à l\'échéance — le collectif consent.'
case 'nuanced': return `${f.nuancedThresholdPct ?? 80} % de nuances positives (3 à 5) requis.`
case 'binary': {
const active = session.value ? store.activeVotes(session.value.id) : []
const total = active.filter(v => v.value === 'for' || v.value === 'against').length
const threshold = wotThreshold(Math.max(session.value?.corpusSize ?? 1, 1), total,
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase)
return `Seuil actuel : ${threshold.toLocaleString('fr-FR')} pour — il descend quand la participation monte.`
}
case 'parametric': return `Le collectif retient la médiane de chaque curseur, cristallisée par le garant${f.parametricMinParticipants !== undefined ? ` (quorum ${f.parametricMinParticipants})` : ''}.`
case 'election': return ELECTION_RULE
default: return ''
}
})
function printPoster() { window.print() }
</script>
<template>
<!-- ld-v2 -->
<div class="vote-room">
<template v-if="decision && session && protocol">
<div class="no-print vote-room__inner">
<!-- En-tête -->
<header class="vr__head">
<NuxtLink :to="`/decisions/${decision.id}`" class="vr__back">
<UIcon name="i-lucide-arrow-left" />
<span>Fiche décision</span>
</NuxtLink>
<div class="vr__title-row">
<h1 class="vr__title">{{ decision.title }}</h1>
<span v-if="decision.status === 'adopted'" class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
</div>
<div class="vr__pills">
<span class="status-pill" :class="displayStatus.css">{{ displayStatus.label }}</span>
<span class="vr__protocol">{{ protocol.name }}</span>
<span v-if="secret" class="vr__secret">
<UIcon name="i-lucide-eye-off" />
<span>{{ SECRET_DISPLAY }}</span>
</span>
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
<button class="ld-btn ld-btn--quiet vr__print" type="button" @click="printPoster()">
<UIcon name="i-lucide-printer" />
<span>Imprimer</span>
</button>
</div>
<div class="vr__who">
<span>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</span>
<LdAvatarStack :people="voterEntries" :size="28" :max="8" />
</div>
</header>
<!-- Mode atelier -->
<div v-if="workshop" class="vr__workshop">
<UIcon name="i-lucide-users-round" />
<span>{{ WORKSHOP_MODE }} — saisir pour</span>
<select v-model="asId" class="vr__workshop-select">
<option value="">moi-même</option>
<option v-for="entry in voterEntries" :key="entry.person.id" :value="entry.person.id">
{{ entry.person.displayName }}
</option>
</select>
</div>
<p v-if="session.status === 'open' && !inList" class="vr__consultative">
Tu n'es pas dans la liste arrêtée — ta voix est consultative.
</p>
<!-- S'instruire, puis se prononcer -->
<VoteInstruct :decision="decision" :secret="secret" />
<VoteConsent
v-if="method === 'consent'"
:decision="decision" :session="session"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteNuanced
v-else-if="method === 'nuanced'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteBinary
v-else-if="method === 'binary'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteParametric
v-else-if="method === 'parametric' && decision.paramSpec"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
:is-steward="isSteward"
@adopted="celebrate()"
/>
<VoteElection
v-else-if="method === 'election'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<!-- Transversal : re-vote et discrétion -->
<p class="vr__privacy">
<UIcon name="i-lucide-shield" />
<span>{{ VOTE_PRIVACY }}</span>
</p>
<VoteMyHistory v-if="col.me && voterId === col.me.id" :session="session" :voter-id="col.me.id" />
</div>
<!-- Affiche de session A4 -->
<div class="print-only vr__poster">
<h1>{{ decision.title }}</h1>
<p v-if="decision.body" class="vr__poster-body">{{ decision.body }}</p>
<hr>
<p><strong>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</strong></p>
<p v-if="!secret">{{ voterEntries.map(e => e.person.displayName).join(' · ') }}</p>
<p>{{ posterRule }}</p>
<p><strong>Échéance :</strong> {{ closesAtLong }}</p>
<p><strong>Comment participer :</strong> ouvre la salle de vote « {{ decision.title }} » dans libreDecision, ou confie ton geste en présence (mode atelier).</p>
<p class="vr__poster-seal">井</p>
</div>
</template>
<!-- Garde-fous -->
<div v-else-if="!decision" class="ld-card vr__empty">
<p>Cette décision est introuvable.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Toutes les décisions</NuxtLink>
</div>
<div v-else class="ld-card vr__empty">
<p>Cette décision n'a pas encore de session de vote.</p>
<NuxtLink :to="`/decisions/${decisionId}`" class="ld-btn ld-btn--ghost">Fiche décision</NuxtLink>
</div>
<!-- Micro-célébration — aucune fanfare -->
<Transition name="vr-toast">
<div v-if="toast" class="vr__toast">
<span class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
<span>{{ toast }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.vote-room { max-width: 46rem; width: 100%; margin: 0 auto; }
.vote-room__inner { display: flex; flex-direction: column; gap: 1rem; }
.vr__head { display: flex; flex-direction: column; gap: 0.6rem; }
.vr__back {
display: inline-flex; align-items: center; gap: 0.35rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-decoration: none;
}
.vr__back:hover { color: var(--mood-accent); }
.vr__title-row { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
.vr__title {
margin: 0; font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 800;
line-height: 1.25; letter-spacing: -0.01em; flex: 1; min-width: 0;
}
.vr__pills { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.vr__protocol {
font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent);
background: var(--mood-accent-soft); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__secret {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-status-fige);
background: var(--mood-status-fige-bg); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__print { margin-left: auto; padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
.vr__who {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem 0.9rem;
font-size: 0.9375rem; font-weight: 600; color: var(--mood-text-muted);
}
.vr__workshop {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;
padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-secondary) 10%, var(--mood-surface));
color: var(--mood-secondary); font-size: 0.875rem; font-weight: 700;
}
.vr__workshop-select {
min-height: 2.25rem; padding: 0.25rem 0.6rem; font-size: 0.875rem; font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.vr__consultative {
margin: 0; padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre);
font-size: 0.875rem; font-weight: 600;
}
.vr__privacy {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; color: var(--mood-text-muted);
}
.vr__empty {
padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;
text-align: center; font-weight: 600;
}
.vr__empty p { margin: 0; }
.vr__toast {
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
z-index: 50; display: flex; align-items: center; gap: 0.75rem;
background: var(--mood-surface); color: var(--mood-text);
padding: 0.75rem 1.25rem; border-radius: var(--r-card);
box-shadow: var(--shadow-raised); font-weight: 600; font-size: 0.9375rem;
max-width: min(92vw, 30rem);
}
.vr-toast-enter-active, .vr-toast-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; }
.vr-toast-enter-from, .vr-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* Affiche A4 — noir sur blanc, sans ambiance */
.vr__poster { color: #111; font-size: 12pt; line-height: 1.5; }
.vr__poster h1 { font-size: 22pt; margin: 0 0 8pt; }
.vr__poster hr { border: none; border-top: 1pt solid #111; margin: 8pt 0; }
.vr__poster-body { white-space: pre-wrap; }
.vr__poster-seal { font-size: 28pt; text-align: right; margin-top: 16pt; }
</style>
@@ -0,0 +1,320 @@
<script setup lang="ts">
/**
* /decisions — le registre v2. Pills d'état cliquables (mapping unique),
* pills courtes de route, filtres (cercle/tag/me concerne/poids/gravées/
* consignées), recherche locale. Zéro bouton de création : on crée par la
* capture — l'état vide le rappelle.
*/
import type { Decision, DecisionRoute, Weight } from '~/types/domain'
import { ROUTE_SHORT, STATUS_LABELS, FROZEN_LABEL, CAPTURE_PLACEHOLDER } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, fold, type DisplayStatus } from '~/components/decisions/decisionUi'
const col = useCollectiveStore()
// ── Filtres ──
const query = ref('')
const statusFilter = ref<DisplayStatus | null>(null)
const routeFilter = ref<DecisionRoute | null>(null)
const filterCircle = ref<string | null>(null)
const filterTag = ref<string | null>(null)
const filterMine = ref(false)
const filterWeight = ref<Weight | null>(null)
const filterEngraved = ref(false)
const filterRecorded = ref(false)
const latestSession = (decisionId: string) =>
col.sessions
.filter(s => s.decisionId === decisionId)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
const shownStatus = (d: Decision): DisplayStatus => displayStatus(d, latestSession(d.id))
// ── Pills d'état — l'ordre du cycle, comptées, masquées à zéro ──
const STATUS_ORDER: DisplayStatus[] = [
'draft', 'advice', 'objection', 'framing', 'voting', 'frozen',
'adopted', 'closed', 'rejected', 'revoked', 'transmitted',
]
const statusPills = computed(() =>
STATUS_ORDER
.map(status => ({
status,
label: status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status],
count: col.decisions.filter(d => shownStatus(d) === status).length,
}))
.filter(pill => pill.count > 0),
)
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routePills = computed(() =>
ROUTES
.map(route => ({ route, count: col.decisions.filter(d => d.route === route).length }))
.filter(pill => pill.count > 0),
)
// ── Liste filtrée puis triée : en cours par échéance, terminées ensuite ──
const meId = computed(() => col.me?.id ?? null)
const ACTIVE: DisplayStatus[] = ['draft', 'advice', 'objection', 'framing', 'voting', 'frozen']
function concernsMe(d: Decision): boolean {
if (!meId.value) return false
if (d.authorId === meId.value) return true
return col.concerns.some(c => c.decisionId === d.id && c.personId === meId.value)
}
function deadlineOf(d: Decision): string {
return d.windowEndsAt ?? latestSession(d.id)?.closesAt ?? d.createdAt
}
const filtered = computed(() => {
const needle = fold(query.value.trim())
return col.decisions.filter((d) => {
if (statusFilter.value && shownStatus(d) !== statusFilter.value) return false
if (routeFilter.value && d.route !== routeFilter.value) return false
if (filterCircle.value && !d.scope.circleIds.includes(filterCircle.value)) return false
if (filterTag.value && !d.tags.includes(filterTag.value)) return false
if (filterMine.value && !concernsMe(d)) return false
if (filterWeight.value && d.weight !== filterWeight.value) return false
if (filterEngraved.value && !d.engraving) return false
if (filterRecorded.value && d.route !== 'record') return false
if (needle.length > 0 && !fold(d.title).includes(needle)
&& !d.tags.some(t => fold(t).includes(needle))) return false
return true
})
})
const sorted = computed(() => {
const active = filtered.value
.filter(d => ACTIVE.includes(shownStatus(d)))
.sort((a, b) => deadlineOf(a).localeCompare(deadlineOf(b)))
const settled = filtered.value
.filter(d => !ACTIVE.includes(shownStatus(d)))
.sort((a, b) => (b.decidedAt ?? b.updatedAt).localeCompare(a.decidedAt ?? a.updatedAt))
return [...active, ...settled]
})
const hasAny = computed(() => col.decisions.length > 0)
function toggleStatus(status: DisplayStatus) {
statusFilter.value = statusFilter.value === status ? null : status
}
function toggleRoute(route: DecisionRoute) {
routeFilter.value = routeFilter.value === route ? null : route
}
</script>
<template>
<!-- ld-v2 -->
<div class="reg">
<header class="reg__header">
<div>
<h1 class="reg__title">Décisions</h1>
<p class="reg__subtitle">le registre — chaque décision a son URL, pour toujours</p>
</div>
<NuxtLink to="/decisions/observatoire" class="reg__observatory">
<UIcon name="i-lucide-telescope" />
<span>L'Observatoire</span>
</NuxtLink>
</header>
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par état">
<button
v-for="pill in statusPills"
:key="pill.status"
type="button"
class="status-pill is-clickable"
:class="[`status-${pill.status}`, { active: statusFilter === pill.status }]"
@click="toggleStatus(pill.status)"
>
<span>{{ pill.label }}</span>
<span class="reg__count">{{ pill.count }}</span>
</button>
</div>
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par chemin">
<button
v-for="pill in routePills"
:key="pill.route"
type="button"
class="reg__route-pill"
:class="{ 'reg__route-pill--on': routeFilter === pill.route }"
@click="toggleRoute(pill.route)"
>
{{ ROUTE_SHORT[pill.route] }}
</button>
</div>
<div v-if="hasAny" class="reg__tools">
<div class="reg__search">
<UIcon name="i-lucide-search" class="reg__search-icon" />
<input
v-model="query"
type="search"
placeholder="Chercher une décision…"
aria-label="Chercher une décision"
>
</div>
<DecisionRegistryFilters
v-model:circle-id="filterCircle"
v-model:tag="filterTag"
v-model:mine="filterMine"
v-model:weight="filterWeight"
v-model:engraved="filterEngraved"
v-model:recorded="filterRecorded"
/>
</div>
<div v-if="sorted.length > 0" class="reg__list">
<DecisionRegistryCard v-for="d in sorted" :key="d.id" :decision="d" />
</div>
<div v-else class="ld-card reg__empty">
<template v-if="!hasAny">
<span class="reg__empty-well">井</span>
<p class="reg__empty-title">Aucune décision pour l'instant.</p>
<p class="reg__empty-line">
Rien ne se crée ici : tout part de la capture —
« {{ CAPTURE_PLACEHOLDER }} » sur Aujourd'hui.
</p>
<NuxtLink to="/" class="ld-btn ld-btn--ghost">
<UIcon name="i-lucide-sun-medium" />
<span>Aller à Aujourd'hui</span>
</NuxtLink>
</template>
<template v-else>
<p class="reg__empty-title">Rien ne correspond à ces filtres.</p>
<p class="reg__empty-line">Élargis la recherche — ou décide par la capture, sur Aujourd'hui.</p>
</template>
</div>
</div>
</template>
<style scoped>
.reg {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.reg__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.reg__title {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.reg__subtitle {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.reg__observatory {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.375rem 1rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
font-weight: 700;
text-decoration: none;
transition: transform 0.1s ease;
}
.reg__observatory:hover { transform: translateY(-1px); }
.reg__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reg__count {
font-size: 0.75rem;
opacity: 0.75;
}
.reg__route-pill {
min-height: 2.25rem;
padding: 0.25rem 0.875rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s ease;
}
.reg__route-pill:hover { transform: translateY(-1px); color: var(--mood-text); }
.reg__route-pill--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.reg__tools {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.reg__search {
position: relative;
flex: 1;
min-width: 14rem;
}
.reg__search input {
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem 0.5rem 2.5rem;
font-size: 0.9375rem;
box-shadow: var(--shadow-card);
}
.reg__search-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--mood-text-muted);
pointer-events: none;
}
.reg__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reg__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
text-align: center;
}
.reg__empty-well {
font-size: 2rem;
font-weight: 800;
color: var(--mood-accent);
opacity: 0.5;
transform: rotate(-10deg);
}
.reg__empty-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.reg__empty-line {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
max-width: 28rem;
}
</style>
@@ -0,0 +1,385 @@
<script setup lang="ts">
// <!-- ld-v2 --> /decisions/observatoire — L'Observatoire (Δ12) : comment nous
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
// faits comptés, des barres sobres aux couleurs du mood — jamais un score.
import type { DecisionRoute, Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
} from '~/lexicon'
import { formatDay } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const decisionsStore = useDecisionsStore()
const reviewLabels = REVIEW_VERDICTS
// ── Filtres : personne / cercle / tag / période ──
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
const since = computed(() =>
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
)
const filtered = computed(() => col.decisions.filter(d =>
(fPerson.value === '' || d.authorId === fPerson.value)
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
&& (fTag.value === '' || d.tags.includes(fTag.value))
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
))
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
// ── Autonomie : ratio par route, maturité, temps médians, participation ──
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routeBars = computed(() => {
const total = Math.max(1, engaged.value.length)
return ROUTES.map((r) => {
const n = engaged.value.filter(d => d.route === r).length
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
})
})
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
function median(xs: number[]): number {
if (xs.length === 0) return 0
const s = [...xs].sort((a, b) => a - b)
const mid = Math.floor(s.length / 2)
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
}
const medianBars = computed(() => ROUTES.map((r) => {
const days = filtered.value
.filter(d => d.route === r && d.decidedAt !== undefined)
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
const m = median(days)
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
}))
const participation = computed(() => {
const ids = new Set(filtered.value.map(d => d.id))
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
})
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
// ── Ressources PAR UNITÉ — l'huile et l'eau, jamais additionnées ──
const resourceLines = computed(() => {
const byUnit = new Map<string, { total: number; count: number }>()
for (const d of engaged.value) {
if (!d.resources?.amount) continue
const unit = d.resources.unit ?? 'sans unité'
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
line.total += d.resources.amount
line.count += 1
byUnit.set(unit, line)
}
return [...byUnit.entries()].map(([unit, l]) => ({
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
}))
})
// ── Épreuves du réel ──
const now = new Date().toISOString()
const reviewsDue = computed(() => filtered.value
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
filtered.value.filter(d => d.review?.verdict === v).length
// ── Révoquées, consignations, maturation ──
const revoked = computed(() => filtered.value
.filter(d => d.status === 'revoked')
.map(d => ({
decision: d,
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
})))
const records = computed(() => filtered.value
.filter(d => d.route === 'record')
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
const maturations = computed(() => {
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
for (const r of records.value) {
const tags = [...new Set(r.tags)].sort()
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
const key = `${tags[i]}|${tags[j]}`
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
entry.ids.add(r.id)
pairs.set(key, entry)
}
}
}
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
.sort((a, b) => b.ids.size - a.ids.size)
const seen = new Set<string>()
return ripe.filter((p) => {
const sig = [...p.ids].sort().join(',')
if (seen.has(sig)) return false
seen.add(sig)
return true
}).slice(0, 4)
})
// ── Élagage & décisions jamais revues ──
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
const unusedProtocols = computed(() => col.protocols.filter(p =>
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
const idleMandates = computed(() => col.mandates.filter(m =>
m.status === 'active' && m.startsAt <= sixMonthsAgo
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
const neverReviewed = computed(() => {
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
return filtered.value.filter(d =>
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
&& d.decidedAt !== undefined && d.decidedAt <= limit)
})
</script>
<template>
<!-- ld-v2 -->
<div class="obs">
<header class="obs__header">
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
</header>
<!-- Filtres -->
<div class="obs__filters">
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
<option value="">Toutes les personnes</option>
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
</select>
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
<option value="">Tous les cercles</option>
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
<option value="">Tous les tags</option>
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
</select>
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
<option value="">Depuis toujours</option>
<option value="30">30 derniers jours</option>
<option value="90">90 derniers jours</option>
<option value="365">Cette année</option>
</select>
</div>
<!-- Autonomie -->
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
<ObservatoryBars :items="routeBars" />
<div class="obs__tiles">
<ObservatoryStat
label="Maturité"
:value="`${recordedCount} / ${tooledCount}`"
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
/>
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
</div>
<h3 class="obs__h3">Temps médians par route</h3>
<ObservatoryBars :items="medianBars" />
<h3 class="obs__h3">Dérogations au chemin</h3>
<div class="obs__tiles">
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
</div>
</ObservatorySection>
<!-- Ressources par unité -->
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
<ul v-if="resourceLines.length" class="obs__list">
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
</li>
</ul>
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
</ObservatorySection>
<!-- Épreuves du réel -->
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
<div class="obs__tiles">
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
</div>
<ul v-if="reviewsDue.length" class="obs__list">
<li v-for="r in reviewsDue" :key="r.id">
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
<span class="obs__row-title">{{ r.title }}</span>
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
</NuxtLink>
</li>
</ul>
</ObservatorySection>
<!-- Révoquées — ce qu'on en a appris -->
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
<ul v-if="revoked.length" class="obs__list">
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
<span class="obs__row-title">{{ entry.decision.title }}</span>
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
</NuxtLink>
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
<UIcon name="i-lucide-link" />
la décision qui l'a révoquée
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant — aucune révoquée sur la période.</p>
</ObservatorySection>
<!-- Consignations -->
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs — consigné ici, tel quel">
<ul v-if="records.length" class="obs__list">
<li v-for="d in records" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
<span class="obs__row-title">{{ d.title }}</span>
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
<p class="obs__card-title">
<UIcon name="i-lucide-sprout" />
{{ MATURATION_CARD }}
</p>
<p class="obs__muted">
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
</p>
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
Protocoliser
</NuxtLink>
</div>
</ObservatorySection>
<!-- Élagage & relances -->
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue — pas un enterrement silencieux">
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
</div>
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
<UIcon name="i-lucide-key-round" />
voir la fiche — une revue s'impose peut-être
</NuxtLink>
</div>
<template v-if="neverReviewed.length">
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
<ul class="obs__list">
<li v-for="d in neverReviewed" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
<span class="obs__row-title">{{ d.title }}</span>
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
</NuxtLink>
</li>
</ul>
</template>
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
Rien à élaguer — tout ce qui existe sert encore.
</p>
</ObservatorySection>
</div>
</template>
<style scoped>
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.obs__select {
flex: 1 1 10rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-input-bg);
border: none;
border-radius: var(--r-input);
box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.obs__h3 {
margin: 0.375rem 0 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.obs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
flex-wrap: wrap;
}
.obs__row:hover { transform: translateY(-1px); }
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
.obs__learned {
margin: 0;
padding-left: 0.75rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.obs__chain {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
padding-left: 0.75rem;
}
.obs__chain:hover { text-decoration: underline; }
.obs__card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
}
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
.obs__card-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
/**
* /donnees — local-first assumé : tes données, ton fichier, ta machine.
* Sections : collectifs locaux · import · jeux de démonstration · identité ·
* mode atelier · niveaux de preuve. Note synchro en pied.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import { PROOF_LOCAL, SYNC_NOTE, WORKSHOP_MODE } from '~/lexicon'
const store = useCollectiveStore()
// ── Jeux de démonstration — désactivés si déjà présents ──────
const seedBusy = ref<SeedName | null>(null)
const seedIssues = ref<string[]>([])
const hasSeed = (slug: string) => store.index.some(entry => entry.slug === slug)
async function loadSeed(name: SeedName) {
if (seedBusy.value) return
seedBusy.value = name
seedIssues.value = []
const result = await store.loadSeed(name)
seedIssues.value = result.issues.filter(i => i.level === 'error').map(i => i.message)
seedBusy.value = null
}
// ── Mode atelier — flag local lu par les écrans de vote ──────
const workshop = ref(false)
onMounted(() => {
workshop.value = localStorage.getItem('ld2-workshop') === '1'
})
function toggleWorkshop() {
workshop.value = !workshop.value
localStorage.setItem('ld2-workshop', workshop.value ? '1' : '0')
}
</script>
<template>
<!-- ld-v2 -->
<div class="donnees">
<header class="donnees__head">
<h1 class="donnees__title">Données locales</h1>
<p class="donnees__sub">Tes données, ton fichier, ta machine.</p>
</header>
<DataCollectivesCard />
<DataImportCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-sprout" /> Jeux de démonstration
</h2>
<p class="data-card__text">
Deux collectifs complets, importés par le même chemin qu'un fichier à toi.
</p>
<div class="seed-row">
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('duniter-g1') || seedBusy !== null"
@click="loadSeed('duniter-g1')"
>
<UIcon name="i-lucide-coins" />
{{ hasSeed('duniter-g1') ? 'Duniter Ğ1 — déjà là' : 'Explorer Duniter Ğ1' }}
</button>
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('atelier-du-canal') || seedBusy !== null"
@click="loadSeed('atelier-du-canal')"
>
<UIcon name="i-lucide-hammer" />
{{ hasSeed('atelier-du-canal') ? "L'Atelier du Canal — déjà là" : "Explorer l'Atelier du Canal" }}
</button>
</div>
<ul v-if="seedIssues.length" class="seed-issues">
<li v-for="msg in seedIssues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
</section>
<DataIdentityCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-users-round" />
<span class="cap-word">{{ WORKSHOP_MODE }}</span>
</h2>
<div class="workshop">
<p class="data-card__text">
En présence, une seule machine circule : chaque saisie garde qui a saisi,
pour qui, et la date de l'atelier.
</p>
<button
type="button"
class="switch"
:class="{ 'switch--on': workshop }"
role="switch"
:aria-checked="workshop"
:aria-label="WORKSHOP_MODE"
@click="toggleWorkshop"
>
<span class="switch__thumb" />
</button>
</div>
</section>
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-shield-check" /> Niveaux de preuve
</h2>
<ul class="proof-list">
<li class="proof-row">
<UIcon name="i-lucide-fingerprint" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name cap-word">{{ PROOF_LOCAL }}</span>
<span class="proof-row__desc">
Chaque gravure calcule l'empreinte du texte décidé, vérifiable sur ta machine.
</span>
</span>
<span class="status-pill status-adopted">disponible</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-globe" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">IPFS</span>
<span class="proof-row__desc">Une copie de l'empreinte répliquée entre pairs.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-link" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">Chaîne Duniter</span>
<span class="proof-row__desc">L'empreinte ancrée dans la chaîne, pour de bon.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
</ul>
</section>
<p class="donnees__sync">{{ SYNC_NOTE }}</p>
</div>
</template>
<style scoped>
.donnees {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
width: min(100%, 46rem);
margin-inline: auto;
padding: clamp(1rem, 3vw, 2rem) clamp(0.875rem, 3vw, 1.5rem) 4.5rem;
}
.donnees__head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.donnees__title {
margin: 0;
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--mood-text);
}
.donnees__sub {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--mood-text-muted);
}
.cap-word {
display: inline-block;
}
.cap-word::first-letter {
text-transform: uppercase;
}
.seed-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.seed-issues {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.875rem;
font-weight: 600;
}
.seed-issues li {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.workshop {
display: flex;
align-items: center;
gap: 1rem;
}
.switch {
position: relative;
width: 3.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
border-radius: var(--r-pill);
cursor: pointer;
background: var(--mood-input-border);
transition: background 0.15s ease;
}
.switch--on {
background: var(--mood-accent);
}
.switch__thumb {
position: absolute;
top: 50%;
left: 0.3rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
background: var(--mood-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transform: translateY(-50%);
transition: left 0.15s ease;
}
.switch--on .switch__thumb {
left: calc(100% - 1.45rem);
}
.proof-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.proof-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
border-radius: var(--r-input);
background: var(--mood-bg);
}
.proof-row--off {
opacity: 0.55;
}
.proof-row__icon {
font-size: 1.25rem;
flex-shrink: 0;
color: var(--mood-accent);
}
.proof-row--off .proof-row__icon {
color: var(--mood-text-muted);
}
.proof-row__main {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.proof-row__name {
font-weight: 700;
color: var(--mood-text);
}
.proof-row__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.donnees__sync {
margin: 0;
text-align: center;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
@media (max-width: 480px) {
.proof-row {
flex-wrap: wrap;
}
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
/**
* « / — Aujourd'hui » : capture + Fil personnel — la boîte de réception de la
* démocratie. Tout est sélecteur Pinia pur (useFeed) ; les sections vides sont
* masquées ; le compteur est nu — jamais de rouge.
*/
import type { Decision } from '~/types/domain'
import {
BOUNDARY_SUSPENDED,
CRYSTALLIZE_CARD,
DOSSIER_COMPLETE_CARD,
FEED_HEADER,
FIRST_DECISION,
ICONS,
MATURATION_CARD,
REVIEW_TITLE,
SCOPE_KEEP,
SCOPE_WIDEN,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { useFeed } from '~/composables/useFeed'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const {
objectionWindows, adviceRequests, openVotes, toCrystallize, dossiersComplete,
tiesToBreak, reviewsDue, mandateReportsDue, boundaryObjections,
overflowingScopes, prioritiesAsked, suggestions, collectiveActivity, myCount,
} = useFeed()
const dossierError = ref('')
const allEmpty = computed(
() => myCount.value === 0 && suggestions.value.length === 0
&& collectiveActivity.value.length === 0,
)
/** The proposed founding decision, still in preparation. */
const pactPending = computed(() =>
col.decisions.some(d => d.title === FIRST_DECISION && d.status === 'draft'),
)
const decisionById = (id: string): Decision | undefined =>
col.decisions.find(d => d.id === id)
const personName = (id: string): string =>
col.people.find(p => p.id === id)?.displayName ?? 'Quelqu\'un'
const dateFr = (iso: string): string =>
new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
/** Steward gesture: framing → closed once every element is terminal. */
function closeDossier(decision: Decision) {
const res = decisions.transition(decision.id, 'closed')
dossierError.value = res.ok ? '' : res.reason
}
</script>
<template>
<!-- ld-v2 -->
<div class="feed-page">
<FeedCapture />
<header class="feed-header">
<h1 class="feed-header__title">{{ FEED_HEADER }}</h1>
<span v-if="myCount > 0" class="feed-header__count">{{ myCount }}</span>
</header>
<!-- État vide global — invitation chaleureuse -->
<div v-if="allEmpty" class="ld-card feed-empty">
<UIcon name="i-lucide-sun-medium" class="feed-empty__icon" />
<p class="feed-empty__line">
Rien ne t'attend — c'est le bon moment pour capturer ta première décision.
</p>
<p class="feed-empty__hint">
Une phrase suffit, là-haut. Le chemin s'occupe du reste.
</p>
<NuxtLink
v-if="pactPending"
class="ld-btn"
:to="{ path: '/decider', query: { titre: FIRST_DECISION } }"
>
<UIcon name="i-lucide-feather" />
<span>{{ FIRST_DECISION }}</span>
</NuxtLink>
</div>
<template v-else>
<!-- Frontières contestées — priorité haute, en tête -->
<FeedSection
v-if="boundaryObjections.length > 0"
title="Frontières contestées — à traiter d'abord"
icon="i-lucide-octagon-pause"
priority
>
<FeedItemCard
v-for="o in boundaryObjections"
:key="o.id"
:title="decisionById(o.decisionId)?.title ?? 'Décision'"
icon="i-lucide-octagon-pause"
tint="var(--mood-status-fenetre)"
:to="`/decisions/${o.decisionId}`"
>
<template #meta>
<span class="status-pill status-suspended">{{ BOUNDARY_SUSPENDED }}</span>
</template>
<p class="feed-quote">{{ personName(o.personId) }} : « {{ o.argument }} »</p>
</FeedItemCard>
</FeedSection>
<!-- Fenêtres d'objection -->
<FeedSection
v-if="objectionWindows.length > 0"
title="Fenêtres d'objection"
icon="i-lucide-timer"
>
<FeedWindowCard v-for="d in objectionWindows" :key="d.id" :decision="d" />
</FeedSection>
<!-- Avis demandés -->
<FeedSection
v-if="adviceRequests.length > 0"
title="Avis demandés"
icon="i-lucide-message-circle"
>
<FeedAdviceCard v-for="d in adviceRequests" :key="d.id" :decision="d" />
</FeedSection>
<!-- Votes ouverts -->
<FeedSection v-if="openVotes.length > 0" title="Votes ouverts" icon="i-lucide-users">
<FeedVoteCard
v-for="item in openVotes"
:key="item.session.id"
:session="item.session"
:decision="item.decision"
/>
</FeedSection>
<!-- À cristalliser — le geste du garant -->
<FeedSection v-if="toCrystallize.length > 0" title="À cristalliser" :icon="ICONS.crystallize">
<FeedItemCard
v-for="item in toCrystallize"
:key="item.session.id"
:title="item.decision?.title ?? 'Réglage collectif'"
:icon="ICONS.crystallize"
tint="var(--mood-accent)"
:to="item.decision ? `/decisions/${item.decision.id}/vote` : undefined"
>
<p class="feed-line">{{ CRYSTALLIZE_CARD }}</p>
</FeedItemCard>
</FeedSection>
<!-- Dossiers complets — clore est un geste -->
<FeedSection v-if="dossiersComplete.length > 0" title="Dossiers complets" icon="i-lucide-folder-check">
<FeedItemCard
v-for="d in dossiersComplete"
:key="d.id"
:title="d.title"
icon="i-lucide-folder-check"
tint="var(--mood-status-vigueur)"
:to="`/decisions/${d.id}`"
>
<p class="feed-line">{{ DOSSIER_COMPLETE_CARD }}</p>
<p v-if="dossierError" class="feed-error">{{ dossierError }}</p>
<template #actions>
<button class="ld-btn feed-btn" type="button" @click="closeDossier(d)">
<UIcon name="i-lucide-stamp" />
<span>Clore et publier</span>
</button>
</template>
</FeedItemCard>
</FeedSection>
<!-- Égalités à départager — jamais par l'outil -->
<FeedSection v-if="tiesToBreak.length > 0" title="Égalités à départager" icon="i-lucide-scale">
<FeedItemCard
v-for="item in tiesToBreak"
:key="item.session.id"
:title="item.decision?.title ?? 'Élection'"
icon="i-lucide-scale"
tint="var(--mood-status-vote)"
:to="item.decision ? `/decisions/${item.decision.id}` : undefined"
>
<p class="feed-line">Égalité constatée — vous départagez, jamais l'outil.</p>
</FeedItemCard>
</FeedSection>
<!-- L'épreuve du réel -->
<FeedSection v-if="reviewsDue.length > 0" :title="REVIEW_TITLE" icon="i-lucide-flask-conical">
<FeedReviewCard v-for="d in reviewsDue" :key="d.id" :decision="d" />
</FeedSection>
<!-- Rapports de mandat -->
<FeedSection v-if="mandateReportsDue.length > 0" title="Rapports de mandat" icon="i-lucide-key-round">
<FeedItemCard
v-for="item in mandateReportsDue"
:key="item.mandate.id"
:title="item.mandate.title"
icon="i-lucide-key-round"
tint="var(--route-mandate)"
:to="`/mandats/${item.mandate.id}`"
>
<p class="feed-line">Un rapport est attendu depuis le {{ dateFr(item.dueAt) }}.</p>
</FeedItemCard>
</FeedSection>
<!-- Périmètres qui débordent -->
<FeedSection v-if="overflowingScopes.length > 0" title="Périmètres qui débordent" icon="i-lucide-expand">
<FeedItemCard
v-for="d in overflowingScopes"
:key="d.id"
:title="d.title"
icon="i-lucide-expand"
tint="var(--mood-status-fenetre)"
>
<p class="feed-line">Plus de monde se déclare concerné que prévu — décide du périmètre.</p>
<template #actions>
<NuxtLink class="ld-btn feed-btn" :to="`/decisions/${d.id}?geste=elargir`">
{{ SCOPE_WIDEN }}
</NuxtLink>
<NuxtLink class="ld-btn ld-btn--ghost feed-btn" :to="`/decisions/${d.id}?geste=maintenir`">
{{ SCOPE_KEEP }}
</NuxtLink>
</template>
</FeedItemCard>
</FeedSection>
<!-- Pondère tes enjeux -->
<FeedSection v-if="prioritiesAsked.length > 0" title="Pondère tes enjeux" icon="i-lucide-sliders-horizontal">
<FeedItemCard
v-for="c in prioritiesAsked"
:key="c.id"
:title="decisionById(c.decisionId)?.title ?? 'Élément de dossier'"
icon="i-lucide-sliders-horizontal"
tint="var(--mood-tertiary)"
:to="`/decisions/${c.decisionId}`"
>
<p class="feed-line">Dis au dossier combien cet élément compte pour toi (0–3).</p>
</FeedItemCard>
</FeedSection>
<!-- Boucle apprenante — cartes discrètes -->
<FeedSection v-if="suggestions.length > 0" title="Boucle apprenante" icon="i-lucide-repeat" discrete>
<template v-for="(s, i) in suggestions" :key="i">
<FeedItemCard
v-if="s.kind === 'claim-mandate'"
:title="`${s.count}ᵉ fois sur « ${s.tags[0]} » — réclame un mandat`"
:icon="ICONS.recurrence"
to="/mandats/nouveau"
/>
<FeedItemCard
v-else-if="s.kind === 'protocolize'"
:title="MATURATION_CARD"
icon="i-lucide-sprout"
to="/textes"
>
<p class="feed-line">{{ s.count }} consignations proches sur « {{ s.tags[0] }} ».</p>
</FeedItemCard>
<FeedItemCard
v-else
:title="`Le protocole « ${s.protocol.name} » n'a jamais servi — élague ?`"
icon="i-lucide-scissors"
to="/textes/formules"
/>
</template>
</FeedSection>
<!-- Activité du collectif -->
<FeedSection v-if="collectiveActivity.length > 0" title="Activité du collectif" icon="i-lucide-activity">
<FeedActivityCard v-for="d in collectiveActivity" :key="d.id" :decision="d" />
</FeedSection>
</template>
</div>
</template>
<style scoped>
.feed-page {
width: 100%;
max-width: 44rem;
margin: 0 auto;
}
.feed-header {
display: flex;
align-items: baseline;
gap: 0.625rem;
margin: 0.75rem 0 1.25rem;
}
.feed-header__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.feed-header__count {
font-size: 1.0625rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.feed-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: clamp(2rem, 6vw, 3.5rem) 1.5rem;
text-align: center;
}
.feed-empty__icon { font-size: 2rem; color: var(--mood-accent); }
.feed-empty__line {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
max-width: 26rem;
}
.feed-empty__hint {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.feed-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.feed-quote { margin: 0; font-size: 0.875rem; font-style: italic; }
.feed-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
.feed-btn { font-size: 0.875rem; padding: 0.4rem 1rem; text-decoration: none; }
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/[id] — le pouvoir confié, visible et reprenable.
// Origine cliquable (le mandat EST né d'une décision), faits comptés (Δ7),
// feux de la rampe, révocation en un geste au protocole d'origine.
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_REVOKE, NOMINATION_LABELS } from '~/lexicon'
import {
MANDATE_STATUS_LABELS,
MANDATE_STATUS_PILL,
} from '~/components/mandates/mandateUi'
const route = useRoute()
const col = useCollectiveStore()
const mandate = computed(() =>
col.mandates.find(m => m.id === String(route.params.id)),
)
const holder = computed(() =>
col.people.find(p => p.id === mandate.value?.holderId),
)
const originDecision = computed(() =>
col.decisions.find(d => d.id === mandate.value?.originDecisionId),
)
const domainCircles = computed(() =>
(mandate.value?.domain.circleIds ?? [])
.map(id => col.circles.find(c => c.id === id))
.filter(c => c !== undefined),
)
const electorCircle = computed(() =>
col.circles.find(c => c.id === mandate.value?.electorCircleId),
)
const revokeTo = computed(() =>
mandate.value ? `/decider?mandat=${mandate.value.id}&chain=revocation` : '/decider',
)
</script>
<template>
<!-- ld-v2 -->
<div v-if="mandate" class="mfiche">
<NuxtLink to="/mandats" class="mfiche__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<!-- ── En-tête : le pouvoir, explicite ── -->
<header class="mfiche__head ld-card">
<div class="mfiche__title-row">
<h1 class="mfiche__title">{{ mandate.title }}</h1>
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
{{ MANDATE_STATUS_LABELS[mandate.status] }}
</span>
</div>
<div v-if="holder" class="mfiche__holder">
<LdAvatarStack :people="[{ person: holder }]" :size="38" />
<div>
<p class="mfiche__holder-name">{{ holder.displayName }}</p>
<p class="mfiche__holder-role">titulaire</p>
</div>
</div>
<dl class="mfiche__meta">
<div class="mfiche__meta-row">
<dt>Origine</dt>
<dd>
<NuxtLink
v-if="originDecision"
:to="`/decisions/${originDecision.id}`"
class="mfiche__origin"
>
<UIcon name="i-lucide-scale" />
{{ originDecision.title }}
</NuxtLink>
<span v-else class="mfiche__muted">décision fondatrice hors de ce fichier</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Domaine</dt>
<dd class="mfiche__chips">
<span v-for="c in domainCircles" :key="c.id" class="mfiche__chip">
<UIcon name="i-lucide-circle-dashed" />
{{ c.name }}
</span>
<span v-for="tag in mandate.domain.tags" :key="tag" class="mfiche__tag">
#{{ tag }}
</span>
<span v-if="!domainCircles.length && !mandate.domain.tags.length" class="mfiche__muted">
domaine non précisé
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Cercle électeur</dt>
<dd>
<span class="mfiche__chip">
<UIcon name="i-lucide-users-round" />
{{ electorCircle?.name ?? '—' }}
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Nomination</dt>
<dd>{{ NOMINATION_LABELS[mandate.nominationMethod] }}</dd>
</div>
<div class="mfiche__meta-row mfiche__meta-row--period">
<dt>Période</dt>
<dd>
<MandatePeriodBar :starts-at="mandate.startsAt" :ends-at="mandate.endsAt" />
</dd>
</div>
</dl>
</header>
<!-- ── Exercice du mandat — faits comptés ── -->
<MandateExercise :mandate="mandate" />
<!-- ── Feux de la rampe ── -->
<MandateSpotlights :mandate="mandate" />
<!-- ── Révocation en un geste — jamais improvisée ── -->
<section v-if="mandate.status === 'active'" class="mfiche__revoke ld-card">
<NuxtLink :to="revokeTo" class="ld-btn ld-btn--ghost mfiche__revoke-btn">
<UIcon name="i-lucide-key-square" />
{{ MANDATE_REVOKE }}
</NuxtLink>
<p class="mfiche__revoke-note">
La révocation suit le protocole de nomination d'origine
({{ NOMINATION_LABELS[mandate.nominationMethod] }}), avec le cercle électeur
« {{ electorCircle?.name ?? '—' }} » comme périmètre — jamais plus coûteuse
que la nomination.
</p>
</section>
</div>
<div v-else class="mfiche mfiche--missing">
<p class="ld-card mfiche__missing-card">
Ce mandat est introuvable dans ce collectif.
<NuxtLink to="/mandats" class="mfiche__origin">Revenir aux mandats</NuxtLink>
</p>
</div>
</template>
<style scoped>
.mfiche {
max-width: 46rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.mfiche__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
align-self: flex-start;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
}
.mfiche__back:hover { color: var(--mood-text); }
.mfiche__head {
padding: 1.375rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mfiche__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mfiche__title {
margin: 0;
font-size: clamp(1.25rem, 3.5vw, 1.625rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.01em;
}
.mfiche__holder { display: flex; align-items: center; gap: 0.75rem; }
.mfiche__holder-name { margin: 0; font-size: 1rem; font-weight: 700; }
.mfiche__holder-role { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__meta { margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.mfiche__meta-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.75rem;
align-items: baseline;
}
.mfiche__meta-row dt {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.mfiche__meta-row dd { margin: 0; font-size: 0.9375rem; min-width: 0; }
.mfiche__meta-row--period dd { padding-top: 0.25rem; }
@media (max-width: 479px) {
.mfiche__meta-row { grid-template-columns: 1fr; gap: 0.125rem; }
}
.mfiche__origin {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.mfiche__origin:hover { text-decoration: underline; }
.mfiche__chips { display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: center; }
.mfiche__chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
}
.mfiche__tag { font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__muted { color: var(--mood-text-muted); font-style: italic; font-size: 0.875rem; }
.mfiche__revoke {
padding: 1.125rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.mfiche__revoke-btn { align-self: flex-start; color: var(--mood-status-revoque); }
.mfiche__revoke-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
}
.mfiche--missing { padding-top: 2rem; }
.mfiche__missing-card {
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats — la carte des pouvoirs confiés : qui peut décider
// quoi, jusqu'à quand. Cartes par statut, compteurs bruts (Δ7 : aucune jauge).
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATES_TITLE, MANDATE_CLAIM } from '~/lexicon'
import { MANDATE_STATUS_LABELS } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const ORDER: Mandate['status'][] = ['active', 'proposed', 'expired', 'revoked']
const groups = computed(() =>
ORDER
.map(status => ({
status,
label: MANDATE_STATUS_LABELS[status],
items: col.mandates
.filter(m => m.status === status)
.sort((a, b) => (a.endsAt < b.endsAt ? -1 : 1)),
}))
.filter(g => g.items.length > 0),
)
</script>
<template>
<!-- ld-v2 -->
<div class="mpage">
<header class="mpage__header">
<div>
<h1 class="mpage__title">{{ MANDATES_TITLE }}</h1>
<p class="mpage__sub">qui peut décider quoi, jusqu'à quand</p>
</div>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</header>
<template v-if="groups.length">
<section v-for="group in groups" :key="group.status" class="mpage__group">
<h2 class="mpage__group-title">
{{ group.label }}
<span class="mpage__count">{{ group.items.length }}</span>
</h2>
<div class="mpage__grid">
<MandateCard v-for="m in group.items" :key="m.id" :mandate="m" />
</div>
</section>
</template>
<div v-else class="mpage__empty ld-card">
<UIcon name="i-lucide-key-round" class="mpage__empty-icon" />
<p class="mpage__empty-title">Aucun pouvoir confié pour l'instant.</p>
<p class="mpage__empty-text">
Un mandat naît d'une décision : un rôle, un domaine, une durée bornée —
et des comptes à rendre.
</p>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.mpage {
max-width: 64rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.mpage__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.mpage__title {
margin: 0;
font-size: clamp(1.375rem, 3.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.01em;
}
.mpage__sub {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.mpage__group { display: flex; flex-direction: column; gap: 0.875rem; }
.mpage__group-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.mpage__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.mpage__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.mpage__grid { grid-template-columns: 1fr 1fr; }
}
.mpage__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 3rem 1.5rem;
text-align: center;
}
.mpage__empty-icon { font-size: 2rem; color: var(--mood-accent); }
.mpage__empty-title { margin: 0; font-size: 1.0625rem; font-weight: 700; }
.mpage__empty-text {
margin: 0 0 0.625rem;
max-width: 26rem;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.55;
}
</style>
@@ -0,0 +1,389 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/nouveau — wizard 3 étapes porté du v1 (la structure
// à pépite : pills de progression, cartes cliquables, transitions), branché sur
// le pivot : la nomination EST une décision (createsMandate, route collective).
// Pré-remplissage par query ?titre=&tags= (suggestion R6 du chemin).
import type { Id, MandateDraft, NominationMethod, TriageInput } from '~/types/domain'
import type { ApplyPathEdits } from '~/stores/decisions'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { MANDATE_CLAIM, NOMINATION_LABELS } from '~/lexicon'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
const STEPS = ['Rôle et domaine', 'Nomination', 'Durée et comptes'] as const
const step = ref(0)
// ── Étape 1 : rôle et domaine ──
const title = ref(typeof route.query.titre === 'string' ? route.query.titre : '')
const mission = ref('')
const circleIds = ref<Id[]>([])
const tags = ref<string[]>(
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: [],
)
const tagInput = ref('')
function toggleCircle(id: Id): void {
circleIds.value = circleIds.value.includes(id)
? circleIds.value.filter(c => c !== id)
: [...circleIds.value, id]
}
function addTag(): void {
const tag = tagInput.value.trim().replace(/^#/, '').toLowerCase()
if (tag && !tags.value.includes(tag)) tags.value.push(tag)
tagInput.value = ''
}
function removeTag(tag: string): void {
tags.value = tags.value.filter(t => t !== tag)
}
// ── Étape 2 : modalité de nomination (6) ──
const method = ref<NominationMethod | null>(null)
// ── Étape 3 : durée bornée, cadence, cercle électeur ──
const durationDays = ref(180)
const withReports = ref(true)
const reportEveryDays = ref(90)
const electorCircleId = ref<Id>(col.current?.collective.rootCircleId ?? '')
const canNext = computed(() => {
if (step.value === 0) return title.value.trim().length > 0
if (step.value === 1) return method.value !== null
return durationDays.value >= 1 && electorCircleId.value !== ''
})
const electorCircle = computed(() => col.circles.find(c => c.id === electorCircleId.value))
const domainNames = computed(() =>
circleIds.value
.map(id => col.circles.find(c => c.id === id)?.name)
.filter(n => n !== undefined),
)
/** La modalité choisie résout le protocole via le Pacte (repli : consentement). */
function protocolFor(m: NominationMethod): Id | undefined {
const byRange = col.settings?.protocolByRange
if (!byRange) return undefined
if (m === 'election-no-candidate') return byRange.election ?? byRange.consent
if (m === 'nuanced-vote') return byRange.nuanced ?? byRange.consent
return byRange.consent // ratification par consentement (auto-désignation, tirage, rotation)
}
// ── Sortie : une Decision createsMandate, route collective ──
const submitting = ref(false)
const error = ref<string | null>(null)
function submit(): void {
if (!method.value || submitting.value) return
submitting.value = true
error.value = null
const captured = decisions.capture(`Confier le mandat « ${title.value.trim()} »`)
if ('ok' in captured) { error.value = captured.reason; submitting.value = false; return }
const scope = {
selfOnly: false,
// Le cercle électeur EN PREMIER : il devient le périmètre d'élection et de révocation.
circleIds: [electorCircleId.value, ...circleIds.value.filter(id => id !== electorCircleId.value)],
personIds: [] as Id[],
}
const input: TriageInput = {
title: captured.title,
tags: tags.value,
scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}
const path = decisions.runTriage(input)
if ('ok' in path) { error.value = path.reason; submitting.value = false; return }
const draft: MandateDraft = {
title: title.value.trim(),
domainCircleIds: [...circleIds.value],
domainTags: [...tags.value],
durationDays: durationDays.value,
...(withReports.value ? { reportEveryDays: reportEveryDays.value } : {}),
}
const protocolId = protocolFor(method.value)
const edits: ApplyPathEdits = {
route: 'collective',
scope,
reversibility: 'costly',
weight: 'binding',
tags: [...tags.value],
visibility: 'collective',
createsMandate: draft,
...(mission.value.trim() ? { body: mission.value.trim() } : {}),
...(protocolId !== undefined ? { protocolId } : {}),
}
const applied = decisions.applyPath(captured, path, edits)
if ('ok' in applied) { error.value = applied.reason; submitting.value = false; return }
navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="mwiz">
<nav class="mwiz__nav">
<button v-if="step > 0" type="button" class="mwiz__back" @click="step--">
<UIcon name="i-lucide-arrow-left" />
Retour
</button>
<NuxtLink v-else to="/mandats" class="mwiz__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<div class="mwiz__progress">
<template v-for="(label, i) in STEPS" :key="label">
<span v-if="i > 0" class="mwiz__sep">›</span>
<span
class="mwiz__pill"
:class="{ 'mwiz__pill--active': step === i, 'mwiz__pill--done': step > i }"
>
{{ i + 1 }} · {{ label }}
</span>
</template>
</div>
</nav>
<Transition name="slide-fade" mode="out-in">
<!-- ── ÉTAPE 1 : rôle et domaine ── -->
<div v-if="step === 0" key="role" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">{{ MANDATE_CLAIM }}</h1>
<p class="mwiz__sub">Un rôle nommé, un domaine explicite — le pouvoir se voit.</p>
</header>
<label class="mwiz__label" for="mwiz-title">Titre du rôle</label>
<input
id="mwiz-title"
v-model="title"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Trésorerie, Animation du jardin…"
>
<label class="mwiz__label" for="mwiz-mission">La mission, en une phrase</label>
<input
id="mwiz-mission"
v-model="mission"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Tenir les comptes et rendre la caisse lisible par tous."
>
<p class="mwiz__label">Cercles du domaine</p>
<div class="mwiz__chips">
<button
v-for="c in col.circles"
:key="c.id"
type="button"
class="mwiz__chip"
:class="{ 'mwiz__chip--on': circleIds.includes(c.id) }"
@click="toggleCircle(c.id)"
>
{{ c.name }}
</button>
</div>
<label class="mwiz__label" for="mwiz-tag">Tags du domaine</label>
<div class="mwiz__chips">
<span v-for="tag in tags" :key="tag" class="mwiz__chip mwiz__chip--on">
#{{ tag }}
<button type="button" class="mwiz__chip-x" :aria-label="`Retirer ${tag}`" @click="removeTag(tag)">
<UIcon name="i-lucide-x" />
</button>
</span>
<input
id="mwiz-tag"
v-model="tagInput"
type="text"
class="mwiz__input mwiz__input--tag"
placeholder="ajouter un tag ⏎"
@keydown.enter.prevent="addTag"
@blur="addTag"
>
</div>
</div>
<!-- ── ÉTAPE 2 : modalité de nomination ── -->
<div v-else-if="step === 1" key="nomination" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Comment nommer ?</h1>
<p class="mwiz__sub">Six modalités, chacune avec sa pédagogie — le cercle électeur tranchera.</p>
</header>
<MandateNominationPicker v-model="method" />
</div>
<!-- ── ÉTAPE 3 : durée bornée, cadence, cercle électeur ── -->
<div v-else key="terms" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Borné, redevable</h1>
<p class="mwiz__sub">Un mandat a toujours une fin — et des comptes à rendre avant.</p>
</header>
<label class="mwiz__label" for="mwiz-duration">Durée du mandat (jours)</label>
<div class="mwiz__row">
<input
id="mwiz-duration"
v-model.number="durationDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
max="730"
>
<input
v-model.number="durationDays"
type="range"
class="mwiz__slider"
min="7"
max="730"
step="1"
aria-label="Durée du mandat en jours"
>
</div>
<label class="mwiz__check">
<input v-model="withReports" type="checkbox" class="mwiz__checkbox">
<span>Avec rapports réguliers</span>
</label>
<div v-if="withReports" class="mwiz__row">
<label class="mwiz__label mwiz__label--inline" for="mwiz-cadence">tous les</label>
<input
id="mwiz-cadence"
v-model.number="reportEveryDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
:max="durationDays"
>
<span class="mwiz__unit">jours</span>
</div>
<label class="mwiz__label" for="mwiz-elector">Cercle électeur — il nomme, il révoque</label>
<select id="mwiz-elector" v-model="electorCircleId" class="mwiz__input">
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div class="mwiz__recap">
<p class="mwiz__recap-title">{{ title }}</p>
<p v-if="mission" class="mwiz__recap-line">{{ mission }}</p>
<p class="mwiz__recap-line">
{{ method ? NOMINATION_LABELS[method] : '—' }} ·
{{ durationDays }} jours<template v-if="withReports"> · rapport tous les {{ reportEveryDays }} jours</template>
</p>
<p v-if="domainNames.length || tags.length" class="mwiz__recap-line">
Domaine : {{ [...domainNames, ...tags.map(t => `#${t}`)].join(' · ') }}
</p>
<p class="mwiz__recap-note">
La nomination est une décision — elle part au vote du cercle
« {{ electorCircle?.name ?? '—' }} ».
</p>
</div>
<p v-if="error" class="mwiz__error">{{ error }}</p>
</div>
</Transition>
<div class="mwiz__actions">
<button
v-if="step < 2"
type="button"
class="ld-btn"
:disabled="!canNext"
@click="step++"
>
Continuer
<UIcon name="i-lucide-arrow-right" />
</button>
<button
v-else
type="button"
class="ld-btn"
:disabled="!canNext || !method || submitting"
@click="submit"
>
<UIcon name="i-lucide-scale" />
{{ submitting ? 'Ouverture…' : 'Soumettre au cercle électeur' }}
</button>
</div>
</div>
</template>
<style scoped>
.mwiz { max-width: 46rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
.mwiz__nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.mwiz__back {
display: inline-flex; align-items: center; gap: 0.375rem;
font-size: 0.875rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-decoration: none;
}
.mwiz__back:hover { color: var(--mood-text); }
.mwiz__progress { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; flex-wrap: wrap; }
.mwiz__sep { color: var(--mood-text-muted); font-size: 0.75rem; }
.mwiz__pill {
font-size: 0.75rem; font-weight: 600; padding: 0.25rem 0.625rem;
border-radius: var(--r-pill); color: var(--mood-text-muted);
background: var(--mood-surface); white-space: nowrap;
}
.mwiz__pill--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__pill--done { background: var(--mood-accent-soft); color: var(--mood-accent); }
.mwiz__step { display: flex; flex-direction: column; gap: 0.75rem; }
.mwiz__header { margin-bottom: 0.5rem; }
.mwiz__title { margin: 0 0 0.25rem; font-size: clamp(1.25rem, 3vw, 1.625rem); font-weight: 800; }
.mwiz__sub { margin: 0; font-size: 0.9375rem; color: var(--mood-text-muted); }
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
.mwiz__label--inline { margin-top: 0; }
.mwiz__input {
width: 100%; box-sizing: border-box; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
color: var(--mood-text); background: var(--mood-input-bg); border: none;
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.mwiz__input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.mwiz__input--num { width: 6rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.mwiz__input--tag { width: 11rem; padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.mwiz__row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.mwiz__slider { flex: 1; min-width: 10rem; accent-color: var(--mood-accent); }
.mwiz__unit { font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.mwiz__chip {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.875rem; font-size: 0.875rem; font-weight: 600;
color: var(--mood-text-muted); background: var(--mood-surface);
border-radius: var(--r-pill); box-shadow: var(--shadow-card);
cursor: pointer; transition: transform 0.1s ease;
}
.mwiz__chip:hover { transform: translateY(-1px); }
.mwiz__chip--on { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__chip-x { display: inline-flex; background: none; color: inherit; cursor: pointer; padding: 0; }
.mwiz__check {
display: flex; align-items: center; gap: 0.625rem; margin-top: 0.375rem;
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
}
.mwiz__checkbox { width: 1.125rem; height: 1.125rem; accent-color: var(--mood-accent); }
.mwiz__recap {
margin-top: 0.75rem; padding: 1.125rem 1.25rem; border-radius: var(--r-card);
background: var(--mood-surface); box-shadow: var(--shadow-card);
display: flex; flex-direction: column; gap: 0.375rem;
}
.mwiz__recap-title { margin: 0; font-size: 1.0625rem; font-weight: 800; }
.mwiz__recap-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__recap-note { margin: 0.375rem 0 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-accent); }
.mwiz__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.mwiz__actions { display: flex; justify-content: flex-end; }
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
.slide-fade-leave-to { opacity: 0; transform: translateX(-20px); }
</style>
@@ -0,0 +1,377 @@
<script setup lang="ts">
/**
* /textes/[slug] — le document vivant : sections repliables + scroll-spy
* (sommaire latéral desktop, dropdown mobile), clauses (pastille d'inertie
* câblée, mini-jauge de session réelle, statut), détail dépliable, vue
* projetée, provenance. Spécifique Pacte : valeurs en clair + effet immédiat,
* clause d'inertie protégée, A1 en tête, préambule boussoles.
*/
import type { Clause } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
buildClauseView,
formatInt,
liveClauseGauges,
sectionMeta,
type ClauseSessionGauge,
type ClauseView,
type ClauseViewCtx,
} from '~/components/texts/textsModel'
const route = useRoute()
const store = useCollectiveStore()
const doc = computed(() =>
store.docs.find(d => d.slug === String(route.params.slug)) ?? null)
const isPact = computed(() => doc.value?.role === 'pact')
// ── Clauses & sections (A1 en tête : l'ordre des positions fait foi) ──
const docClauses = computed(() =>
store.clauses
.filter(c => c.docId === doc.value?.id)
.sort((a, b) => a.position - b.position))
interface SectionGroup { tag: string; label: string; icon: string; clauses: Clause[] }
const sections = computed((): SectionGroup[] => {
const groups = new Map<string, Clause[]>()
for (const clause of docClauses.value) {
const list = groups.get(clause.section) ?? []
list.push(clause)
groups.set(clause.section, list)
}
return [...groups.entries()].map(([tag, clauses]) => ({ tag, ...sectionMeta(tag), clauses }))
})
// ── Assemblage des vues de clauses (pur, dans textsModel) ──
const gauges = computed(() => {
const map = new Map<string, ClauseSessionGauge>()
for (const gauge of liveClauseGauges(docClauses.value, store.decisions, store.sessions, store.votes)) {
map.set(gauge.clauseId, gauge)
}
return map
})
const viewCtx = computed((): ClauseViewCtx => ({
versions: store.versions,
decisions: store.decisions,
people: store.people,
protocols: store.protocols,
settings: store.settings,
gauges: gauges.value,
isPact: isPact.value,
memberCount: store.people.length,
}))
const viewByClause = computed(() => new Map<string, ClauseView>(
docClauses.value.map(c => [c.id, buildClauseView(c, viewCtx.value)]),
))
function viewOf(clause: Clause): ClauseView {
return viewByClause.value.get(clause.id) ?? buildClauseView(clause, viewCtx.value)
}
// ── Dépliage clause + repli sections ──
const expandedClauseId = ref<string | null>(null)
function toggleClause(id: string) {
expandedClauseId.value = expandedClauseId.value === id ? null : id
}
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (list) => {
if (list.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
list.forEach((s, i) => { map[s.tag] = i >= 2 })
collapsedSections.value = map
}
}, { immediate: true })
// ── Vue projetée : le document si les votes en cours passaient ──
const projected = ref(false)
function projectedContent(clause: Clause): { content: string; changed: boolean } {
const view = viewOf(clause)
const proposed = view.proposed[0]
return proposed
? { content: proposed.version.content, changed: true }
: { content: view.current?.content ?? '', changed: false }
}
const projectedChanges = computed(() =>
docClauses.value.filter(c => viewOf(c).proposed.length > 0).length)
// ── Sommaire + scroll-spy ──
const activeSection = ref<string | null>(null)
let observer: IntersectionObserver | null = null
function observeSections() {
observer?.disconnect()
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) activeSection.value = entry.target.id.replace('sec-', '')
}
}, { rootMargin: '-15% 0px -70% 0px' })
for (const section of sections.value) {
const el = document.getElementById(`sec-${section.tag}`)
if (el) observer.observe(el)
}
}
onMounted(() => nextTick(observeSections))
watch([sections, projected], () => nextTick(observeSections))
onUnmounted(() => observer?.disconnect())
function scrollToSection(tag: string) {
if (collapsedSections.value[tag]) collapsedSections.value[tag] = false
nextTick(() => {
document.getElementById(`sec-${tag}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
})
}
</script>
<template>
<!-- ld-v2 -->
<div class="doc-page">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet doc-page__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<p v-if="!doc" class="doc-page__missing">
Ce texte est introuvable — il a peut-être été remplacé ou retiré.
</p>
<template v-else>
<!-- En-tête -->
<header class="doc-page__header">
<div class="doc-page__badges">
<span v-if="isPact" class="doc-page__pact-badge">{{ PACT_BADGE }}</span>
<span v-else class="doc-page__ref-badge">document de référence</span>
</div>
<h1 class="doc-page__title">{{ doc.title }}</h1>
<p class="doc-page__desc">{{ isPact ? PACT_SUBTITLE : doc.description }}</p>
<div v-if="isPact" class="doc-page__compass">
<UIcon name="i-lucide-compass" />
<span>Autonomie</span><span class="doc-page__compass-dot">·</span>
<span>Équilibre</span><span class="doc-page__compass-dot">·</span>
<span>Liaison</span>
</div>
<div class="doc-page__meta">
<span class="doc-page__meta-chip">{{ formatInt(docClauses.length) }} clauses</span>
<span v-if="gauges.size" class="doc-page__meta-chip doc-page__meta-chip--vote">
{{ gauges.size }} session{{ gauges.size > 1 ? 's' : '' }} en cours
</span>
<button
type="button"
class="doc-page__projected"
:class="{ 'doc-page__projected--on': projected }"
:disabled="projectedChanges === 0"
:title="projectedChanges === 0 ? 'Aucune version proposée sur ce texte' : ''"
@click="projected = !projected"
>
<UIcon name="i-lucide-telescope" />
{{ projected ? 'Revenir au texte en vigueur' : 'Le document si les votes en cours passaient' }}
</button>
</div>
</header>
<div class="doc-page__body">
<!-- Sommaire : latéral en desktop, dropdown en mobile -->
<nav class="doc-page__toc" aria-label="Sommaire">
<select
class="doc-page__toc-select"
:value="activeSection ?? ''"
@change="scrollToSection(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Aller à une section…</option>
<option v-for="section in sections" :key="section.tag" :value="section.tag">
{{ section.label }} ({{ section.clauses.length }})
</option>
</select>
<ul class="doc-page__toc-list">
<li v-for="section in sections" :key="section.tag">
<button
type="button"
class="doc-page__toc-item"
:class="{ 'doc-page__toc-item--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" />
<span class="doc-page__toc-label">{{ section.label }}</span>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
</button>
</li>
</ul>
</nav>
<!-- Les sections -->
<div class="doc-page__sections">
<section
v-for="section in sections"
:id="`sec-${section.tag}`"
:key="section.tag"
class="doc-page__section"
>
<button
type="button"
class="doc-page__section-head"
@click="collapsedSections[section.tag] = !collapsedSections[section.tag]"
>
<UIcon :name="section.icon" class="doc-page__section-icon" />
<h2 class="doc-page__section-title">{{ section.label }}</h2>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
<UIcon
name="i-lucide-chevron-down"
class="doc-page__chevron"
:class="{ 'doc-page__chevron--open': !collapsedSections[section.tag] }"
/>
</button>
<div v-show="!collapsedSections[section.tag]" class="doc-page__clauses">
<!-- Vue projetée : lecture, votes appliqués -->
<template v-if="projected">
<article
v-for="clause in section.clauses"
:key="clause.id"
class="doc-page__projected-clause"
:class="{ 'doc-page__projected-clause--changed': projectedContent(clause).changed }"
>
<p class="doc-page__clause-head">
<span class="doc-page__clause-code">{{ clause.code }}</span>
<span class="doc-page__clause-name">{{ clause.title }}</span>
<span v-if="projectedContent(clause).changed" class="doc-page__changed-chip">
si le vote passe
</span>
</p>
<MarkdownRenderer
:content="projectedContent(clause).content"
class="doc-page__projected-text"
/>
</article>
</template>
<!-- Vue structurée : la boucle vote ⇒ texte -->
<template v-else>
<ClauseCard
v-for="clause in section.clauses"
:key="clause.id"
:view="viewOf(clause)"
:expanded="expandedClauseId === clause.id"
@toggle="toggleClause(clause.id)"
/>
</template>
</div>
</section>
<TextProvenance v-if="doc.provenance" :provenance="doc.provenance" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.1rem;
max-width: 64rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.doc-page__missing { font-size: 0.9375rem; color: var(--mood-text-muted); font-style: italic; }
.doc-page__header { display: flex; flex-direction: column; gap: 0.45rem; }
.doc-page__badges { display: flex; gap: 0.4rem; }
.doc-page__pact-badge,
.doc-page__ref-badge {
font-size: 0.6875rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 3px 10px; border-radius: var(--r-pill);
}
.doc-page__pact-badge { background: var(--mood-accent); color: var(--mood-accent-text); }
.doc-page__ref-badge {
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-page__title {
font-size: clamp(1.35rem, 4.5vw, 1.8rem); font-weight: 800;
letter-spacing: -0.02em; color: var(--mood-text); line-height: 1.2;
}
.doc-page__desc { font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.55; }
.doc-page__compass {
display: inline-flex; align-items: center; gap: 0.45rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 10%, transparent);
padding: 0.3rem 0.9rem; border-radius: var(--r-pill);
}
.doc-page__compass-dot { opacity: 0.5; }
.doc-page__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.2rem; }
.doc-page__meta-chip {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-page__meta-chip--vote { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.doc-page__projected {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.75rem; font-weight: 700; padding: 4px 12px; border-radius: var(--r-pill);
background: var(--mood-surface); color: var(--mood-text-muted);
box-shadow: var(--shadow-card); cursor: pointer;
}
.doc-page__projected:disabled { opacity: 0.45; cursor: not-allowed; }
.doc-page__projected--on {
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
box-shadow: 0 0 0 2px var(--mood-status-fige);
}
.doc-page__body { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }
@media (min-width: 1024px) { .doc-page__body { grid-template-columns: 15rem 1fr; } }
.doc-page__toc { display: flex; flex-direction: column; gap: 0.5rem; }
.doc-page__toc-select {
padding: 0.55rem 0.8rem;
font-size: 0.875rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.doc-page__toc-list { display: none; }
@media (min-width: 1024px) {
.doc-page__toc-select { display: none; }
.doc-page__toc { position: sticky; top: 4.5rem; }
.doc-page__toc-list { display: flex; flex-direction: column; gap: 2px; }
}
.doc-page__toc-item {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.45rem 0.7rem; border-radius: var(--r-input);
font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-align: left;
}
.doc-page__toc-item:hover { background: var(--mood-accent-soft); color: var(--mood-text); }
.doc-page__toc-item--active { background: var(--mood-accent-soft); color: var(--mood-accent); font-weight: 700; }
.doc-page__toc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-page__toc-count { font-size: 0.6875rem; font-weight: 800; opacity: 0.6; }
.doc-page__sections { display: flex; flex-direction: column; gap: 1.4rem; min-width: 0; }
.doc-page__section { scroll-margin-top: 4.5rem; }
.doc-page__section-head {
display: flex; align-items: center; gap: 0.55rem; width: 100%;
padding: 0.5rem 0; background: none; cursor: pointer;
box-shadow: 0 2px 0 color-mix(in srgb, var(--mood-accent) 14%, transparent);
}
.doc-page__section-icon { color: var(--mood-accent); }
.doc-page__section-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.doc-page__chevron { margin-left: auto; color: var(--mood-text-muted); transform: rotate(-90deg); transition: transform 0.2s ease; }
.doc-page__chevron--open { transform: rotate(0); }
.doc-page__clauses { display: flex; flex-direction: column; gap: 0.6rem; padding-top: 0.7rem; }
.doc-page__projected-clause { padding: 0.6rem 0.9rem; border-radius: var(--r-input); }
.doc-page__projected-clause--changed { background: color-mix(in srgb, var(--mood-status-fige) 8%, transparent); }
.doc-page__clause-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.doc-page__clause-code {
font-family: monospace; font-size: 0.75rem; font-weight: 800;
color: var(--mood-accent); background: var(--mood-accent-soft);
padding: 2px 8px; border-radius: 7px;
}
.doc-page__clause-name { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.doc-page__changed-chip {
font-size: 0.6875rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
}
.doc-page__projected-text { font-size: 0.875rem; }
</style>
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* /textes/formules — l'Atelier des formules : la pédagogie du seuil,
* seule maison des lettres W/T/M/B/G/C. Query params ?W=&T=&M=&B=&G=&C=(&S=)
* pré-règlent l'établi — les liens « comprendre ce seuil » arrivent ici.
*/
import { BINARY_DESCRIPTION, BINARY_COST, METHOD_LABELS } from '~/lexicon'
import type { AtelierInit } from '~/components/texts/textsModel'
const route = useRoute()
function numParam(name: string): number | undefined {
const raw = route.query[name]
const value = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(value) ? value : undefined
}
const initial = computed((): AtelierInit => {
const init: AtelierInit = {}
const W = numParam('W')
const T = numParam('T')
const M = numParam('M')
const B = numParam('B')
const G = numParam('G')
const C = numParam('C')
const S = numParam('S')
if (W !== undefined) init.W = W
if (T !== undefined) init.T = T
if (M !== undefined) init.M = M
if (B !== undefined) init.B = B
if (G !== undefined) init.G = G
if (C !== undefined) init.C = C
if (S !== undefined) init.S = S
return init
})
</script>
<template>
<!-- ld-v2 -->
<div class="atelier">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet atelier__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<header class="atelier__header">
<h1 class="atelier__title">L'Atelier des formules</h1>
<p class="atelier__subtitle">
La pédagogie du seuil — c'est ici, et seulement ici, que vivent les lettres.
Partout ailleurs, l'outil parle français.
</p>
</header>
<!-- L'établi -->
<AtelierFormulaLab :key="JSON.stringify(initial)" :initial="initial" />
<!-- Le pour/contre, présenté pour ce qu'il est -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--binary">
<UIcon name="i-lucide-scale" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.binary }}</h2>
<p class="atelier__card-sub">{{ BINARY_DESCRIPTION }}</p>
</div>
</div>
<p class="atelier__prose">
Cette formule inertielle est un héritage de la Toile de Confiance Ğ1 : quand
des milliers de membres sont éligibles et que peu s'expriment, elle exige
presque l'unanimité ; quand la participation monte, le seuil descend vers la
majorité cible. C'est une protection remarquable pour les très grands corps —
et un outil d'exception : son coût reste « {{ BINARY_COST }} ».
Pour la vie courante d'un collectif, le consentement, le vote nuancé et le
réglage collectif font mieux vivre le désaccord.
</p>
</section>
<!-- Le Réglage collectif : la médiane basse expliquée -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--parametric">
<UIcon name="i-lucide-sliders-horizontal" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.parametric }}</h2>
<p class="atelier__card-sub">décider au curseur — la médiane basse, expliquée en jouant</p>
</div>
</div>
<p class="atelier__prose">
Quand la question est un nombre — un montant, un taux, une répartition —
voter pour ou contre appauvrit la question. Chacun pose son curseur, et le
collectif retient la médiane basse. Bouge les cinq curseurs-votes ci-dessous :
tire un vote vers l'extrême, la médiane bouge à peine.
</p>
<AtelierMedianDemo />
</section>
</div>
</template>
<style scoped>
.atelier {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.atelier__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.atelier__header { display: flex; flex-direction: column; gap: 0.35rem; }
.atelier__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.atelier__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.55;
max-width: 38rem;
}
.atelier__card {
padding: clamp(1rem, 3vw, 1.4rem);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.atelier__card-head { display: flex; align-items: center; gap: 0.8rem; }
.atelier__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
font-size: 1.3rem;
flex-shrink: 0;
}
.atelier__card-icon--binary {
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
color: var(--mood-secondary);
}
.atelier__card-icon--parametric {
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
color: var(--mood-tertiary);
}
.atelier__card-title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
.atelier__card-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.atelier__prose {
font-size: 0.875rem;
line-height: 1.65;
color: var(--mood-text);
}
</style>
@@ -0,0 +1,261 @@
<script setup lang="ts">
/**
* /textes — la bibliothèque des documents sous vote permanent.
* Pacte épinglé en tête, cartes documents (clauses, sessions en cours,
* provenance résumée), lien vers l'Atelier des formules, filtre gravées 井.
*/
import type { TextDoc } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
engravedCount,
liveClauseGauges,
type ClauseSessionGauge,
} from '~/components/texts/textsModel'
const store = useCollectiveStore()
interface DocEntry {
doc: TextDoc
clauseCount: number
gauges: ClauseSessionGauge[]
engraved: number
}
function entryFor(doc: TextDoc): DocEntry {
const clauses = store.clauses.filter(c => c.docId === doc.id)
return {
doc,
clauseCount: clauses.length,
gauges: liveClauseGauges(clauses, store.decisions, store.sessions, store.votes),
engraved: engravedCount(clauses, store.decisions),
}
}
const pactEntry = computed((): DocEntry | null =>
store.pactDoc ? entryFor(store.pactDoc) : null)
const referenceEntries = computed((): DocEntry[] =>
store.docs
.filter(d => d.id !== store.pactDoc?.id)
.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
.map(entryFor))
// ── Filtre gravées 井 ──
const onlyEngraved = ref(false)
const visibleEntries = computed(() =>
onlyEngraved.value
? referenceEntries.value.filter(entry => entry.engraved > 0)
: referenceEntries.value)
const pactVisible = computed(() =>
pactEntry.value !== null && (!onlyEngraved.value || pactEntry.value.engraved > 0))
const liveSessionTotal = computed(() =>
(pactEntry.value?.gauges.length ?? 0)
+ referenceEntries.value.reduce((n, entry) => n + entry.gauges.length, 0))
</script>
<template>
<!-- ld-v2 -->
<div class="texts-page">
<header class="texts-page__header">
<div>
<h1 class="texts-page__title">Textes</h1>
<p class="texts-page__subtitle">la bibliothèque des documents sous vote permanent</p>
</div>
<button
type="button"
class="texts-page__filter"
:class="{ 'texts-page__filter--on': onlyEngraved }"
@click="onlyEngraved = !onlyEngraved"
>
<span class="texts-page__filter-seal">井</span>
gravées
</button>
</header>
<p v-if="liveSessionTotal > 0" class="texts-page__pulse">
<UIcon name="i-lucide-activity" />
{{ liveSessionTotal }} session{{ liveSessionTotal > 1 ? 's' : '' }} de vote en cours
sur ces textes — le document vit.
</p>
<!-- Le Pacte, épinglé -->
<section v-if="pactVisible && pactEntry" class="texts-page__pact">
<div class="texts-page__pact-banner">
<UIcon name="i-lucide-pin" class="texts-page__pin" />
<span class="texts-page__pact-badge">{{ PACT_BADGE }}</span>
<span class="texts-page__pact-subtitle">{{ PACT_SUBTITLE }}</span>
</div>
<TextDocCard
:doc="pactEntry.doc"
:clause-count="pactEntry.clauseCount"
:gauges="pactEntry.gauges"
:engraved="pactEntry.engraved"
pinned
/>
</section>
<!-- Les documents de référence -->
<section v-if="visibleEntries.length" class="texts-page__grid">
<TextDocCard
v-for="entry in visibleEntries"
:key="entry.doc.id"
:doc="entry.doc"
:clause-count="entry.clauseCount"
:gauges="entry.gauges"
:engraved="entry.engraved"
/>
</section>
<p v-else-if="onlyEngraved" class="texts-page__empty">
Aucun texte gravé pour l'instant — la gravure vient avec les décisions qui comptent.
</p>
<p v-else-if="!pactEntry" class="texts-page__empty">
Aucun texte ici pour l'instant — les documents naissent des décisions.
</p>
<!-- L'Atelier des formules -->
<NuxtLink to="/textes/formules" class="texts-page__atelier ld-card ld-card--hover">
<span class="texts-page__atelier-icon">
<UIcon name="i-lucide-flask-conical" />
</span>
<span class="texts-page__atelier-text">
<span class="texts-page__atelier-title">L'Atelier des formules</span>
<span class="texts-page__atelier-sub">
la pédagogie du seuil — pourquoi 94 pour quand 120 votent sur 7 224
</span>
</span>
<UIcon name="i-lucide-arrow-right" class="texts-page__atelier-arrow" />
</NuxtLink>
</div>
</template>
<style scoped>
.texts-page {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.texts-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.texts-page__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.texts-page__subtitle { font-size: 0.875rem; color: var(--mood-text-muted); margin-top: 2px; }
.texts-page__filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.35rem 1rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
background: var(--mood-surface);
color: var(--mood-text-muted);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.texts-page__filter:hover { transform: translateY(-1px); }
.texts-page__filter--on {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
box-shadow: 0 0 0 2px var(--mood-status-vigueur);
}
.texts-page__filter-seal { font-size: 1rem; font-weight: 800; }
.texts-page__pulse {
display: inline-flex;
align-items: center;
gap: 0.45rem;
align-self: flex-start;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-vote);
background: var(--mood-status-vote-bg);
padding: 0.35rem 0.9rem;
border-radius: var(--r-pill);
}
.texts-page__pact { display: flex; flex-direction: column; gap: 0.5rem; }
.texts-page__pact-banner {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.texts-page__pin { color: var(--mood-accent); font-size: 0.95rem; }
.texts-page__pact-badge {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.texts-page__pact-subtitle {
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.texts-page__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
}
@media (min-width: 768px) { .texts-page__grid { grid-template-columns: 1fr 1fr; } }
.texts-page__empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-style: italic;
padding: 1rem 0;
}
.texts-page__atelier {
display: flex;
align-items: center;
gap: 0.9rem;
padding: clamp(0.9rem, 3vw, 1.2rem);
text-decoration: none;
color: inherit;
}
.texts-page__atelier-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.3rem;
flex-shrink: 0;
}
.texts-page__atelier-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.texts-page__atelier-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.texts-page__atelier-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.texts-page__atelier-arrow {
margin-left: auto;
color: var(--mood-text-muted);
flex-shrink: 0;
}
</style>