forked from yvv/decision
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,353 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Perimeter panel: first-place concerned (computed, full dot,
|
||||
// tap → inclusion reason), second-place (declared, raised hand, public note),
|
||||
// « Ça me concerne », arrested-list countdown, NON-IGNORABLE influx banner
|
||||
// (widen one notch / keep with a public note), boundary objection.
|
||||
import type { Concern, Decision, Id, Person } from '~/types/domain'
|
||||
import {
|
||||
CONCERN_FIRST, CONCERN_ME, CONCERN_SECOND, PERIMETER_OVERFLOW,
|
||||
SCOPE_KEEP, SCOPE_WIDEN,
|
||||
} from '~/lexicon'
|
||||
import { computeConcerned } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
|
||||
const props = defineProps<{ decision: Decision }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const concerns = computed(() =>
|
||||
col.concerns.filter(c => c.decisionId === props.decision.id))
|
||||
|
||||
function entriesOf(origin: Concern['origin']) {
|
||||
return concerns.value
|
||||
.filter(c => c.origin === origin)
|
||||
.map((c) => {
|
||||
const person = col.people.find(p => p.id === c.personId)
|
||||
return person ? { person, origin, reason: c.reason, concern: c } : null
|
||||
})
|
||||
.filter((e): e is { person: Person; origin: Concern['origin']; reason: string; concern: Concern } => e !== null)
|
||||
}
|
||||
|
||||
const firstPlace = computed(() => entriesOf('computed'))
|
||||
const secondPlace = computed(() => entriesOf('declared'))
|
||||
|
||||
// ── Popover : raison d'inclusion en un tap ──
|
||||
const selected = ref<{ name: string; title: string; reason: string; note?: string; consultative: boolean } | null>(null)
|
||||
|
||||
function onTap(entry: { person: Person; origin?: string; reason?: string }) {
|
||||
const concern = concerns.value.find(c => c.personId === entry.person.id)
|
||||
const parts: string[] = []
|
||||
if (concern?.reason) parts.push(concern.reason)
|
||||
selected.value = {
|
||||
name: entry.person.displayName,
|
||||
title: concern?.origin === 'declared' ? CONCERN_SECOND : CONCERN_FIRST,
|
||||
reason: parts.join(' — '),
|
||||
...(concern?.declaredNote ? { note: concern.declaredNote } : {}),
|
||||
consultative: concern?.origin === 'declared' && concern.beforeSnapshot === false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── « Ça me concerne » ──
|
||||
const meId = computed(() => col.me?.id ?? null)
|
||||
const alreadyConcerned = computed(() =>
|
||||
meId.value !== null
|
||||
&& (props.decision.authorId === meId.value
|
||||
|| concerns.value.some(c => c.personId === meId.value)))
|
||||
|
||||
function declareMe() {
|
||||
if (meId.value) store.declareConcern(props.decision.id, meId.value)
|
||||
}
|
||||
|
||||
// ── Compte à rebours de la liste arrêtée (session à venir) ──
|
||||
const arrestAt = computed(() =>
|
||||
props.decision.status === 'framing' ? props.decision.windowEndsAt : undefined)
|
||||
|
||||
// ── Bannière d'affluence — non-ignorable ──
|
||||
const ACTIVE = ['draft', 'advice', 'objection', 'framing', 'voting']
|
||||
const overflow = computed(() => {
|
||||
if (!ACTIVE.includes(props.decision.status) || props.decision.scopeKeptNote) return false
|
||||
const computedCount = firstPlace.value.length
|
||||
const declaredCount = secondPlace.value.length
|
||||
const ratio = col.settings?.triage.concernEscalateRatio ?? 0.5
|
||||
return computedCount > 0 && declaredCount >= ratio * computedCount
|
||||
})
|
||||
|
||||
function widen() {
|
||||
const d = props.decision
|
||||
if (!col.current) return
|
||||
const next = new Set<Id>()
|
||||
for (const id of d.scope.circleIds) {
|
||||
const circle = col.circles.find(c => c.id === id)
|
||||
next.add(circle?.parentCircleId ?? id)
|
||||
}
|
||||
if (next.size === 0) next.add(col.current.collective.rootCircleId)
|
||||
d.scope.circleIds = [...next]
|
||||
// Re-persist the widened computation — every inclusion keeps its reason.
|
||||
const activeMandates = col.mandates.filter(m => m.status === 'active')
|
||||
const known = new Set(concerns.value.map(c => c.personId))
|
||||
const now = col.now()
|
||||
for (const entry of computeConcerned(d.scope, col.circles, activeMandates, d.authorId)) {
|
||||
if (known.has(entry.personId)) continue
|
||||
col.current.concerns.push({
|
||||
id: col.newId(),
|
||||
collectiveId: d.collectiveId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
decisionId: d.id,
|
||||
personId: entry.personId,
|
||||
origin: 'computed',
|
||||
reason: entry.reason,
|
||||
beforeSnapshot: !col.sessions.some(s => s.decisionId === d.id),
|
||||
})
|
||||
}
|
||||
col.stamp(d)
|
||||
col.persist()
|
||||
}
|
||||
|
||||
const keepOpen = ref(false)
|
||||
const keepNote = ref('')
|
||||
function keepScope() {
|
||||
const note = keepNote.value.trim()
|
||||
if (note.length === 0) return
|
||||
props.decision.scopeKeptNote = note
|
||||
col.stamp(props.decision)
|
||||
col.persist()
|
||||
keepOpen.value = false
|
||||
}
|
||||
|
||||
// ── Objection de frontière ──
|
||||
const boundaryOpen = ref(false)
|
||||
const boundaryArg = ref('')
|
||||
const boundaryError = ref('')
|
||||
function sendBoundary() {
|
||||
const result = store.objectTo(props.decision.id, 'boundary', boundaryArg.value)
|
||||
if ('ok' in result) { boundaryError.value = result.reason; return }
|
||||
boundaryOpen.value = false
|
||||
boundaryArg.value = ''
|
||||
boundaryError.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="peri">
|
||||
<h2 class="peri__title">Périmètre</h2>
|
||||
|
||||
<div v-if="overflow" class="peri__overflow no-print">
|
||||
<p class="peri__overflow-text">
|
||||
<UIcon name="i-lucide-waves" />
|
||||
<span>{{ PERIMETER_OVERFLOW }}</span>
|
||||
</p>
|
||||
<div class="peri__overflow-actions">
|
||||
<button type="button" class="ld-btn" @click="widen()">{{ SCOPE_WIDEN }}</button>
|
||||
<button type="button" class="ld-btn ld-btn--ghost" @click="keepOpen = !keepOpen">
|
||||
{{ SCOPE_KEEP }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="keepOpen" class="peri__keep">
|
||||
<textarea
|
||||
v-model="keepNote"
|
||||
rows="2"
|
||||
placeholder="Pourquoi le périmètre reste-t-il ainsi ? Cette note sera publique."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ld-btn"
|
||||
:disabled="keepNote.trim().length === 0"
|
||||
@click="keepScope()"
|
||||
>
|
||||
Publier la note et maintenir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="peri__group">
|
||||
<p class="peri__label">{{ CONCERN_FIRST }}</p>
|
||||
<LdAvatarStack
|
||||
v-if="firstPlace.length > 0"
|
||||
:people="firstPlace"
|
||||
:max="8"
|
||||
@tap="onTap"
|
||||
/>
|
||||
<p v-else class="peri__none">Personne d'autre — décision sur soi.</p>
|
||||
</div>
|
||||
|
||||
<div class="peri__group">
|
||||
<p class="peri__label">{{ CONCERN_SECOND }}</p>
|
||||
<LdAvatarStack
|
||||
v-if="secondPlace.length > 0"
|
||||
:people="secondPlace"
|
||||
:max="8"
|
||||
@tap="onTap"
|
||||
/>
|
||||
<p v-else class="peri__none">Personne ne s'est déclaré·e pour l'instant.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="peri__popover" @click="selected = null">
|
||||
<p class="peri__popover-name">{{ selected.name }}</p>
|
||||
<p class="peri__popover-origin">{{ selected.title }}</p>
|
||||
<p v-if="selected.reason" class="peri__popover-reason">{{ selected.reason }}</p>
|
||||
<p v-if="selected.note" class="peri__popover-note">« {{ selected.note }} »</p>
|
||||
<p v-if="selected.consultative" class="peri__popover-consult">
|
||||
Déclaré·e après l'arrêt de la liste — voix consultative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="arrestAt" class="peri__arrest">
|
||||
<LdCountdown :ends-at="arrestAt" />
|
||||
<p class="peri__arrest-rule">
|
||||
Déclaré·e avant l'arrêt de la liste : tu votes. Après : voix consultative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="peri__actions no-print">
|
||||
<button
|
||||
v-if="!alreadyConcerned"
|
||||
type="button"
|
||||
class="ld-btn ld-btn--ghost"
|
||||
@click="declareMe()"
|
||||
>
|
||||
<UIcon name="i-lucide-hand" />
|
||||
<span>{{ CONCERN_ME }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ld-btn ld-btn--quiet"
|
||||
@click="boundaryOpen = !boundaryOpen"
|
||||
>
|
||||
<UIcon name="i-lucide-user-plus" />
|
||||
<span>J'ai été oublié·e / il manque quelqu'un</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="boundaryOpen" class="peri__keep no-print">
|
||||
<textarea
|
||||
v-model="boundaryArg"
|
||||
rows="2"
|
||||
placeholder="Qui manque, et pourquoi cette personne est concernée ?"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ld-btn"
|
||||
:disabled="boundaryArg.trim().length === 0"
|
||||
@click="sendBoundary()"
|
||||
>
|
||||
Contester la frontière
|
||||
</button>
|
||||
<p v-if="boundaryError" class="peri__error">{{ boundaryError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.peri {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.peri__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.peri__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.peri__label {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.peri__none {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.peri__popover {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--mood-accent-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.peri__popover-name { margin: 0; font-weight: 800; font-size: 0.9375rem; }
|
||||
.peri__popover-origin {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
.peri__popover-reason { margin: 0.25rem 0 0; font-size: 0.875rem; }
|
||||
.peri__popover-note {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.peri__popover-consult {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-fenetre);
|
||||
}
|
||||
.peri__arrest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.peri__arrest-rule {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.peri__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.peri__overflow {
|
||||
padding: 1rem 1.125rem;
|
||||
border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-status-fenetre) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 2px var(--mood-status-fenetre);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.peri__overflow-text {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-status-fenetre);
|
||||
}
|
||||
.peri__overflow-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.peri__keep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.peri__keep textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.peri__error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-error);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user