Files
decision/frontend/app/pages/decider.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

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>