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:
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Consentement — « Ça me va » est un geste stocké (Assent),
|
||||
// l'objection s'argumente toujours. Zéro objection maintenue = adopté.
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import { WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
|
||||
import type { Decision, Id, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
}>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const assents = computed(() => col.assents.filter(a => a.decisionId === props.decision.id))
|
||||
const objections = computed(() => col.objections.filter(o => o.decisionId === props.decision.id))
|
||||
const openObjections = computed(() => objections.value.filter(o => o.status === 'open'))
|
||||
const liftedObjections = computed(() => objections.value.filter(o => o.status !== 'open'))
|
||||
const assentPeople = computed(() =>
|
||||
assents.value
|
||||
.map(a => col.people.find(p => p.id === a.personId))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
.map(person => ({ person })),
|
||||
)
|
||||
|
||||
const gaveAssent = computed(() => {
|
||||
const id = props.asPersonId ?? col.me?.id
|
||||
return assents.value.some(a => a.personId === id)
|
||||
})
|
||||
|
||||
const objecting = ref(false)
|
||||
const argument = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function name(id: Id): string {
|
||||
return col.people.find(p => p.id === id)?.displayName ?? '—'
|
||||
}
|
||||
function assent() {
|
||||
error.value = ''
|
||||
const result = store.assentTo(props.decision.id, props.asPersonId)
|
||||
if ('ok' in result) error.value = result.reason
|
||||
}
|
||||
function object() {
|
||||
error.value = ''
|
||||
const result = store.objectTo(props.decision.id, 'content', argument.value, props.asPersonId)
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
argument.value = ''
|
||||
objecting.value = false
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { withdrawn: 'levée', integrated: 'intégrée', escalated: 'escaladée' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vc">
|
||||
<!-- Les deux gestes -->
|
||||
<div v-if="session.status === 'open'" class="vc__gestures">
|
||||
<button class="vc__ok" type="button" :disabled="!canAct || gaveAssent" @click="assent()">
|
||||
<UIcon name="i-lucide-check" class="vc__gesture-icon" />
|
||||
<span>{{ WINDOW_OK }}</span>
|
||||
<span v-if="gaveAssent" class="vc__done">c'est noté</span>
|
||||
</button>
|
||||
<button class="vc__no" type="button" :disabled="!canAct" @click="objecting = !objecting">
|
||||
<UIcon name="i-lucide-hand" class="vc__gesture-icon" />
|
||||
<span>{{ WINDOW_OBJECT }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="objecting && session.status === 'open'" class="vc__argue">
|
||||
<textarea
|
||||
v-model="argument"
|
||||
rows="3"
|
||||
placeholder="Une objection s'argumente — écris pourquoi."
|
||||
/>
|
||||
<button class="ld-btn" type="button" :disabled="!argument.trim()" @click="object()">
|
||||
Déposer l'objection
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vc__error">{{ error }}</p>
|
||||
|
||||
<!-- Décomptes agrégés -->
|
||||
<div class="vc__tally">
|
||||
<span class="vc__count">
|
||||
<strong>{{ assents.length }}</strong>
|
||||
{{ assents.length > 1 ? 'accords' : 'accord' }}
|
||||
</span>
|
||||
<LdAvatarStack v-if="!secret && assentPeople.length" :people="assentPeople" :size="26" />
|
||||
<span class="vc__count">
|
||||
<strong>{{ openObjections.length }}</strong>
|
||||
{{ openObjections.length > 1 ? 'objections ouvertes' : 'objection ouverte' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Objections ouvertes puis levées -->
|
||||
<ul v-if="objections.length" class="vc__objections">
|
||||
<li v-for="objection in openObjections" :key="objection.id" class="vc__objection vc__objection--open">
|
||||
<span class="vc__objection-state">ouverte</span>
|
||||
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
|
||||
<p>{{ objection.argument }}</p>
|
||||
</li>
|
||||
<li v-for="objection in liftedObjections" :key="objection.id" class="vc__objection">
|
||||
<span class="vc__objection-state vc__objection-state--lifted">
|
||||
{{ STATUS_LABEL[objection.status as keyof typeof STATUS_LABEL] ?? objection.status }}
|
||||
</span>
|
||||
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
|
||||
<p>{{ objection.argument }}</p>
|
||||
<p v-if="objection.resolutionNote" class="vc__resolution">{{ objection.resolutionNote }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Clôture -->
|
||||
<p v-if="session.status === 'closed'" class="vc__outcome">
|
||||
<template v-if="session.outcome === 'adopted'">
|
||||
Aucune objection maintenue à l'échéance — le collectif consent.
|
||||
</template>
|
||||
<template v-else>
|
||||
Des objections restaient ouvertes à l'échéance — le collectif ne consent pas encore.
|
||||
</template>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vc { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.vc__gestures { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.vc__ok, .vc__no {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
||||
padding: 1.1rem 1rem; border-radius: var(--r-card); cursor: pointer;
|
||||
font-size: 1.0625rem; font-weight: 800;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.vc__ok { background: color-mix(in srgb, var(--mood-success) 14%, var(--mood-surface)); color: var(--mood-success); }
|
||||
.vc__no { background: color-mix(in srgb, var(--mood-warning) 13%, var(--mood-surface)); color: var(--mood-warning); }
|
||||
.vc__ok:hover:not(:disabled), .vc__no:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
|
||||
.vc__ok:active, .vc__no:active { transform: translateY(0); }
|
||||
.vc__ok:disabled, .vc__no:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.vc__gesture-icon { font-size: 1.4rem; }
|
||||
.vc__done { font-size: 0.75rem; font-weight: 600; opacity: 0.8; }
|
||||
.vc__argue { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vc__argue textarea { padding: 0.6rem 0.8rem; font-size: 0.9375rem; resize: vertical; }
|
||||
.vc__argue .ld-btn { align-self: flex-end; }
|
||||
.vc__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vc__tally { display: flex; align-items: center; flex-wrap: wrap; gap: 0.9rem; }
|
||||
.vc__count { font-size: 0.9375rem; color: var(--mood-text-muted); }
|
||||
.vc__count strong { color: var(--mood-text); font-size: 1.0625rem; }
|
||||
.vc__objections { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.vc__objection { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.6rem 0.85rem; font-size: 0.9rem; }
|
||||
.vc__objection--open { background: color-mix(in srgb, var(--mood-warning) 9%, var(--mood-bg)); }
|
||||
.vc__objection p { margin: 0.25rem 0 0; }
|
||||
.vc__objection-state {
|
||||
font-size: 0.75rem; font-weight: 700; color: var(--mood-warning);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.5rem;
|
||||
}
|
||||
.vc__objection-state--lifted { color: var(--mood-success); }
|
||||
.vc__objection-who { font-weight: 700; font-size: 0.8125rem; }
|
||||
.vc__resolution { color: var(--mood-text-muted); font-style: italic; }
|
||||
.vc__outcome { margin: 0; font-weight: 600; font-size: 0.9375rem; }
|
||||
@media (max-width: 480px) {
|
||||
.vc__gestures { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user