Files
decision/frontend/app/components/votes/VoteParametric.vue
T
YvvandClaude Fable 5 3301b764f7 v2 : correctifs QA — overlays posés, accueil jamais orphelin, finitions mobiles
- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS
  (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui
  ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et
  modales de confirmation se rendaient hors écran
- a11y : title/description sur les 3 overlays (DialogTitle/Description
  rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée
- suppression du collectif actif : bascule sur un collectif restant —
  jamais d'accueil orphelin (test store ajouté, 343 vitest verts)
- observatoire : la route « transmis » entre dans les barres (base 100 %)
- overflows mobiles à zéro : formules KaTeX (défilement), table vigueur
  (défilement < 768px), pill cible (retour à la ligne), inputs decider et
  mandat (border-box)
- vérifié navigateur : drawer, modale, 14 routes sans débordement,
  zéro erreur console

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 20:11:54 +02:00

409 lines
18 KiB
Vue

<script setup lang="ts">
// <!-- ld-v2 --> Réglage collectif — décider au curseur. Faisceau anonyme,
// médiane basse nommée, carte « Pour moi » (jamais de chiffre inventé),
// sandbox Explorer, et la CRISTALLISATION : un geste humain daté du garant,
// solennel mais léger — jamais un automatisme.
import { computeMyImpact, detectBimodality, medianByElement, resolveDerived } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, CRYSTALLIZE_MODAL_DESC, EXPLORE_DISCLAIMER,
EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER,
MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL,
} from '~/lexicon'
import type { Decision, Id, ParamSpec, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
isSteward?: boolean
}>()
const emit = defineEmits<{ (e: 'adopted'): void }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const spec = computed<ParamSpec>(() => props.decision.paramSpec!)
const votable = computed(() => spec.value.params.filter(p => p.derived !== true))
/** Index votable → index complet (ordre spec.params). */
const votableIndex = computed(() => {
const map = new Map<string, number>()
votable.value.forEach((p, i) => map.set(p.key, i))
return map
})
// ── Les positions déposées (derniers votes actifs, toujours recalculées) ──
const active = computed(() => store.activeVotes(props.session.id))
const vectors = computed(() => active.value
.map(v => v.values)
.filter((v): v is number[] => v !== undefined && v.length === votable.value.length))
const fullVectors = computed(() => vectors.value.map(v => resolveDerived(spec.value, v)))
const medianVotable = computed(() => medianByElement(vectors.value))
const medianFull = computed(() =>
medianVotable.value.length === votable.value.length && votable.value.length > 0
? resolveDerived(spec.value, medianVotable.value)
: null)
const baselineFull = computed(() => spec.value.params.map(p => p.baseline ?? p.min))
const bimodalKeys = computed(() => votable.value
.filter((_, i) => detectBimodality(vectors.value.map(v => v[i] ?? Number.NaN)))
.map(p => p.key))
function columnFor(fullIndex: number): number[] {
return fullVectors.value.map(v => v[fullIndex] ?? Number.NaN).filter(Number.isFinite)
}
// ── Ma position de travail ──
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const working = ref<number[]>(
(myVote.value?.values && [...myVote.value.values])
?? votable.value.map(p => p.baseline ?? p.min),
)
const workingFull = computed(() => resolveDerived(spec.value, working.value))
const round6 = (v: number) => Math.round(v * 1e6) / 1e6
/** Nouvelle liste avec la valeur vi ajustée : bornes, pas, et part calculée gardée dans ses bornes. */
function adjusted(list: number[], vi: number, raw: number): number[] {
const p = votable.value[vi]
if (!p || !Number.isFinite(raw)) return list
const snap = (v: number) => round6(p.min + Math.round((v - p.min) / p.step) * p.step)
let v = snap(Math.min(p.max, Math.max(p.min, raw)))
const next = [...list]
const derived = spec.value.params.find(q => q.derived === true)
if (spec.value.constraint === 'sum100' && p.kind === 'share' && derived) {
const otherSum = votable.value.reduce(
(sum, q, j) => sum + (q.kind === 'share' && j !== vi ? (next[j] ?? 0) : 0), 0)
const lo = 100 - derived.max - otherSum
const hi = 100 - derived.min - otherSum
v = snap(Math.min(hi, Math.max(lo, v)))
if (v > hi + 1e-6) v = round6(v - p.step)
if (v < lo - 1e-6) v = round6(v + p.step)
v = Math.min(p.max, Math.max(p.min, v))
}
next[vi] = v
return next
}
function setWorking(key: string, v: number) {
const vi = votableIndex.value.get(key)
if (vi !== undefined) working.value = adjusted(working.value, vi, v)
}
const error = ref('')
function deposit() {
error.value = ''
const result = store.castVote(props.session.id, {
values: [...working.value],
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) error.value = result.reason
}
// ── Carte « Pour moi » — estimation sur données déclarées, sinon rien ──
const attrKey = computed(() => spec.value.impactAttrKey)
const myAttr = computed(() =>
attrKey.value !== undefined ? col.me?.attributes?.[attrKey.value] : undefined)
const declaredAttrs = computed(() => {
const key = attrKey.value
if (key === undefined) return []
return col.people
.filter(p => props.session.corpusPersonIds.includes(p.id))
.map(p => p.attributes?.[key])
.filter((v): v is number => typeof v === 'number')
})
const resources = computed(() => props.decision.resources ?? { note: '' })
const impactMine = computed(() =>
computeMyImpact(spec.value, resources.value, workingFull.value, myAttr.value, declaredAttrs.value))
const impactMedian = computed(() => medianFull.value
? computeMyImpact(spec.value, resources.value, medianFull.value, myAttr.value, declaredAttrs.value)
: null)
const impactBase = computed(() =>
computeMyImpact(spec.value, resources.value, baselineFull.value, myAttr.value, declaredAttrs.value))
const unit = computed(() => props.decision.resources?.unit ?? '')
const fmtAmount = (v?: number) =>
v === undefined ? '—' : `${v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} ${unit.value}`
// ── Explorer — exploration, ne compte pas ──
const exploring = ref(false)
const sandbox = ref<number[]>([])
function toggleExplore() {
if (exploring.value) { exploring.value = false; return }
sandbox.value = [...working.value]
exploring.value = true
}
function setSandbox(key: string, v: number) {
const vi = votableIndex.value.get(key)
if (vi !== undefined) sandbox.value = adjusted(sandbox.value, vi, v)
}
const sandboxFull = computed(() =>
sandbox.value.length === votable.value.length ? resolveDerived(spec.value, sandbox.value) : [])
const seedMedian = () => { if (medianVotable.value.length) sandbox.value = [...medianVotable.value] }
const seedBase = () => { sandbox.value = votable.value.map(p => p.baseline ?? p.min) }
const seedMine = () => { sandbox.value = [...working.value] }
function makeMine() { working.value = [...sandbox.value]; exploring.value = false }
// ── Cristallisation — le geste du garant ──
const frozen = computed(() => props.session.status === 'frozen')
const locked = computed(() => props.session.status !== 'open' || !props.canAct)
const showCrystal = ref(false)
const quorum = computed(() => props.protocol.formula.parametricMinParticipants)
const quorumShort = computed(() =>
quorum.value !== undefined && vectors.value.length < quorum.value)
function doCrystallize() {
error.value = ''
const result = store.crystallize(props.session.id)
if ('ok' in result) { error.value = result.reason; return }
showCrystal.value = false
if (result.outcome === 'adopted') emit('adopted')
}
function split() {
navigateTo({ path: '/decider', query: { parent: props.decision.id, chain: 'element' } })
}
function reframe() {
error.value = ''
const result = store.transition(props.decision.id, 'framing')
if (!result.ok) { error.value = result.reason; return }
showCrystal.value = false
}
const crystallizer = computed(() => props.session.crystallizedById
? col.people.find(p => p.id === props.session.crystallizedById)?.displayName ?? '—'
: null)
const shareParams = computed(() => spec.value.constraint === 'sum100'
? spec.value.params.filter(p => p.kind === 'share')
: [])
const plainParams = computed(() => spec.value.constraint === 'sum100'
? spec.value.params.filter(p => p.kind !== 'share')
: spec.value.params)
function fullIndexOf(key: string): number {
return spec.value.params.findIndex(p => p.key === key)
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vp">
<p class="vp__explain">{{ MEDIAN_EXPLANATION }}</p>
<p v-if="frozen" class="vp__frozen">
<UIcon name="i-lucide-lock" />
<span>{{ FROZEN_BANNER }}</span>
</p>
<!-- Le cadre contraint (parts à 100) puis les curseurs libres -->
<div v-if="shareParams.length" class="vp__frame">
<span class="vp__frame-tag">{{ PARAM_CONSTRAINT_LABEL }} les parts totalisent 100</span>
<VoteParamSlider
v-for="p in shareParams"
:key="p.key"
:param="p"
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
:dots="columnFor(fullIndexOf(p.key))"
:median="medianFull?.[fullIndexOf(p.key)]"
:locked="locked"
:bimodal="bimodalKeys.includes(p.key)"
@update:value="setWorking(p.key, $event)"
/>
</div>
<VoteParamSlider
v-for="p in plainParams"
:key="p.key"
:param="p"
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
:dots="columnFor(fullIndexOf(p.key))"
:median="medianFull?.[fullIndexOf(p.key)]"
:locked="locked"
:bimodal="bimodalKeys.includes(p.key)"
@update:value="setWorking(p.key, $event)"
/>
<div v-if="session.status === 'open'" class="vp__actions">
<button class="ld-btn" type="button" :disabled="!canAct" @click="deposit()">
{{ myVote ? 'Remplacer ma position' : 'Déposer ma position' }}
</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="toggleExplore()">
<UIcon name="i-lucide-flask-conical" />
<span>{{ EXPLORE_LABEL }}</span>
</button>
</div>
<p v-if="error" class="vp__error">{{ error }}</p>
<!-- Sandbox Explorer -->
<div v-if="exploring" class="vp__sandbox">
<p class="vp__sandbox-banner">
<UIcon name="i-lucide-flask-conical" />
<span>{{ EXPLORE_DISCLAIMER }}</span>
</p>
<div class="vp__sandbox-seeds">
<button class="ld-btn ld-btn--quiet" type="button" :disabled="!medianVotable.length" @click="seedMedian()">Partir de la médiane</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="seedBase()">Partir du statu quo</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="seedMine()">Partir de ma position</button>
</div>
<VoteParamSlider
v-for="p in spec.params"
:key="p.key"
:param="p"
:value="sandboxFull[fullIndexOf(p.key)] ?? p.min"
neutral
@update:value="setSandbox(p.key, $event)"
/>
<button class="ld-btn" type="button" :disabled="session.status !== 'open'" @click="makeMine()">
En faire ma position
</button>
</div>
<!-- Carte « Pour moi » masquée si rien de déclaré, jamais de chiffre inventé -->
<div v-if="impactMine && impactBase" class="vp__impact">
<h3 class="vp__impact-title">{{ IMPACT_CARD_TITLE }}</h3>
<table class="vp__impact-table">
<thead>
<tr><th /><th>ma position</th><th>médiane</th><th>statu quo</th></tr>
</thead>
<tbody>
<tr v-for="(line, i) in impactMine.perParam" :key="line.key">
<th>{{ line.label }}</th>
<td>{{ fmtAmount(line.amount) }}</td>
<td>{{ fmtAmount(impactMedian?.perParam[i]?.amount) }}</td>
<td>{{ fmtAmount(impactBase.perParam[i]?.amount) }}</td>
</tr>
<tr class="vp__impact-total">
<th>total</th>
<td>{{ fmtAmount(impactMine.total) }}</td>
<td>{{ fmtAmount(impactMedian?.total) }}</td>
<td>{{ fmtAmount(impactBase.total) }}</td>
</tr>
</tbody>
</table>
<p class="vp__impact-note">{{ IMPACT_DISCLAIMER }}</p>
</div>
<p class="vp__count">
{{ vectors.length }} position{{ vectors.length > 1 ? 's' : '' }} déposée{{ vectors.length > 1 ? 's' : '' }}
<template v-if="quorum !== undefined"> · quorum : {{ quorum }}</template>
</p>
<!-- Le geste du garant -->
<div v-if="frozen && isSteward" class="vp__crystal">
<p class="vp__crystal-card">{{ CRYSTALLIZE_CARD }}</p>
<button class="vp__stamp-btn" type="button" @click="showCrystal = true">
<UIcon name="i-lucide-stamp" />
<span>{{ CRYSTALLIZE_ACTION }}</span>
</button>
</div>
<p v-if="crystallizer && session.crystallizedAt" class="vp__crystallized">
Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}.
</p>
<UModal
v-model:open="showCrystal"
:title="CRYSTALLIZE_ACTION"
:description="CRYSTALLIZE_MODAL_DESC"
:ui="{ header: 'ldov-header', title: 'ldov-title', description: 'ldov-desc', close: 'ldov-close', body: 'ldov-body' }"
>
<template #body>
<div class="vp__modal">
<p v-if="medianFull" class="vp__modal-median">
<template v-for="(p, i) in spec.params" :key="p.key">
<span>{{ p.label }} : <strong>{{ (medianFull[i] ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 2 }) }}</strong>{{ p.unit ? ` ${p.unit}` : '' }}</span>
</template>
</p>
<p v-if="quorumShort" class="vp__modal-warn">
Le quorum n'est pas atteint ({{ vectors.length }} sur {{ quorum }}) le geste constatera le rejet.
</p>
<p v-for="key in bimodalKeys" :key="key" class="vp__modal-warn">
<UIcon name="i-lucide-split" />
<span>{{ votable.find(p => p.key === key)?.label }} : deux positions distinctes se dessinent.</span>
</p>
<div class="vp__modal-choices">
<template v-if="bimodalKeys.length">
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_CHOICES[0] }}</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="split()">{{ CRYSTALLIZE_CHOICES[1] }}</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="reframe()">{{ CRYSTALLIZE_CHOICES[2] }}</button>
</template>
<template v-else>
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_ACTION }}</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="showCrystal = false">Annuler</button>
</template>
</div>
<p v-if="error" class="vp__error">{{ error }}</p>
</div>
</template>
</UModal>
</section>
</template>
<style scoped>
.vp { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.vp__explain { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5; }
.vp__frozen {
display: flex; align-items: center; gap: 0.5rem; margin: 0;
padding: 0.6rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
font-weight: 700; font-size: 0.9375rem;
}
.vp__frame {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 0.9rem; border-radius: var(--r-input);
background: var(--mood-bg);
}
.vp__frame-tag {
align-self: flex-start; font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: var(--mood-text-muted); background: var(--mood-surface);
padding: 2px 10px; border-radius: var(--r-pill);
}
.vp__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.vp__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vp__sandbox {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text-muted) 7%, var(--mood-bg));
}
.vp__sandbox-banner {
display: flex; align-items: center; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
text-transform: uppercase; letter-spacing: 0.04em;
}
.vp__sandbox-seeds { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.vp__sandbox > .ld-btn { align-self: flex-start; }
.vp__impact {
background: var(--mood-accent-soft); border-radius: var(--r-input);
padding: 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.5rem;
}
.vp__impact-title { margin: 0; font-size: 0.9375rem; font-weight: 800; color: var(--mood-accent); }
.vp__impact-table { border-collapse: collapse; font-size: 0.875rem; width: 100%; }
.vp__impact-table th, .vp__impact-table td { text-align: right; padding: 0.2rem 0.45rem; }
.vp__impact-table tbody th { text-align: left; font-weight: 600; }
.vp__impact-table thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mood-text-muted); }
.vp__impact-total { font-weight: 800; }
.vp__impact-note { margin: 0; font-size: 0.75rem; font-style: italic; color: var(--mood-text-muted); }
.vp__count { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.vp__crystal {
display: flex; flex-direction: column; align-items: center; gap: 0.6rem;
padding: 1rem; border-radius: var(--r-input); background: var(--mood-status-fige-bg);
}
.vp__crystal-card { margin: 0; font-weight: 600; font-size: 0.9375rem; text-align: center; }
.vp__stamp-btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.65rem 1.5rem; border-radius: 10px; cursor: pointer;
transform: rotate(-2deg);
font-weight: 800; font-size: 1rem; letter-spacing: 0.03em;
color: var(--mood-accent); background: var(--mood-surface);
box-shadow: inset 0 0 0 2.5px var(--mood-accent), 0 2px 8px var(--mood-shadow);
transition: transform 0.12s ease;
}
.vp__stamp-btn:hover { transform: rotate(-2deg) translateY(-1px); }
.vp__stamp-btn:active { transform: rotate(-2deg) translateY(0); }
.vp__crystallized { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-status-vigueur); }
.vp__modal { display: flex; flex-direction: column; gap: 0.75rem; }
.vp__modal-median { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.9375rem; }
.vp__modal-warn {
display: flex; align-items: baseline; gap: 0.4rem; margin: 0;
font-size: 0.875rem; font-weight: 600; color: var(--mood-warning);
}
.vp__modal-choices { display: flex; flex-wrap: wrap; gap: 0.5rem; }
</style>