Files
decision/frontend/app/components/votes/VoteNuanced.vue
T
YvvandClaude Fable 5 e164b5f6c1 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>
2026-08-11 13:47:20 +02:00

193 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// <!-- ld-v2 --> Nuancé 6 niveaux — segments en gradient sémantique dérivé des
// custom properties du mood (color-mix error→success, jamais de couleurs crues),
// commentaire obligatoire sous 0-1, histogramme de la distribution en cours.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { NUANCED_LABELS } from '~/lexicon'
import type { Decision, Id, NuancedValue, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const LEVELS: NuancedValue[] = [0, 1, 2, 3, 4, 5]
/** % de success dans le mélange sémantique error→success, par niveau. */
const MIX = [6, 24, 44, 62, 80, 94] as const
function levelColor(level: number): string {
return `color-mix(in oklab, var(--mood-success) ${MIX[level] ?? 50}%, var(--mood-error))`
}
const active = computed(() => store.activeVotes(props.session.id))
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const pending = ref<NuancedValue | null>(null)
watch(myVote, (vote) => {
if (pending.value === null && typeof vote?.value === 'number') pending.value = vote.value as NuancedValue
}, { immediate: true })
const comment = ref('')
const error = ref('')
const needsComment = computed(() => pending.value === 0 || pending.value === 1)
const canDeposit = computed(() =>
props.canAct && pending.value !== null && (!needsComment.value || comment.value.trim().length > 0),
)
function deposit() {
if (pending.value === null) return
error.value = ''
const result = store.castVote(props.session.id, {
value: pending.value,
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) { error.value = result.reason; return }
comment.value = ''
}
// ── Distribution (recalculée depuis les derniers votes actifs) ──
const counts = computed(() => {
const perLevel = [0, 0, 0, 0, 0, 0]
for (const vote of active.value) {
if (typeof vote.value === 'number') perLevel[vote.value] = (perLevel[vote.value] ?? 0) + 1
}
return perLevel
})
const maxCount = computed(() => Math.max(1, ...counts.value))
const total = computed(() => active.value.length)
const result = computed(() => {
const tallied = store.tally(props.session)
return !('ok' in tallied) && tallied.method === 'nuanced' ? tallied.result : null
})
const thresholdPct = computed(() => props.protocol.formula.nuancedThresholdPct ?? 80)
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vn">
<!-- La jauge : 6 segments -->
<div class="vn__scale" role="radiogroup" aria-label="Nuance">
<button
v-for="level in LEVELS"
:key="level"
type="button"
class="vn__segment"
:class="{ 'vn__segment--picked': pending === level, 'vn__segment--mine': typeof myVote?.value === 'number' && myVote.value === level }"
:style="{ '--seg': levelColor(level) }"
:disabled="!canAct"
@click="pending = level"
>
<span class="vn__segment-value">{{ level }}</span>
<span class="vn__segment-label">{{ NUANCED_LABELS[level] }}</span>
</button>
</div>
<!-- Commentaire obligatoire sous 0-1 -->
<div v-if="needsComment && session.status === 'open'" class="vn__comment">
<label>Dis pourquoi un commentaire accompagne toute position négative.</label>
<textarea v-model="comment" rows="2" placeholder="Ton argument…" />
</div>
<div v-if="session.status === 'open'" class="vn__actions">
<button class="ld-btn" type="button" :disabled="!canDeposit" @click="deposit()">
{{ myVote ? 'Remplacer mon vote' : 'Déposer mon vote' }}
</button>
<span v-if="myVote && typeof myVote.value === 'number'" class="vn__mine">
Ta nuance actuelle : {{ NUANCED_LABELS[myVote.value as NuancedValue] }}
</span>
</div>
<p v-if="error" class="vn__error">{{ error }}</p>
<!-- Histogramme des 6 niveaux la distribution, pas seulement le résultat -->
<div class="vn__histogram" aria-label="Distribution des nuances">
<div v-for="level in LEVELS" :key="level" class="vn__bar-col">
<span class="vn__bar-count">{{ counts[level] }}</span>
<div
class="vn__bar"
:style="{ height: `${4 + ((counts[level] ?? 0) / maxCount) * 64}px`, background: levelColor(level) }"
/>
<span class="vn__bar-label">{{ level }}</span>
</div>
<span class="vn__total">{{ total }} vote{{ total > 1 ? 's' : '' }}</span>
</div>
<!-- Résultat -->
<p v-if="result && total > 0" class="vn__result" :class="{ 'vn__result--closed': session.status === 'closed' }">
{{ result.positive_count }} nuance{{ result.positive_count > 1 ? 's' : '' }} positive{{ result.positive_count > 1 ? 's' : '' }}
sur {{ result.total }} {{ result.positive_pct.toLocaleString('fr-FR') }} %
(seuil {{ thresholdPct.toLocaleString('fr-FR') }} %).
<template v-if="session.status === 'closed'">
{{ session.outcome === 'adopted' ? 'Le collectif adopte.' : 'Le collectif nadopte pas.' }}
</template>
</p>
<!-- Les arguments, toujours en liste -->
<ul v-if="comments.length" class="vn__comments">
<li v-for="vote in comments" :key="vote.id">
<span v-if="!secret" class="vn__comment-who">{{ name(vote.voterId) }}</span>
<span class="vn__comment-level" :style="{ color: levelColor(Number(vote.value)) }">
{{ typeof vote.value === 'number' ? NUANCED_LABELS[vote.value as NuancedValue] : '' }}
</span>
<p>{{ vote.comment }}</p>
</li>
</ul>
</section>
</template>
<style scoped>
.vn { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.vn__scale { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.vn__segment {
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
padding: 0.6rem 0.2rem; min-height: 3.5rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--seg) 14%, var(--mood-surface));
color: var(--seg); cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.vn__segment:hover:not(:disabled) { transform: translateY(-1px); }
.vn__segment:disabled { cursor: not-allowed; opacity: 0.6; }
.vn__segment--picked { background: var(--seg); color: var(--mood-surface); box-shadow: 0 2px 8px var(--mood-shadow); }
.vn__segment--mine:not(.vn__segment--picked) { box-shadow: inset 0 0 0 2px var(--seg); }
.vn__segment-value { font-size: 1.05rem; font-weight: 800; }
.vn__segment-label { font-size: 0.66rem; font-weight: 700; text-align: center; line-height: 1.15; }
.vn__comment { display: flex; flex-direction: column; gap: 0.35rem; }
.vn__comment label { font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning); }
.vn__comment textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
.vn__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; }
.vn__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
.vn__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vn__histogram {
display: flex; align-items: flex-end; gap: 6px;
padding: 0.75rem 0.5rem 0.4rem; background: var(--mood-bg); border-radius: var(--r-input);
}
.vn__bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
.vn__bar { width: 100%; max-width: 44px; border-radius: 4px 4px 2px 2px; opacity: 0.85; transition: height 0.2s ease; }
.vn__bar-count { font-size: 0.75rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__bar-label { font-size: 0.7rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__total { align-self: flex-end; font-size: 0.75rem; color: var(--mood-text-muted); padding: 0 0.25rem 0.35rem; white-space: nowrap; }
.vn__result { margin: 0; font-size: 0.9375rem; }
.vn__result--closed { font-weight: 700; }
.vn__comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.vn__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
.vn__comments p { margin: 0.2rem 0 0; }
.vn__comment-who { font-weight: 700; margin-right: 0.5rem; }
.vn__comment-level { font-weight: 700; font-size: 0.8125rem; }
@media (max-width: 480px) {
.vn__scale { grid-template-columns: repeat(3, 1fr); }
}
</style>