forked from yvv/decision
- 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>
399 lines
14 KiB
Vue
399 lines
14 KiB
Vue
<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%;
|
|
box-sizing: border-box;
|
|
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>
|