v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,142 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Binary vote component: Pour / Contre.
|
||||
*
|
||||
* Displays two large buttons for binary voting with confirmation modal.
|
||||
* Integrates with the votes store and auth store for submission and access control.
|
||||
*/
|
||||
// <!-- ld-v2 --> Binaire inertiel hérité — jauge SIGNATURE : arc 270°, le
|
||||
// remplissage suit les voix pour, le curseur de seuil DESCEND quand la
|
||||
// participation monte (wotThreshold recalculé en direct). Les lettres de la
|
||||
// formule vivent à l'Atelier ; ici, des chiffres clairs et une phrase.
|
||||
import { wotThreshold } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { thresholdSentence } from '~/lexicon'
|
||||
import type { Decision, Id, Protocol, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
disabled?: boolean
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
protocol: Protocol
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const votes = useVotesStore()
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const submitting = ref(false)
|
||||
const pendingVote = ref<'pour' | 'contre' | null>(null)
|
||||
const showConfirm = ref(false)
|
||||
const eligible = computed(() => Math.max(props.session.corpusSize, 1))
|
||||
const active = computed(() => store.activeVotes(props.session.id))
|
||||
const votesFor = computed(() => active.value.filter(v => v.value === 'for').length)
|
||||
const votesAgainst = computed(() => active.value.filter(v => v.value === 'against').length)
|
||||
const total = computed(() => votesFor.value + votesAgainst.value)
|
||||
|
||||
/** Check if the current user has already voted in this session. */
|
||||
const userVote = computed(() => {
|
||||
if (!auth.identity) return null
|
||||
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
|
||||
const formula = computed(() => props.protocol.formula)
|
||||
const threshold = computed(() => wotThreshold(
|
||||
eligible.value, total.value,
|
||||
formula.value.majorityPct, formula.value.baseExponent,
|
||||
formula.value.gradientExponent, formula.value.constantBase,
|
||||
))
|
||||
|
||||
const sentence = computed(() =>
|
||||
total.value > 0 ? thresholdSentence(eligible.value, total.value, threshold.value) : '')
|
||||
const formulaLink = computed(() => {
|
||||
const f = formula.value
|
||||
return `/textes/formules?W=${eligible.value}&T=${total.value}&M=${f.majorityPct}`
|
||||
+ `&B=${f.baseExponent}&G=${f.gradientExponent}&C=${f.constantBase}`
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
||||
})
|
||||
// ── Arc 270° : de 135° à 405°, sens horaire ──
|
||||
const R = 46
|
||||
function pt(frac: number): { x: number; y: number } {
|
||||
const a = ((135 + 270 * frac) * Math.PI) / 180
|
||||
return { x: 60 + R * Math.cos(a), y: 60 + R * Math.sin(a) }
|
||||
}
|
||||
function arc(from: number, to: number): string {
|
||||
const p1 = pt(from)
|
||||
const p2 = pt(to)
|
||||
const large = (to - from) > 2 / 3 ? 1 : 0
|
||||
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} A ${R} ${R} 0 ${large} 1 ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`
|
||||
}
|
||||
function tick(frac: number): string {
|
||||
const a = ((135 + 270 * frac) * Math.PI) / 180
|
||||
const x1 = 60 + (R - 11) * Math.cos(a)
|
||||
const y1 = 60 + (R - 11) * Math.sin(a)
|
||||
const x2 = 60 + (R + 11) * Math.cos(a)
|
||||
const y2 = 60 + (R + 11) * Math.sin(a)
|
||||
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} L ${x2.toFixed(2)} ${y2.toFixed(2)}`
|
||||
}
|
||||
const trackPath = arc(0, 1)
|
||||
const forFrac = computed(() => (total.value > 0 ? votesFor.value / total.value : 0))
|
||||
const againstFrac = computed(() => (total.value > 0 ? votesAgainst.value / total.value : 0))
|
||||
const thrFrac = computed(() => (total.value > 0 ? Math.min(threshold.value / total.value, 1) : 1))
|
||||
const fillPath = computed(() => (forFrac.value > 0 ? arc(0, forFrac.value) : ''))
|
||||
const againstPath = computed(() => (againstFrac.value > 0 ? arc(1 - againstFrac.value, 1) : ''))
|
||||
const thresholdTick = computed(() => tick(thrFrac.value))
|
||||
|
||||
function requestVote(value: 'pour' | 'contre') {
|
||||
if (isDisabled.value) return
|
||||
pendingVote.value = value
|
||||
showConfirm.value = true
|
||||
// ── Le geste ──
|
||||
const voterId = computed(() => props.asPersonId ?? col.me?.id)
|
||||
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
|
||||
const refusing = ref(false)
|
||||
const comment = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function cast(value: 'for' | 'against') {
|
||||
error.value = ''
|
||||
const result = store.castVote(props.session.id, {
|
||||
value,
|
||||
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
|
||||
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
|
||||
})
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
comment.value = ''
|
||||
refusing.value = false
|
||||
}
|
||||
|
||||
async function confirmVote() {
|
||||
if (!pendingVote.value) return
|
||||
showConfirm.value = false
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await votes.submitVote({
|
||||
session_id: props.sessionId,
|
||||
vote_value: pendingVote.value,
|
||||
signature: 'pending',
|
||||
signed_payload: 'pending',
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
pendingVote.value = null
|
||||
}
|
||||
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
|
||||
function cancelVote() {
|
||||
showConfirm.value = false
|
||||
pendingVote.value = null
|
||||
}
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
return pendingVote.value === 'pour'
|
||||
? 'Confirmer le vote POUR'
|
||||
: 'Confirmer le vote CONTRE'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Vote buttons -->
|
||||
<div class="flex gap-4">
|
||||
<UButton
|
||||
size="xl"
|
||||
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
|
||||
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
|
||||
:disabled="isDisabled"
|
||||
:loading="submitting && pendingVote === 'pour'"
|
||||
icon="i-lucide-thumbs-up"
|
||||
class="flex-1 justify-center py-6 text-lg"
|
||||
@click="requestVote('pour')"
|
||||
>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vb">
|
||||
<!-- La jauge inertielle -->
|
||||
<div class="vb__gauge">
|
||||
<svg viewBox="0 0 120 114" class="vb__svg" aria-hidden="true">
|
||||
<path :d="trackPath" class="vb__track" />
|
||||
<path v-if="againstPath" :d="againstPath" class="vb__against" />
|
||||
<path v-if="fillPath" :d="fillPath" class="vb__fill" />
|
||||
<path :d="thresholdTick" class="vb__threshold" />
|
||||
</svg>
|
||||
<div class="vb__center">
|
||||
<span class="vb__for">{{ votesFor.toLocaleString('fr-FR') }}</span>
|
||||
<span class="vb__for-label">pour</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Les chiffres, clairs -->
|
||||
<dl class="vb__stats">
|
||||
<div><dt>seuil requis</dt><dd>{{ threshold.toLocaleString('fr-FR') }}</dd></div>
|
||||
<div><dt>votants</dt><dd>{{ total.toLocaleString('fr-FR') }}</dd></div>
|
||||
<div><dt>inscrits</dt><dd>{{ eligible.toLocaleString('fr-FR') }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<p v-if="sentence" class="vb__sentence">{{ sentence }}</p>
|
||||
<p v-else class="vb__sentence vb__sentence--muted">Personne n'a encore voté — le seuil part de l'unanimité et descend avec la participation.</p>
|
||||
<NuxtLink :to="formulaLink" class="vb__link">
|
||||
<UIcon name="i-lucide-graduation-cap" />
|
||||
<span>comprendre ce seuil</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Le geste -->
|
||||
<div v-if="session.status === 'open'" class="vb__actions">
|
||||
<button class="ld-btn vb__yes" type="button" :disabled="!canAct" @click="refusing = false; cast('for')">
|
||||
Pour
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
size="xl"
|
||||
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
|
||||
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
|
||||
:disabled="isDisabled"
|
||||
:loading="submitting && pendingVote === 'contre'"
|
||||
icon="i-lucide-thumbs-down"
|
||||
class="flex-1 justify-center py-6 text-lg"
|
||||
@click="requestVote('contre')"
|
||||
>
|
||||
</button>
|
||||
<button class="ld-btn ld-btn--ghost vb__no" type="button" :disabled="!canAct" @click="refusing = !refusing">
|
||||
Contre
|
||||
</UButton>
|
||||
</button>
|
||||
<span v-if="myVote" class="vb__mine">
|
||||
Ta position actuelle : {{ myVote.value === 'for' ? 'pour' : 'refus argumenté' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="refusing && session.status === 'open'" class="vb__argue">
|
||||
<textarea v-model="comment" rows="2" placeholder="Dis pourquoi — un commentaire accompagne toute position négative." />
|
||||
<button class="ld-btn" type="button" :disabled="!comment.trim()" @click="cast('against')">
|
||||
Déposer ce refus
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vb__error">{{ error }}</p>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
|
||||
Connectez-vous pour voter
|
||||
</div>
|
||||
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
|
||||
Cette session de vote est fermee
|
||||
</div>
|
||||
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
|
||||
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="votes.error" class="text-sm text-red-500 text-center">
|
||||
{{ votes.error }}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<UModal v-model:open="showConfirm">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Confirmation du vote
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Vous etes sur le point de voter
|
||||
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
|
||||
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
|
||||
</strong>.
|
||||
Cette action est definitive.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="ghost" color="neutral" @click="cancelVote">
|
||||
Annuler
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="pendingVote === 'pour' ? 'success' : 'error'"
|
||||
@click="confirmVote"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
<!-- Clôture : la distribution complète -->
|
||||
<p v-if="session.status === 'closed'" class="vb__outcome">
|
||||
{{ votesFor.toLocaleString('fr-FR') }} pour · {{ votesAgainst.toLocaleString('fr-FR') }} refus ·
|
||||
seuil {{ threshold.toLocaleString('fr-FR') }} —
|
||||
{{ session.outcome === 'adopted' ? 'le collectif adopte.' : 'le collectif n’adopte pas.' }}
|
||||
</p>
|
||||
<ul v-if="comments.length" class="vb__comments">
|
||||
<li v-for="vote in comments" :key="vote.id">
|
||||
<span v-if="!secret" class="vb__comment-who">{{ name(vote.voterId) }}</span>
|
||||
<p>{{ vote.comment }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vb { padding: 1.25rem; display: flex; flex-direction: column; align-items: center; gap: 0.8rem; }
|
||||
.vb__gauge { position: relative; width: min(240px, 70vw); }
|
||||
.vb__svg { width: 100%; display: block; }
|
||||
.vb__track { fill: none; stroke: var(--mood-accent-soft); stroke-width: 9; stroke-linecap: round; }
|
||||
.vb__fill { fill: none; stroke: var(--mood-accent); stroke-width: 9; stroke-linecap: round; transition: d 0.25s ease; }
|
||||
.vb__against { fill: none; stroke: color-mix(in srgb, var(--mood-error) 45%, transparent); stroke-width: 9; stroke-linecap: round; }
|
||||
.vb__threshold { fill: none; stroke: var(--mood-secondary); stroke-width: 3.5; stroke-linecap: round; transition: d 0.25s ease; }
|
||||
.vb__center {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; pointer-events: none;
|
||||
}
|
||||
.vb__for { font-size: clamp(1.6rem, 8vw, 2.2rem); font-weight: 800; color: var(--mood-accent); line-height: 1; }
|
||||
.vb__for-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
|
||||
.vb__stats { display: flex; gap: 1.5rem; margin: 0; }
|
||||
.vb__stats div { display: flex; flex-direction: column; align-items: center; }
|
||||
.vb__stats dt { font-size: 0.72rem; font-weight: 700; color: var(--mood-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.vb__stats dd { margin: 0; font-size: 1.125rem; font-weight: 800; }
|
||||
.vb__stats div:first-child dd { color: var(--mood-secondary); }
|
||||
.vb__sentence { margin: 0; text-align: center; font-size: 0.9375rem; line-height: 1.5; max-width: 34rem; }
|
||||
.vb__sentence--muted { color: var(--mood-text-muted); }
|
||||
.vb__link {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.8125rem;
|
||||
font-weight: 700; color: var(--mood-accent); text-decoration: none;
|
||||
}
|
||||
.vb__link:hover { text-decoration: underline; }
|
||||
.vb__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 0.75rem; }
|
||||
.vb__yes { min-width: 7rem; }
|
||||
.vb__no { min-width: 7rem; color: var(--mood-error); background: color-mix(in srgb, var(--mood-error) 10%, transparent); }
|
||||
.vb__mine { font-size: 0.8125rem; color: var(--mood-text-muted); width: 100%; text-align: center; }
|
||||
.vb__argue { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vb__argue textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
|
||||
.vb__argue .ld-btn { align-self: flex-end; }
|
||||
.vb__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vb__outcome { margin: 0; font-weight: 700; font-size: 0.9375rem; text-align: center; }
|
||||
.vb__comments { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vb__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
|
||||
.vb__comments p { margin: 0.2rem 0 0; }
|
||||
.vb__comment-who { font-weight: 700; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user