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,397 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* « /decider — Le chemin » : une phrase → un chemin exécutable en un tap.
|
||||
* Le moteur pur recalcule à chaque geste ; tout est expliqué en une phrase,
|
||||
* tout est contournable (alourdir libre, alléger motivé). Chemin nominal :
|
||||
* phrase + Entrée (au Fil) puis LE bouton — 2 gestes.
|
||||
*/
|
||||
import type { ApplyPathEdits } from '~/stores/decisions'
|
||||
import type {
|
||||
ChainKind, Decision, DecisionRoute, Id, ParamSpec, Reversibility,
|
||||
TriageInput, Verdict, Weight,
|
||||
} from '~/types/domain'
|
||||
import { computeConcerned } from '~/engine'
|
||||
import {
|
||||
ADOPTED_STAMP, ADOPTED_TOAST, METHOD_LABELS,
|
||||
CAPTURE_PLACEHOLDER, ROUTE_LABELS, WHY_DISCLOSURE,
|
||||
} from '~/lexicon'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
|
||||
type AltKey = 'parametric' | 'record' | 'binary'
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const decisions = useDecisionsStore()
|
||||
const route = useRoute()
|
||||
|
||||
// ── State of the tunnel ──
|
||||
const title = ref('')
|
||||
const scope = ref<Decision['scope']>({ selfOnly: true, circleIds: [], personIds: [] })
|
||||
const reversibility = ref<Reversibility>('easy')
|
||||
const weight = ref<Weight>('light')
|
||||
const urgent = ref(false)
|
||||
const amendsClauseId = ref<Id | undefined>()
|
||||
const parentId = ref<Id | undefined>()
|
||||
const chainKind = ref<ChainKind | undefined>()
|
||||
const baselineNote = ref('')
|
||||
const resourceNote = ref('')
|
||||
const resourceAmount = ref<number | null>(null)
|
||||
const resourceUnit = ref('heures')
|
||||
const chosenAlt = ref<AltKey | null>(null)
|
||||
const decidedHow = ref('')
|
||||
const overrideNote = ref('')
|
||||
const spec = ref<ParamSpec | undefined>()
|
||||
const reviewChecked = ref(false)
|
||||
const engraveChecked = ref(false)
|
||||
const mandateAttached = ref(false)
|
||||
const error = ref('')
|
||||
const stamping = ref(false)
|
||||
|
||||
// ── Prefill from the query (?titre ?clause ?parent ?chain ?mandat) ──
|
||||
const q = route.query
|
||||
if (typeof q.titre === 'string') title.value = q.titre
|
||||
if (typeof q.clause === 'string') amendsClauseId.value = q.clause
|
||||
if (typeof q.parent === 'string') {
|
||||
const prefill = decisions.reopen(q.parent)
|
||||
if (!('ok' in prefill)) {
|
||||
parentId.value = prefill.parentDecisionId
|
||||
chainKind.value = prefill.chainKind
|
||||
if (title.value.length === 0) title.value = prefill.title
|
||||
scope.value = prefill.scope
|
||||
reversibility.value = prefill.reversibility
|
||||
weight.value = prefill.weight
|
||||
if (prefill.amendsClauseId) amendsClauseId.value = prefill.amendsClauseId
|
||||
}
|
||||
}
|
||||
if (typeof q.chain === 'string') chainKind.value = q.chain as ChainKind
|
||||
if (typeof q.mandat === 'string') {
|
||||
const mandate = col.mandates.find(m => m.id === q.mandat)
|
||||
if (mandate) {
|
||||
scope.value = { selfOnly: false, circleIds: [...mandate.domain.circleIds], personIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── The live path — the pure engine recomputes at every gesture ──
|
||||
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
|
||||
const tags = computed(() => [...title.value.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()))
|
||||
|
||||
const engineInput = computed<TriageInput>(() => ({
|
||||
title: title.value,
|
||||
tags: tags.value,
|
||||
scope: scope.value,
|
||||
reversibility: reversibility.value,
|
||||
weight: weight.value,
|
||||
urgent: urgent.value,
|
||||
...(amendsClauseId.value ? { amendsClauseId: amendsClauseId.value } : {}),
|
||||
}))
|
||||
const chemin = computed<Verdict | null>(() => {
|
||||
const res = decisions.runTriage(engineInput.value)
|
||||
return 'ok' in res ? null : res
|
||||
})
|
||||
|
||||
watch(() => chemin.value?.reviewRequired, v => { reviewChecked.value = v === true }, { immediate: true })
|
||||
watch(() => chemin.value?.engravingSuggested, v => { engraveChecked.value = v === true }, { immediate: true })
|
||||
|
||||
// ── Concernés pré-calculés (plancher) ──
|
||||
const stack = computed(() => {
|
||||
if (scope.value.selfOnly || !col.me) return []
|
||||
const active = col.mandates.filter(m => m.status === 'active')
|
||||
return computeConcerned(scope.value, col.circles, active, col.me.id).flatMap((entry) => {
|
||||
const person = col.people.find(p => p.id === entry.personId)
|
||||
return person ? [{ person, origin: 'computed' as const, reason: entry.reason }] : []
|
||||
})
|
||||
})
|
||||
|
||||
// ── Route effective & asymétrie de dérogation ──
|
||||
const ROUTE_ORDER: Record<DecisionRoute, number> = {
|
||||
record: 0, solo: 0, mandate: 1, transmit: 1, advice: 2, collective: 3,
|
||||
}
|
||||
const effectiveRoute = computed<DecisionRoute>(() => {
|
||||
if (!chemin.value) return 'solo'
|
||||
if (!chosenAlt.value) return chemin.value.route
|
||||
return chosenAlt.value === 'record' ? 'record' : 'collective'
|
||||
})
|
||||
const overridden = computed(() => chemin.value !== null && effectiveRoute.value !== chemin.value.route)
|
||||
const lightening = computed(
|
||||
() => chemin.value !== null && ROUTE_ORDER[effectiveRoute.value] < ROUTE_ORDER[chemin.value.route],
|
||||
)
|
||||
const overrideLabel = computed(() => {
|
||||
if (chosenAlt.value === 'parametric') return METHOD_LABELS.parametric
|
||||
if (chosenAlt.value === 'binary') return METHOD_LABELS.binary
|
||||
if (chosenAlt.value === 'record') return ROUTE_LABELS.record
|
||||
return ''
|
||||
})
|
||||
|
||||
// ── Frise : durée / seuil (si connus) ──
|
||||
const protocol = computed(() => {
|
||||
if (effectiveRoute.value !== 'collective') return undefined
|
||||
let id = chemin.value?.protocolId
|
||||
if (chosenAlt.value === 'parametric') id = col.settings?.protocolByRange.parametric ?? id
|
||||
if (chosenAlt.value === 'binary') id = col.protocols.find(p => p.method === 'binary')?.id ?? id
|
||||
return col.protocols.find(p => p.id === id)
|
||||
})
|
||||
const framingPlanned = computed(
|
||||
() => chemin.value?.framingDays !== undefined && effectiveRoute.value === 'collective',
|
||||
)
|
||||
const durationLabel = computed(() => {
|
||||
const v = chemin.value
|
||||
if (!v || effectiveRoute.value === 'solo' || effectiveRoute.value === 'record') return ''
|
||||
if (framingPlanned.value) return `${v.framingDays} jours de formulation`
|
||||
if (v.windowHours !== undefined && effectiveRoute.value === v.route) return `fenêtre de ${v.windowHours} h`
|
||||
return protocol.value ? `${protocol.value.durationDays} jours de vote` : ''
|
||||
})
|
||||
const thresholdLabel = computed(() => {
|
||||
const p = protocol.value
|
||||
if (!p) return ''
|
||||
if (p.method === 'consent') return 'zéro objection maintenue'
|
||||
return METHOD_LABELS[p.method]
|
||||
})
|
||||
|
||||
// ── Bouton principal ──
|
||||
const mainLabel = computed(() => {
|
||||
switch (effectiveRoute.value) {
|
||||
case 'advice': return 'Demande leur avis'
|
||||
case 'transmit': return 'Transmets'
|
||||
case 'record': return 'Consigne'
|
||||
case 'collective': return framingPlanned.value ? 'Ouvre la formulation' : 'Ouvre le vote'
|
||||
default: return 'Décide'
|
||||
}
|
||||
})
|
||||
const blockReason = computed(() =>
|
||||
lightening.value && overrideNote.value.trim().length === 0
|
||||
? 'Alléger se motive — écris pourquoi, ta note s\'affichera.'
|
||||
: '',
|
||||
)
|
||||
const disabled = computed(
|
||||
() => title.value.trim().length === 0 || blockReason.value !== '' || chemin.value === null,
|
||||
)
|
||||
|
||||
const engagesOpen = computed(
|
||||
() => (weight.value === 'binding' || weight.value === 'structural') && !scope.value.selfOnly,
|
||||
)
|
||||
const baselineSuggested = computed(() => ['advice', 'collective'].includes(effectiveRoute.value))
|
||||
const suggestion = computed(() => chemin.value?.suggestion)
|
||||
const reviewLocked = computed(
|
||||
() => chemin.value?.reviewRequired === true && effectiveRoute.value !== 'solo',
|
||||
)
|
||||
|
||||
// ── Valider : capture → applyPath → fiche (ou Fil si adopté sur-le-champ) ──
|
||||
async function validate() {
|
||||
const v = chemin.value
|
||||
if (!v || disabled.value) return
|
||||
const created = decisions.capture(title.value)
|
||||
if ('ok' in created) { error.value = created.reason; return }
|
||||
if (parentId.value) {
|
||||
created.parentDecisionId = parentId.value
|
||||
created.chainKind = chainKind.value ?? 'revision'
|
||||
}
|
||||
const edits: ApplyPathEdits = {
|
||||
scope: {
|
||||
selfOnly: scope.value.selfOnly,
|
||||
circleIds: [...scope.value.circleIds],
|
||||
personIds: [...scope.value.personIds],
|
||||
},
|
||||
reversibility: reversibility.value,
|
||||
weight: weight.value,
|
||||
urgent: urgent.value,
|
||||
visibility: scope.value.selfOnly ? 'private' : 'scope',
|
||||
}
|
||||
if (effectiveRoute.value !== v.route) edits.route = effectiveRoute.value
|
||||
if (amendsClauseId.value) edits.amendsClauseId = amendsClauseId.value
|
||||
if (baselineSuggested.value && baselineNote.value.trim()) edits.baselineNote = baselineNote.value.trim()
|
||||
if (resourceNote.value.trim().length > 0 || typeof resourceAmount.value === 'number') {
|
||||
edits.resources = {
|
||||
note: resourceNote.value.trim(),
|
||||
...(typeof resourceAmount.value === 'number'
|
||||
? { amount: resourceAmount.value, unit: resourceUnit.value }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
if (chosenAlt.value === 'record' && decidedHow.value.trim()) edits.decidedHow = decidedHow.value.trim()
|
||||
if (chosenAlt.value === 'parametric') {
|
||||
if (spec.value) edits.paramSpec = spec.value
|
||||
const pid = col.settings?.protocolByRange.parametric
|
||||
if (pid) edits.protocolId = pid
|
||||
}
|
||||
if (chosenAlt.value === 'binary') {
|
||||
const pid = col.protocols.find(p => p.method === 'binary')?.id
|
||||
if (pid) edits.protocolId = pid
|
||||
}
|
||||
if (lightening.value) edits.overrideNote = overrideNote.value.trim()
|
||||
if (mandateAttached.value && suggestion.value?.kind === 'claim-mandate') {
|
||||
edits.createsMandate = {
|
||||
title: `Mandat — ${title.value.trim()}`,
|
||||
domainCircleIds: [...scope.value.circleIds],
|
||||
domainTags: [...tags.value],
|
||||
durationDays: 90,
|
||||
reportEveryDays: 30,
|
||||
}
|
||||
}
|
||||
const applied = decisions.applyPath(created, { ...v, reviewRequired: reviewChecked.value }, edits)
|
||||
if ('ok' in applied) { error.value = applied.reason; return }
|
||||
if (engraveChecked.value && applied.status === 'adopted') await decisions.engrave(applied.id)
|
||||
if (applied.status === 'adopted'
|
||||
&& (effectiveRoute.value === 'solo' || effectiveRoute.value === 'record' || v.conservatoryChain)) {
|
||||
stamping.value = true
|
||||
setTimeout(() => navigateTo('/'), 1100)
|
||||
return
|
||||
}
|
||||
await navigateTo(`/decisions/${applied.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="decider">
|
||||
<div v-if="!col.current" class="ld-card decider__none">
|
||||
<p>Aucun collectif actif — ouvre ou crée un collectif d'abord.</p>
|
||||
<NuxtLink to="/creer" class="ld-btn">Créer un collectif</NuxtLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
class="decider__title"
|
||||
:placeholder="CAPTURE_PLACEHOLDER"
|
||||
:aria-label="CAPTURE_PLACEHOLDER"
|
||||
>
|
||||
|
||||
<CheminQ0
|
||||
:title="title"
|
||||
:linked-clause-id="amendsClauseId"
|
||||
@contest="amendsClauseId = $event"
|
||||
/>
|
||||
|
||||
<CheminChips
|
||||
v-model:scope="scope"
|
||||
v-model:reversibility="reversibility"
|
||||
v-model:weight="weight"
|
||||
v-model:urgent="urgent"
|
||||
v-model:resource-note="resourceNote"
|
||||
v-model:resource-amount="resourceAmount"
|
||||
v-model:resource-unit="resourceUnit"
|
||||
v-model:baseline-note="baselineNote"
|
||||
:stack="stack"
|
||||
:engages-open="engagesOpen"
|
||||
:baseline-suggested="baselineSuggested"
|
||||
/>
|
||||
|
||||
<template v-if="chemin">
|
||||
<CheminPathCard
|
||||
:path="chemin"
|
||||
:route-shown="effectiveRoute"
|
||||
:overridden="overridden"
|
||||
:override-label="overrideLabel"
|
||||
:stack="stack"
|
||||
:self-only="scope.selfOnly"
|
||||
:duration-label="durationLabel"
|
||||
:threshold-label="thresholdLabel"
|
||||
:main-label="mainLabel"
|
||||
v-model:review-checked="reviewChecked"
|
||||
v-model:engrave-checked="engraveChecked"
|
||||
:review-locked="reviewLocked"
|
||||
:disabled="disabled"
|
||||
:block-reason="blockReason"
|
||||
@validate="validate"
|
||||
/>
|
||||
<p v-if="error" class="decider__error">{{ error }}</p>
|
||||
|
||||
<CheminAlternatives
|
||||
v-model:chosen="chosenAlt"
|
||||
v-model:decided-how="decidedHow"
|
||||
v-model:override-note="overrideNote"
|
||||
v-model:spec="spec"
|
||||
:alternatives="chemin.alternatives"
|
||||
:parametric-hint="chemin.parametricHint === true"
|
||||
:lightening="lightening"
|
||||
/>
|
||||
|
||||
<details class="decider__why">
|
||||
<summary>{{ WHY_DISCLOSURE }}</summary>
|
||||
<p>Règle {{ chemin.rule }} — {{ chemin.explanation }}</p>
|
||||
<p>
|
||||
Le chemin est recommandé par le Pacte de ton collectif, jamais imposé :
|
||||
alourdir est libre en un tap, alléger se motive et s'affiche à tous.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<CheminSuggestion
|
||||
v-if="suggestion"
|
||||
v-model:mandate-attached="mandateAttached"
|
||||
:suggestion="suggestion"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Micro-célébration — tampon bref puis retour au Fil -->
|
||||
<Transition name="stamp">
|
||||
<div v-if="stamping" class="decider__overlay">
|
||||
<span class="ld-stamp decider__stamp">井 {{ ADOPTED_STAMP }}</span>
|
||||
<p class="decider__toast">{{ ADOPTED_TOAST }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.decider {
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.decider__none {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.decider__title {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: clamp(1.125rem, 3.5vw, 1.375rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.decider__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.decider__why {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.decider__why summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--r-input);
|
||||
width: fit-content;
|
||||
}
|
||||
.decider__why summary:hover { color: var(--mood-text); }
|
||||
.decider__why p { margin: 0.5rem 0 0 0.25rem; line-height: 1.5; }
|
||||
.decider__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: color-mix(in srgb, var(--mood-bg) 88%, transparent);
|
||||
}
|
||||
.decider__stamp { font-size: 1.5rem; padding: 0.75rem 1.5rem; }
|
||||
.decider__toast { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.stamp-enter-active { transition: opacity 0.12s ease; }
|
||||
.stamp-enter-from { opacity: 0; }
|
||||
.stamp-enter-active .decider__stamp {
|
||||
animation: stamp-in 0.12s ease;
|
||||
}
|
||||
@keyframes stamp-in {
|
||||
from { transform: rotate(-10deg) scale(1.4); opacity: 0; }
|
||||
to { transform: rotate(-10deg) scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user