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:
Yvv
2026-08-11 13:47:20 +02:00
co-authored by Claude Fable 5
parent d886302b59
commit e164b5f6c1
96 changed files with 14097 additions and 11777 deletions
@@ -1,123 +0,0 @@
<script setup lang="ts">
/**
* Cadrage form component for creating or editing a decision.
*
* Provides all fields needed for the initial decision setup:
* title, description, context, decision type, and voting protocol.
*/
import type { DecisionCreate } from '~/stores/decisions'
const props = defineProps<{
modelValue: DecisionCreate
submitting?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DecisionCreate]
'submit': []
}>()
const decisionTypeOptions = [
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
{ label: 'Modification de document', value: 'document_change' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
]
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
const isValid = computed(() => {
return props.modelValue.title?.trim() && props.modelValue.decision_type
})
function onSubmit() {
if (isValid.value) {
emit('submit')
}
}
</script>
<template>
<form class="space-y-6" @submit.prevent="onSubmit">
<!-- Titre -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
</label>
<UInput
:model-value="modelValue.title"
placeholder="Titre de la decision..."
required
@update:model-value="updateField('title', $event as string)"
/>
</div>
<!-- Description -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-red-500">*</span>
</label>
<UTextarea
:model-value="modelValue.description ?? ''"
placeholder="Decrivez l'objet de cette decision..."
:rows="4"
@update:model-value="updateField('description', $event as string)"
/>
</div>
<!-- Contexte -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Contexte
</label>
<UTextarea
:model-value="modelValue.context ?? ''"
placeholder="Contexte, motivations, liens utiles..."
:rows="3"
@update:model-value="updateField('context', $event as string)"
/>
</div>
<!-- Type de decision -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de decision <span class="text-red-500">*</span>
</label>
<USelect
:model-value="modelValue.decision_type"
:items="decisionTypeOptions"
placeholder="Selectionnez un type..."
@update:model-value="updateField('decision_type', $event as string)"
/>
</div>
<!-- Protocole de vote -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Protocole de vote
</label>
<ProtocolPicker
:model-value="modelValue.voting_protocol_id ?? null"
@update:model-value="updateField('voting_protocol_id', $event)"
/>
<p class="text-xs text-gray-500">
Optionnel. Peut etre defini ulterieurement pour chaque etape.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
type="submit"
label="Creer la decision"
icon="i-lucide-plus"
color="primary"
:loading="submitting"
:disabled="!isValid"
/>
</div>
</form>
</template>
@@ -1,71 +0,0 @@
<script setup lang="ts">
/**
* Card component for displaying a decision in a list.
*
* Shows title, type badge, status badge, step count, and creation date.
* Navigates to the decision detail page on click.
*/
import type { Decision } from '~/stores/decisions'
const props = defineProps<{
decision: Decision
}>()
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/decisions/${props.decision.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
@click="navigate"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ decision.title }}
</h3>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ decision.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(decision.decision_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ decision.steps.length }} etape(s)
</span>
<span class="text-xs text-gray-500">
{{ formatDate(decision.created_at) }}
</span>
</div>
</div>
</UCard>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
// <!-- ld-v2 --> Chain block: clickable parent and children with their chain
// kind in plain French (ratification / révision / révocation / élément).
import type { Decision } from '~/types/domain'
import { STATUS_LABELS } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { CHAIN_LABELS } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const parent = computed(() =>
props.decision.parentDecisionId
? col.decisions.find(d => d.id === props.decision.parentDecisionId)
: undefined)
const parentLabel = computed(() =>
props.decision.chainKind
? `${CHAIN_LABELS[props.decision.chainKind]} de`
: 'liée à')
const children = computed(() =>
col.decisions
.filter(d => d.parentDecisionId === props.decision.id)
.map(d => ({
...d,
kindLabel: d.chainKind ? CHAIN_LABELS[d.chainKind] : 'liée',
})))
const hasChain = computed(() => parent.value !== undefined || children.value.length > 0)
</script>
<template>
<!-- ld-v2 -->
<section v-if="hasChain" class="chain">
<h2 class="chain__title">Chaînage</h2>
<NuxtLink
v-if="parent"
:to="`/decisions/${parent.id}`"
class="chain__link"
>
<UIcon name="i-lucide-corner-left-up" />
<span class="chain__kind">{{ parentLabel }}</span>
<span class="chain__name">{{ parent.title }}</span>
<span class="status-pill" :class="`status-${parent.status}`">
{{ STATUS_LABELS[parent.status] }}
</span>
</NuxtLink>
<NuxtLink
v-for="child in children"
:key="child.id"
:to="`/decisions/${child.id}`"
class="chain__link"
>
<UIcon name="i-lucide-corner-down-right" />
<span class="chain__kind">{{ child.kindLabel }}</span>
<span class="chain__name">{{ child.title }}</span>
<span class="status-pill" :class="`status-${child.status}`">
{{ STATUS_LABELS[child.status] }}
</span>
</NuxtLink>
</section>
</template>
<style scoped>
.chain { display: flex; flex-direction: column; gap: 0.5rem; }
.chain__title {
margin: 0 0 0.25rem;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.chain__link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
color: var(--mood-text);
text-decoration: none;
transition: transform 0.1s ease;
}
.chain__link:hover { transform: translateY(-1px); }
.chain__kind {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
text-transform: none;
}
.chain__name {
font-size: 0.9375rem;
font-weight: 600;
flex: 1;
min-width: 10rem;
}
</style>
@@ -0,0 +1,288 @@
<script setup lang="ts">
// <!-- ld-v2 --> ÉLÉMENTS tab of a split dossier: element children with states
// and deadlines, 0-3 stake weighting by the concerned (Concern.priority — feeds
// the closing cartography, never the voting right), auto-generated closing
// cartography, and the steward's « dossier complet » closing gesture.
import type { Decision } from '~/types/domain'
import { DOSSIER_COMPLETE_CARD, STATUS_LABELS } from '~/lexicon'
import { TERMINAL_STATUSES } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { dateFr } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const elements = computed(() =>
col.decisions.filter(d =>
d.parentDecisionId === props.decision.id && d.chainKind === 'element'))
const meId = computed(() => col.me?.id ?? null)
function priorityStats(elementId: string) {
const priorities = col.concerns
.filter(c => c.decisionId === elementId && c.priority !== undefined)
.map(c => c.priority as number)
if (priorities.length === 0) return { avg: null as number | null, count: 0 }
const avg = priorities.reduce((a, b) => a + b, 0) / priorities.length
return { avg: Math.round(avg * 10) / 10, count: priorities.length }
}
function myPriority(elementId: string): number | undefined {
if (!meId.value) return undefined
return col.concerns.find(
c => c.decisionId === elementId && c.personId === meId.value,
)?.priority
}
/** « Pondère tes enjeux » — set my priority on this element (0-3). */
function setPriority(elementId: string, priority: 0 | 1 | 2 | 3) {
if (!meId.value || !col.current) return
let mine = col.concerns.find(
c => c.decisionId === elementId && c.personId === meId.value,
)
if (!mine) {
const created = store.declareConcern(elementId, meId.value)
if ('ok' in created) return
mine = created
}
mine.priority = priority
col.stamp(mine)
col.persist()
}
const allTerminal = computed(() =>
elements.value.length > 0
&& elements.value.every(e => TERMINAL_STATUSES.includes(e.status)))
const isSteward = computed(() => {
if (!meId.value) return false
if (props.decision.stewardIds.length > 0) {
return props.decision.stewardIds.includes(meId.value)
}
return props.decision.authorId === meId.value
})
const closeError = ref('')
function closeDossier() {
const result = store.transition(props.decision.id, 'closed')
closeError.value = result.ok ? '' : result.reason
}
const PRIORITIES = [0, 1, 2, 3] as const
</script>
<template>
<!-- ld-v2 -->
<section class="els">
<h2 class="els__title">Éléments du dossier</h2>
<p class="els__hint">
Chaque élément suit son propre chemin pondère tes enjeux de 0 à 3 :
la pondération nourrit la cartographie, jamais le droit de vote.
</p>
<ul class="els__list">
<li v-for="el in elements" :key="el.id" class="els__item">
<div class="els__item-main">
<NuxtLink :to="`/decisions/${el.id}`" class="els__item-title">
{{ el.title }}
</NuxtLink>
<span class="status-pill" :class="`status-${el.status}`">
{{ STATUS_LABELS[el.status] }}
</span>
<LdCountdown
v-if="el.windowEndsAt"
:ends-at="el.windowEndsAt"
:suspended-at="el.windowSuspendedAt"
/>
</div>
<div class="els__weighting no-print">
<span class="els__weighting-label">Mes enjeux</span>
<button
v-for="p in PRIORITIES"
:key="p"
type="button"
class="els__prio"
:class="{ 'els__prio--on': myPriority(el.id) === p }"
@click="setPriority(el.id, p)"
>
{{ p }}
</button>
</div>
</li>
</ul>
<!-- Cartographie de clôture auto-générée -->
<div class="els__map">
<h3 class="els__map-title">Cartographie de clôture</h3>
<table class="els__table">
<thead>
<tr>
<th>Élément</th>
<th>État</th>
<th>Enjeu moyen</th>
<th>Échéance</th>
</tr>
</thead>
<tbody>
<tr v-for="el in elements" :key="el.id">
<td>{{ el.title }}</td>
<td>
<span class="status-pill" :class="`status-${el.status}`">
{{ STATUS_LABELS[el.status] }}
</span>
</td>
<td>
<template v-if="priorityStats(el.id).avg !== null">
{{ priorityStats(el.id).avg }} / 3
<span class="els__map-count">({{ priorityStats(el.id).count }})</span>
</template>
<template v-else></template>
</td>
<td>{{ el.windowEndsAt ? dateFr(el.windowEndsAt) : (el.decidedAt ? dateFr(el.decidedAt) : '—') }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Dossier complet le geste du garant, jamais automatique -->
<div v-if="allTerminal" class="els__complete">
<p class="els__complete-text">
<UIcon name="i-lucide-flag" />
<span>{{ DOSSIER_COMPLETE_CARD }}</span>
</p>
<button
v-if="isSteward"
type="button"
class="ld-btn no-print"
@click="closeDossier()"
>
<UIcon name="i-lucide-stamp" />
<span>Clore le dossier</span>
</button>
<p v-else class="els__hint">Le geste appartient au garant du dossier.</p>
<p v-if="closeError" class="els__error">{{ closeError }}</p>
</div>
</section>
</template>
<style scoped>
.els { display: flex; flex-direction: column; gap: 0.875rem; }
.els__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.els__hint {
margin: 0;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.els__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.els__item {
padding: 0.75rem 0.875rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.els__item-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.els__item-title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
text-decoration: none;
flex: 1;
min-width: 10rem;
}
.els__item-title:hover { color: var(--mood-accent); }
.els__weighting {
display: flex;
align-items: center;
gap: 0.375rem;
}
.els__weighting-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
margin-right: 0.25rem;
}
.els__prio {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: var(--mood-surface);
font-weight: 800;
font-size: 0.9375rem;
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.12s ease;
box-shadow: var(--shadow-card);
}
.els__prio:hover { transform: translateY(-1px); }
.els__prio--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.els__map-title {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 800;
}
.els__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.els__table th {
text-align: left;
font-size: 0.8125rem;
color: var(--mood-text-muted);
padding: 0.375rem 0.5rem;
}
.els__table td {
padding: 0.375rem 0.5rem;
background: var(--mood-accent-soft);
}
.els__table tbody tr td:first-child { border-radius: var(--r-input) 0 0 var(--r-input); }
.els__table tbody tr td:last-child { border-radius: 0 var(--r-input) var(--r-input) 0; }
.els__map-count { color: var(--mood-text-muted); font-size: 0.8125rem; }
.els__complete {
padding: 1rem 1.125rem;
border-radius: var(--r-input);
background: var(--mood-status-vigueur-bg);
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.els__complete-text {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 800;
color: var(--mood-status-vigueur);
}
.els__error {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error);
}
</style>
@@ -0,0 +1,286 @@
<script setup lang="ts">
// <!-- ld-v2 --> « S'instruire » block (framing/voting): compact summary always
// visible (title, sought effects, « Ce que ça engage ») + internal disclosures
// (body, 2-column versions with folded author, deposited advices/objections,
// provenance). Auto-collapsed on later visits (local preference per decision).
import type { Decision } from '~/types/domain'
import { ENGAGES_LABEL, INSTRUCT_BLOCK } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { ADVICE_LABELS } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
// ── Repli automatique aux visites suivantes ──
const storageKey = `ld2-instruct-${props.decision.id}`
const collapsed = ref(false)
onMounted(() => {
collapsed.value = localStorage.getItem(storageKey) === '1'
localStorage.setItem(storageKey, '1')
})
const effects = computed(() => props.decision.brief?.effects ?? [])
const resources = computed(() => props.decision.resources)
const advices = computed(() =>
col.advices
.filter(a => a.decisionId === props.decision.id)
.map(a => ({
...a,
who: col.people.find(p => p.id === a.personId)?.displayName ?? '—',
label: ADVICE_LABELS[a.position],
})))
const objections = computed(() =>
col.objections
.filter(o => o.decisionId === props.decision.id)
.map(o => ({
...o,
who: col.people.find(p => p.id === o.personId)?.displayName ?? '—',
})))
// ── Versions (clause visée) : diff simple 2 colonnes, auteur replié ──
const clause = computed(() =>
props.decision.amendsClauseId
? col.clauses.find(c => c.id === props.decision.amendsClauseId)
: undefined)
const currentVersion = computed(() =>
clause.value
? col.versions.find(v => v.clauseId === clause.value!.id && v.status === 'current')
: undefined)
const proposedVersions = computed(() =>
clause.value
? col.versions
.filter(v => v.clauseId === clause.value!.id && v.status === 'proposed')
.map((v) => {
const origin = col.decisions.find(d => d.id === v.decisionId)
const author = origin
? col.people.find(p => p.id === origin.authorId)?.displayName
: undefined
return { ...v, author: author ?? '—' }
})
: [])
// ── Provenance (voteRecord du document de la clause visée) ──
const record = computed(() => {
if (!clause.value) return undefined
const doc = col.docs.find(d => d.id === clause.value!.docId)
return doc?.provenance?.voteRecord
})
const recordLine = computed(() => {
const r = record.value
if (!r) return ''
const res = r.result
return `${res.for} oui · ${res.against} non`
+ `${res.invalid !== undefined ? ` · ${res.invalid} nuls` : ''}`
+ ` — seuil ${res.thresholdRequired}, ${res.wotSize} inscrits (${r.period})`
})
const hasDetails = computed(() =>
!!props.decision.body || proposedVersions.value.length > 0
|| advices.value.length > 0 || objections.value.length > 0 || !!record.value)
</script>
<template>
<!-- ld-v2 -->
<section class="ins">
<button type="button" class="ins__head no-print" @click="collapsed = !collapsed">
<UIcon name="i-lucide-book-open" />
<span class="ins__head-title">{{ INSTRUCT_BLOCK }}</span>
<UIcon :name="collapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" />
</button>
<div v-show="!collapsed" class="ins__body">
<!-- Résumé compact toujours visible quand le bloc est ouvert -->
<p class="ins__title">{{ decision.title }}</p>
<div v-if="effects.length > 0" class="ins__effects">
<p class="ins__label">Effets recherchés</p>
<ul class="ins__effect-list">
<li v-for="(effect, i) in effects" :key="i">
<span>{{ effect.label }}</span>
<span v-if="effect.target" class="ins__target">cible : {{ effect.target }}</span>
</li>
</ul>
</div>
<div v-if="resources" class="ins__engages">
<p class="ins__label">{{ ENGAGES_LABEL }}</p>
<p class="ins__engages-note">
{{ resources.note }}
<strong v-if="resources.amount !== undefined">
{{ resources.amount.toLocaleString('fr-FR') }} {{ resources.unit ?? '' }}
</strong>
</p>
</div>
<!-- Dépliables internes -->
<template v-if="hasDetails">
<details v-if="decision.body" class="ins__fold">
<summary>Le texte</summary>
<p class="ins__prose">{{ decision.body }}</p>
</details>
<details v-if="proposedVersions.length > 0" class="ins__fold">
<summary>Versions proposées</summary>
<div v-for="v in proposedVersions" :key="v.id" class="ins__diff">
<div class="ins__diff-col">
<p class="ins__diff-head">Aujourd'hui</p>
<p class="ins__prose">{{ currentVersion?.content ?? ' (nouvelle clause)' }}</p>
</div>
<div class="ins__diff-col ins__diff-col--proposed">
<p class="ins__diff-head">Proposé ({{ v.versionLabel }})</p>
<p class="ins__prose">{{ v.content }}</p>
<details class="ins__author">
<summary>Qui propose ?</summary>
<p>{{ v.author }}</p>
</details>
</div>
</div>
</details>
<details v-if="advices.length > 0" class="ins__fold">
<summary>Avis déposés ({{ advices.length }})</summary>
<ul class="ins__voice-list">
<li v-for="a in advices" :key="a.id">
<strong>{{ a.who }}</strong> — {{ a.label }}
<span v-if="a.note" class="ins__voice-note">« {{ a.note }} »</span>
</li>
</ul>
</details>
<details v-if="objections.length > 0" class="ins__fold">
<summary>Objections ({{ objections.length }})</summary>
<ul class="ins__voice-list">
<li v-for="o in objections" :key="o.id">
<strong>{{ o.who }}</strong>
<span class="ins__obj-status">({{ o.status === 'open' ? 'ouverte' : o.status === 'withdrawn' ? 'retirée' : o.status === 'integrated' ? 'intégrée' : 'escaladée' }})</span>
— « {{ o.argument }} »
</li>
</ul>
</details>
<details v-if="record" class="ins__fold">
<summary>Provenance</summary>
<p class="ins__prose">{{ recordLine }}</p>
<p v-if="record.url" class="ins__prose">
<a :href="record.url" target="_blank" rel="noopener">source du vote</a>
— {{ record.modeParams }}
</p>
</details>
</template>
</div>
<p v-show="collapsed" class="ins__collapsed-hint">
{{ decision.title }} — déplié à ta première visite, replié depuis.
{{ effects.length > 0 ? `${effects.length} effet${effects.length > 1 ? 's' : ''} recherché${effects.length > 1 ? 's' : ''}.` : '' }}
</p>
</section>
</template>
<style scoped>
.ins { display: flex; flex-direction: column; gap: 0.75rem; }
.ins__head {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: var(--r-input);
padding: 0.625rem 0.875rem;
font-weight: 800;
font-size: 0.9375rem;
cursor: pointer;
width: 100%;
text-align: left;
}
.ins__head-title { flex: 1; }
.ins__body { display: flex; flex-direction: column; gap: 0.75rem; }
.ins__title { margin: 0; font-size: 1rem; font-weight: 700; }
.ins__label {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.ins__effect-list {
margin: 0;
padding-left: 1.125rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9375rem;
}
.ins__target {
margin-left: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-status-vote);
background: var(--mood-status-vote-bg);
padding: 2px 8px;
border-radius: var(--r-pill);
}
.ins__engages-note { margin: 0; font-size: 0.9375rem; }
.ins__fold summary {
cursor: pointer;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent);
padding: 0.25rem 0;
user-select: none;
}
.ins__prose {
margin: 0.375rem 0 0;
font-size: 0.9375rem;
line-height: 1.55;
white-space: pre-wrap;
}
.ins__diff {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
margin-top: 0.5rem;
}
@media (min-width: 768px) {
.ins__diff { grid-template-columns: 1fr 1fr; }
}
.ins__diff-col {
padding: 0.75rem 0.875rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
}
.ins__diff-col--proposed {
background: var(--mood-status-vote-bg);
}
.ins__diff-head {
margin: 0;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-text-muted);
}
.ins__author { margin-top: 0.5rem; }
.ins__author summary {
font-size: 0.8125rem;
color: var(--mood-text-muted);
cursor: pointer;
}
.ins__author p { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 700; }
.ins__voice-list {
margin: 0.375rem 0 0;
padding-left: 1.125rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.9375rem;
}
.ins__voice-note { font-style: italic; color: var(--mood-text-muted); }
.ins__obj-status { font-size: 0.8125rem; color: var(--mood-text-muted); }
.ins__collapsed-hint {
margin: 0;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
</style>
@@ -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>
@@ -0,0 +1,105 @@
<script setup lang="ts">
// <!-- ld-v2 --> Sheet actions + proof card: « Imprimer » (A4 PV — the print
// stylesheet lives in moods.css), « Graver » → truncated copyable sha256 mono
// fingerprint + « empreinte locale — démo ».
import type { Decision } from '~/types/domain'
import { PROOF_LOCAL } from '~/lexicon'
import { useDecisionsStore } from '~/stores/decisions'
import { dateFr } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const store = useDecisionsStore()
const engraving = computed(() => props.decision.engraving)
const shortHash = computed(() =>
engraving.value ? `${engraving.value.sha256.slice(0, 20)}` : '')
const busy = ref(false)
async function engraveNow() {
busy.value = true
try { await store.engrave(props.decision.id) }
finally { busy.value = false }
}
const copied = ref(false)
async function copyHash() {
if (!engraving.value) return
await navigator.clipboard.writeText(engraving.value.sha256)
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
}
function printSheet() { window.print() }
</script>
<template>
<!-- ld-v2 -->
<div class="proof">
<div class="proof__actions no-print">
<button type="button" class="ld-btn ld-btn--ghost" @click="printSheet()">
<UIcon name="i-lucide-printer" />
<span>Imprimer</span>
</button>
<button
v-if="!engraving"
type="button"
class="ld-btn ld-btn--ghost"
:disabled="busy"
@click="engraveNow()"
>
<span> Graver</span>
</button>
</div>
<div v-if="engraving" class="ld-card proof__card">
<p class="proof__title">Fiche de preuve</p>
<button
type="button"
class="proof__hash no-print"
title="Copier l'empreinte complète"
@click="copyHash()"
>
<code>{{ shortHash }}</code>
<UIcon :name="copied ? 'i-lucide-check' : 'i-lucide-copy'" />
</button>
<code class="print-only proof__hash-full">{{ engraving.sha256 }}</code>
<p class="proof__meta">
{{ PROOF_LOCAL }} gravée le {{ dateFr(engraving.engravedAt) }}
</p>
</div>
</div>
</template>
<style scoped>
.proof { display: flex; flex-direction: column; gap: 1rem; }
.proof__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.proof__card {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proof__title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
.proof__hash {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: fit-content;
padding: 0.375rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
cursor: pointer;
color: var(--mood-text);
}
.proof__hash code,
.proof__hash-full {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 0.8125rem;
}
.proof__meta {
margin: 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,154 @@
<script setup lang="ts">
// <!-- ld-v2 --> Registry card: title, tinted route icon, weight, deadline,
// mini vote gauge, 井 badge when engraved, urgency badge. Links to the fiche.
import type { Decision } from '~/types/domain'
import { ROUTE_ICONS, ROUTE_SHORT, URGENT_BADGE } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { WEIGHT_SHORT, displayStatus, displayStatusLabel } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === props.decision.id)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0],
)
const shown = computed(() => displayStatus(props.decision, session.value))
/** Mini gauge — cast voices over the arrested list, voting only. */
const gauge = computed(() => {
const s = session.value
if (!s || props.decision.status !== 'voting') return null
const size = Math.max(s.corpusSize, 1)
const cast = store.activeVotes(s.id).length
return { cast, size, pct: Math.min(100, Math.round((cast / size) * 100)) }
})
const gaugeTitle = computed(() =>
gauge.value ? `${gauge.value.cast} voix sur ${gauge.value.size}` : '')
const deadline = computed(() => {
if (props.decision.windowEndsAt) return props.decision.windowEndsAt
if (session.value?.status === 'open') return session.value.closesAt
return undefined
})
</script>
<template>
<!-- ld-v2 -->
<NuxtLink :to="`/decisions/${decision.id}`" class="ld-card ld-card--hover reg-card">
<span class="reg-card__icon" :style="{ color: `var(--route-${decision.route})` }">
<UIcon :name="ROUTE_ICONS[decision.route]" />
</span>
<span class="reg-card__body">
<span class="reg-card__title">{{ decision.title }}</span>
<span class="reg-card__meta">
<span class="status-pill" :class="`status-${shown}`">{{ displayStatusLabel(shown) }}</span>
<span class="reg-card__chip">{{ ROUTE_SHORT[decision.route] }}</span>
<span class="reg-card__chip">{{ WEIGHT_SHORT[decision.weight] }}</span>
<LdCountdown
v-if="deadline"
:ends-at="deadline"
:suspended-at="decision.windowSuspendedAt"
/>
<span v-if="decision.urgent" class="reg-card__urgent">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_BADGE }}</span>
</span>
<span v-if="decision.engraving" class="reg-card__well" title="gravée"></span>
</span>
<span v-if="gauge" class="reg-card__gauge" :title="gaugeTitle">
<span class="reg-card__gauge-fill" :style="{ width: `${gauge.pct}%` }" />
</span>
</span>
</NuxtLink>
</template>
<style scoped>
.reg-card {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.125rem;
text-decoration: none;
color: var(--mood-text);
}
.reg-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
border-radius: var(--r-icon);
font-size: 1.25rem;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.reg-card__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.reg-card__title {
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.reg-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.reg-card__chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.reg-card__urgent {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.reg-card__well {
font-size: 1rem;
font-weight: 800;
color: var(--mood-status-vigueur);
transform: rotate(-10deg);
}
.reg-card__gauge {
display: block;
height: 6px;
border-radius: 3px;
background: var(--mood-status-vote-bg);
overflow: hidden;
max-width: 16rem;
}
.reg-card__gauge-fill {
display: block;
height: 100%;
border-radius: 3px;
background: var(--mood-status-vote);
transition: width 0.3s ease;
}
</style>
@@ -0,0 +1,215 @@
<script setup lang="ts">
// <!-- ld-v2 --> Registry filters: circles grouped by kind (lieu/thème/équipe
// icons), tags, « me concerne », weight, engraved 井, recorded entries.
import type { CircleKind, Weight } from '~/types/domain'
import { CONCERN_ME, ROUTE_SHORT } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { KIND_ICONS, KIND_LABELS, WEIGHT_SHORT } from './decisionUi'
const circleId = defineModel<string | null>('circleId', { default: null })
const tag = defineModel<string | null>('tag', { default: null })
const mine = defineModel<boolean>('mine', { default: false })
const weight = defineModel<Weight | null>('weight', { default: null })
const engraved = defineModel<boolean>('engraved', { default: false })
const recorded = defineModel<boolean>('recorded', { default: false })
const col = useCollectiveStore()
const KINDS: CircleKind[] = ['place', 'theme', 'team']
/** Circles grouped by kind — untyped circles last, under « Cercles ». */
const circleGroups = computed(() => {
const groups = KINDS
.map(kind => ({
kind,
icon: KIND_ICONS[kind],
label: KIND_LABELS[kind],
circles: col.circles.filter(c => c.kind === kind),
}))
.filter(g => g.circles.length > 0)
const untyped = col.circles.filter(c => c.kind === undefined)
if (untyped.length > 0) {
groups.push({ kind: 'team', icon: 'i-lucide-circle-dashed', label: 'Cercles', circles: untyped })
}
return groups
})
const allTags = computed(() =>
[...new Set(col.decisions.flatMap(d => d.tags))].sort((a, b) => a.localeCompare(b, 'fr')))
const WEIGHTS: Weight[] = ['light', 'binding', 'structural']
function toggleCircle(id: string) { circleId.value = circleId.value === id ? null : id }
function toggleTag(t: string) { tag.value = tag.value === t ? null : t }
function toggleWeight(w: Weight) { weight.value = weight.value === w ? null : w }
</script>
<template>
<!-- ld-v2 -->
<details class="reg-filters">
<summary class="reg-filters__summary">
<UIcon name="i-lucide-sliders-horizontal" />
<span>Filtres</span>
<span
v-if="circleId || tag || mine || weight || engraved || recorded"
class="reg-filters__dot"
/>
</summary>
<div class="reg-filters__body">
<div v-for="group in circleGroups" :key="group.label" class="reg-filters__row">
<span class="reg-filters__label">
<UIcon :name="group.icon" />
<span>{{ group.label }}</span>
</span>
<button
v-for="circle in group.circles"
:key="circle.id"
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': circleId === circle.id }"
@click="toggleCircle(circle.id)"
>
{{ circle.name }}
</button>
</div>
<div v-if="allTags.length > 0" class="reg-filters__row">
<span class="reg-filters__label">
<UIcon name="i-lucide-hash" />
<span>Tags</span>
</span>
<button
v-for="t in allTags"
:key="t"
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': tag === t }"
@click="toggleTag(t)"
>
#{{ t }}
</button>
</div>
<div class="reg-filters__row">
<span class="reg-filters__label">
<UIcon name="i-lucide-anchor" />
<span>Poids</span>
</span>
<button
v-for="w in WEIGHTS"
:key="w"
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': weight === w }"
@click="toggleWeight(w)"
>
{{ WEIGHT_SHORT[w] }}
</button>
</div>
<div class="reg-filters__row">
<span class="reg-filters__label">
<UIcon name="i-lucide-filter" />
<span>Et aussi</span>
</span>
<button
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': mine }"
@click="mine = !mine"
>
<UIcon name="i-lucide-hand" />
<span>{{ CONCERN_ME }}</span>
</button>
<button
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': engraved }"
@click="engraved = !engraved"
>
<span> gravées</span>
</button>
<button
type="button"
class="reg-filters__chip"
:class="{ 'reg-filters__chip--on': recorded }"
@click="recorded = !recorded"
>
<UIcon name="i-lucide-notebook-pen" />
<span>{{ ROUTE_SHORT.record }}es</span>
</button>
</div>
</div>
</details>
</template>
<style scoped>
.reg-filters__summary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.375rem 1rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
user-select: none;
list-style: none;
}
.reg-filters__summary::-webkit-details-marker { display: none; }
.reg-filters__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mood-accent);
}
.reg-filters__body {
margin-top: 0.75rem;
padding: 1rem 1.125rem;
border-radius: var(--r-card);
background: var(--mood-surface);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reg-filters__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.reg-filters__label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
min-width: 6rem;
}
.reg-filters__chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-height: 2.25rem;
padding: 0.25rem 0.875rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s ease;
}
.reg-filters__chip:hover { transform: translateY(-1px); color: var(--mood-text); }
.reg-filters__chip:active { transform: translateY(0); }
.reg-filters__chip--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
// <!-- ld-v2 --> Session block: link to the vote room, arrested-list sentence
// (« Qui vote : N personnes — liste arrêtée le … »), frozen banner while the
// steward's crystallization gesture is awaited, outcome once closed.
import type { Decision } from '~/types/domain'
import { FROZEN_BANNER, WHO_VOTES } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { dateFr } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === props.decision.id)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const protocol = computed(() =>
session.value
? col.protocols.find(p => p.id === session.value!.protocolId)
: undefined)
const whoVotes = computed(() =>
session.value
? WHO_VOTES(session.value.corpusSize, dateFr(session.value.opensAt))
: '')
const crystallizedBy = computed(() => {
const s = session.value
if (!s?.crystallizedById) return ''
const who = col.people.find(p => p.id === s.crystallizedById)?.displayName ?? '—'
return `Cristallisée par ${who} le ${dateFr(s.crystallizedAt)}`
})
const outcomeLabel = computed(() => {
switch (session.value?.outcome) {
case 'adopted': return 'adoptée'
case 'rejected': return 'rejetée'
case 'tie': return 'égalité — vous départagez, jamais l\'outil'
default: return ''
}
})
</script>
<template>
<!-- ld-v2 -->
<section v-if="session" class="ses">
<div class="ses__head">
<h2 class="ses__title">Session de vote</h2>
<span v-if="protocol" class="ses__protocol">{{ protocol.name }}</span>
</div>
<p class="ses__who">{{ whoVotes }}</p>
<p v-if="session.status === 'frozen'" class="ses__frozen">
<UIcon name="i-lucide-stamp" />
<span>{{ FROZEN_BANNER }}</span>
</p>
<template v-if="session.status === 'closed'">
<p class="ses__result">
Résultat : <strong>{{ outcomeLabel }}</strong>
<span class="ses__result-date"> clôture le {{ dateFr(session.closesAt) }}</span>
</p>
<p v-if="crystallizedBy" class="ses__crystal">{{ crystallizedBy }}</p>
</template>
<div v-else class="ses__actions no-print">
<NuxtLink :to="`/decisions/${decision.id}/vote`" class="ld-btn">
<UIcon name="i-lucide-vote" />
<span>{{ session.status === 'open' ? 'Entrer dans la salle de vote' : 'Voir la salle de vote' }}</span>
</NuxtLink>
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
</div>
</section>
</template>
<style scoped>
.ses { display: flex; flex-direction: column; gap: 0.75rem; }
.ses__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.ses__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.ses__protocol {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-status-vote);
background: var(--mood-status-vote-bg);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.ses__who {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.ses__frozen {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
width: fit-content;
padding: 0.5rem 0.875rem;
border-radius: var(--r-input);
font-weight: 700;
font-size: 0.875rem;
color: var(--mood-status-fige);
background: var(--mood-status-fige-bg);
}
.ses__result { margin: 0; font-size: 0.9375rem; }
.ses__result-date { color: var(--mood-text-muted); font-size: 0.875rem; }
.ses__crystal {
margin: 0;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.ses__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.625rem;
}
</style>
@@ -0,0 +1,220 @@
<script setup lang="ts">
// <!-- ld-v2 --> Vertical lifecycle timeline: full dots linked by a background
// line, traversed states with timestamps, ONE state→color mapping. The paused
// dot is striped when the boundary is contested; an extended non-easy window
// shows deep amber + « il manque un accord explicite ».
import type { Decision, DecisionStatus, VoteSession } from '~/types/domain'
import {
ASSENT_MISSING, BOUNDARY_SUSPENDED, FROZEN_LABEL, STATUS_LABELS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { STATUS_TOKEN, dateTimeFr, type DisplayStatus } from './decisionUi'
const props = defineProps<{ decision: Decision; session?: VoteSession }>()
const col = useCollectiveStore()
interface Step {
key: DisplayStatus
label: string
at?: string
state: 'done' | 'current' | 'ahead'
token: string
suspended?: boolean
extended?: boolean
}
const hasThirdPartyAssent = computed(() =>
col.assents.some(a =>
a.decisionId === props.decision.id && a.personId !== props.decision.authorId))
/** Extended window: non-easy deadline passed without an explicit agreement. */
const isExtended = computed(() => {
const d = props.decision
return d.status === 'objection'
&& d.reversibility !== 'easy'
&& !d.windowSuspendedAt
&& d.windowEndsAt !== undefined
&& new Date(d.windowEndsAt).getTime() < Date.now()
&& !hasThirdPartyAssent.value
})
/** The path of this decision — traversed states first, expected end last. */
const steps = computed<Step[]>(() => {
const d = props.decision
const s = props.session
const path: { key: DisplayStatus; at?: string }[] = [{ key: 'draft', at: d.createdAt }]
if (d.route === 'mandate') path.push({ key: 'objection', at: d.createdAt })
else if (d.route === 'advice') path.push({ key: 'advice', at: d.createdAt })
else if (d.route === 'collective') {
const framed = d.status === 'framing'
|| (d.status === 'closed' && !d.decidedAt)
|| col.decisions.some(c => c.parentDecisionId === d.id && c.chainKind === 'element')
if (framed) path.push({ key: 'framing', at: d.createdAt })
if (d.status !== 'framing' || s) path.push({ key: 'voting', at: s?.opensAt })
if (s?.status === 'frozen') path.push({ key: 'frozen', at: s.closesAt })
}
const terminals: DecisionStatus[] = ['adopted', 'rejected', 'revoked', 'closed', 'transmitted']
if (d.status === 'revoked') {
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'revoked', at: d.updatedAt })
} else if (d.status === 'closed' && d.decidedAt) {
path.push({ key: 'adopted', at: d.decidedAt }, { key: 'closed', at: d.updatedAt })
} else if (terminals.includes(d.status)) {
path.push({ key: d.status, at: d.decidedAt ?? d.updatedAt })
} else {
// Expected landing of the route, shown ahead.
path.push({ key: d.route === 'transmit' ? 'transmitted' : 'adopted' })
}
const shownKey: DisplayStatus = d.status === 'voting' && s?.status === 'frozen'
? 'frozen'
: d.status
const currentIndex = path.findIndex(p => p.key === shownKey)
return path.map((p, i) => ({
key: p.key,
label: p.key === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[p.key as DecisionStatus],
...(p.at !== undefined ? { at: p.at } : {}),
state: currentIndex === -1 || i < currentIndex
? 'done'
: i === currentIndex ? 'current' : 'ahead',
token: STATUS_TOKEN[p.key],
suspended: i === currentIndex && !!d.windowSuspendedAt
&& (p.key === 'objection' || p.key === 'advice'),
extended: i === currentIndex && p.key === 'objection' && isExtended.value,
}))
})
</script>
<template>
<!-- ld-v2 -->
<ol class="tl" aria-label="Cycle de vie">
<li
v-for="step in steps"
:key="step.key"
class="tl__step"
:class="[`tl__step--${step.state}`, { 'tl__step--extended': step.extended }]"
:style="{
'--step-color': `var(--mood-status-${step.token})`,
'--step-bg': `var(--mood-status-${step.token}-bg)`,
}"
>
<span class="tl__dot" :class="{ 'tl__dot--paused': step.suspended }">
<UIcon v-if="step.suspended" name="i-lucide-pause" class="tl__pause" />
</span>
<span class="tl__body">
<span class="tl__label">{{ step.label }}</span>
<span v-if="step.at && step.state !== 'ahead'" class="tl__date">
{{ dateTimeFr(step.at) }}
</span>
<span v-if="step.suspended" class="tl__note tl__note--paused">
{{ BOUNDARY_SUSPENDED }}
</span>
<span v-else-if="step.extended" class="tl__note tl__note--extended">
{{ ASSENT_MISSING }} la fenêtre se prolonge
</span>
</span>
</li>
</ol>
</template>
<style scoped>
.tl {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tl__step {
position: relative;
display: flex;
gap: 0.75rem;
padding: 0 0 1.125rem 0;
}
.tl__step:last-child { padding-bottom: 0; }
/* Trait de fond reliant les pastilles */
.tl__step:not(:last-child)::before {
content: '';
position: absolute;
left: 8px;
top: 18px;
bottom: 0;
width: 2px;
background: var(--mood-accent-soft);
}
.tl__dot {
position: relative;
z-index: 1;
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 50%;
background: var(--step-color);
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.tl__step--ahead .tl__dot {
background: var(--step-bg);
box-shadow: inset 0 0 0 2px var(--step-color);
opacity: 0.55;
}
.tl__dot--paused {
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre),
var(--mood-status-fenetre) 3px,
var(--mood-status-fenetre-bg) 3px,
var(--mood-status-fenetre-bg) 6px
);
}
.tl__pause {
font-size: 0.625rem;
color: var(--mood-surface);
}
.tl__body {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.tl__label {
font-size: 0.9375rem;
font-weight: 700;
color: var(--step-color);
line-height: 1.25;
}
.tl__step--ahead .tl__label { opacity: 0.5; }
.tl__step--current .tl__label { font-size: 1rem; }
.tl__date {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.tl__note {
margin-top: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--r-pill);
width: fit-content;
}
.tl__note--paused {
color: var(--mood-status-fenetre);
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre-bg),
var(--mood-status-fenetre-bg) 6px,
transparent 6px,
transparent 10px
);
}
.tl__note--extended {
color: var(--mood-surface);
background: var(--mood-status-fenetre);
}
.tl__step--extended .tl__dot { box-shadow: 0 0 0 3px var(--mood-status-fenetre-bg); }
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
// <!-- ld-v2 --> « En vigueur » block: stewards, measurers, « Ce que ça
// engage », sunset, and the reality check when due — the sought/target/
// observed table ABOVE the three buttons. Measurers record observations here.
import type { Decision } from '~/types/domain'
import {
ENGAGES_LABEL, REVIEW_QUESTION, REVIEW_TITLE, REVIEW_VERDICTS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { dateFr } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const reviewLabels = REVIEW_VERDICTS
const names = (ids: string[]) =>
ids.map(id => col.people.find(p => p.id === id)?.displayName ?? '—').join(', ')
const stewards = computed(() => names(props.decision.stewardIds))
const measurers = computed(() => names(props.decision.measurerIds))
const meId = computed(() => col.me?.id ?? null)
const iMeasure = computed(() =>
meId.value !== null && props.decision.measurerIds.includes(meId.value))
const effects = computed(() => props.decision.brief?.effects ?? [])
const review = computed(() => props.decision.review)
const reviewDue = computed(() =>
review.value !== undefined
&& review.value.verdict === undefined
&& new Date(review.value.dueAt).getTime() <= Date.now())
const reviewDoneLabel = computed(() => {
const r = review.value
if (!r?.verdict) return ''
return `${reviewLabels[r.verdict]} — le ${dateFr(r.decidedAt)}`
})
/** One line for the facts list — done label, or the planned date. */
const reviewLine = computed(() => {
const r = review.value
if (!r) return ''
return r.verdict ? reviewDoneLabel.value : `prévue le ${dateFr(r.dueAt)}`
})
// ── Le geste d'épreuve ──
const reviewNote = ref('')
const feedback = ref('')
async function giveReview(kind: 'confirmed' | 'revise' | 'revoke') {
const note = reviewNote.value.trim()
const result = store.reviewVerdict(props.decision.id, kind, note.length > 0 ? note : undefined)
if ('ok' in result) { feedback.value = result.reason; return }
feedback.value = ''
// revise/revoke chain a child decision under the ORIGINAL protocol — go there.
if (result.id !== props.decision.id) await navigateTo(`/decisions/${result.id}`)
}
// ── Saisie de mesure (measurers) ──
const measureDrafts = ref<Record<number, string>>({})
function saveMeasure(index: number) {
const note = (measureDrafts.value[index] ?? '').trim()
if (note.length === 0 || !meId.value) return
const effect = props.decision.brief?.effects[index]
if (!effect) return
effect.measured = { note, at: col.now(), byId: meId.value }
col.stamp(props.decision)
col.persist()
measureDrafts.value[index] = ''
}
</script>
<template>
<!-- ld-v2 -->
<section class="vig">
<h2 class="vig__title">En vigueur</h2>
<dl class="vig__facts">
<template v-if="stewards">
<dt>Garant·e·s</dt>
<dd>{{ stewards }}</dd>
</template>
<template v-if="measurers">
<dt>Mesureur·e·s</dt>
<dd>{{ measurers }}</dd>
</template>
<template v-if="decision.resources">
<dt>{{ ENGAGES_LABEL }}</dt>
<dd>
{{ decision.resources.note }}
<strong v-if="decision.resources.amount !== undefined">
{{ decision.resources.amount.toLocaleString('fr-FR') }} {{ decision.resources.unit ?? '' }}
</strong>
</dd>
</template>
<template v-if="decision.sunsetAt">
<dt>Échéance de fin</dt>
<dd>{{ dateFr(decision.sunsetAt) }}</dd>
</template>
<template v-if="review">
<dt>{{ REVIEW_TITLE }}</dt>
<dd>{{ reviewLine }}</dd>
</template>
</dl>
<p v-if="review?.note" class="vig__review-note">« {{ review.note }} »</p>
<!-- L'épreuve du réel due -->
<div v-if="reviewDue" class="vig__review">
<h3 class="vig__review-title">{{ REVIEW_TITLE }}</h3>
<p class="vig__review-question">{{ REVIEW_QUESTION }}</p>
<table v-if="effects.length > 0" class="vig__table">
<thead>
<tr>
<th>Effet recherché</th>
<th>Cible</th>
<th>Constaté</th>
</tr>
</thead>
<tbody>
<tr v-for="(effect, i) in effects" :key="i">
<td>{{ effect.label }}</td>
<td>{{ effect.target ?? '—' }}</td>
<td>
<template v-if="effect.measured">
{{ effect.measured.note }}
<span class="vig__measured-at">({{ dateFr(effect.measured.at) }})</span>
</template>
<div v-else-if="iMeasure" class="vig__measure no-print">
<input
v-model="measureDrafts[i]"
type="text"
placeholder="Ce que tu constates…"
>
<button type="button" class="ld-btn ld-btn--ghost" @click="saveMeasure(i)">
Consigner
</button>
</div>
<template v-else></template>
</td>
</tr>
</tbody>
</table>
<p v-else class="vig__hint">
Aucun effet recherché n'avait été formulé — l'épreuve se joue sur la mémoire du collectif.
</p>
<textarea
v-model="reviewNote"
rows="2"
class="no-print"
placeholder="Un mot sur ce que le réel a montré (optionnel)…"
/>
<div class="vig__actions no-print">
<button type="button" class="ld-btn" @click="giveReview('confirmed')">
{{ reviewLabels.confirmed }}
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revise')">
{{ reviewLabels.revise }}
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="giveReview('revoke')">
{{ reviewLabels.revoke }}
</button>
</div>
<p v-if="feedback" class="vig__error">{{ feedback }}</p>
</div>
</section>
</template>
<style scoped>
.vig { display: flex; flex-direction: column; gap: 0.875rem; }
.vig__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.vig__facts {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.375rem 1rem;
font-size: 0.9375rem;
}
.vig__facts dt {
font-weight: 700;
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.vig__facts dd { margin: 0; }
.vig__review-note {
margin: 0;
font-style: italic;
color: var(--mood-text-muted);
font-size: 0.9375rem;
}
.vig__review {
padding: 1rem 1.125rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-tertiary) 9%, transparent);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.vig__review-title { margin: 0; font-size: 1rem; font-weight: 800; }
.vig__review-question {
margin: 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.vig__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.vig__table th {
text-align: left;
font-size: 0.8125rem;
color: var(--mood-text-muted);
padding: 0.375rem 0.5rem;
}
.vig__table td {
padding: 0.375rem 0.5rem;
background: var(--mood-surface);
vertical-align: top;
}
.vig__measured-at { color: var(--mood-text-muted); font-size: 0.8125rem; }
.vig__measure {
display: flex;
gap: 0.375rem;
align-items: center;
flex-wrap: wrap;
}
.vig__measure input {
flex: 1;
min-width: 8rem;
min-height: 2.25rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.vig__review textarea {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
resize: vertical;
}
.vig__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.vig__hint {
margin: 0;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.vig__error {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error);
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
// <!-- ld-v2 --> Window block. Objection: « Ça me va / J'objecte » inline —
// silence counts as agreement ONLY when easily reversible, otherwise the
// explicit-agreement counter + « il manque un accord explicite ». Advice:
// three inline positions, the author then decides.
import type { Advice, Decision } from '~/types/domain'
import { ASSENT_MISSING, WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { ADVICE_LABELS } from './decisionUi'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const meId = computed(() => col.me?.id ?? null)
const isAuthor = computed(() => meId.value === props.decision.authorId)
// ── Assents (« Ça me va » stocké) ──
const assents = computed(() =>
col.assents.filter(a => a.decisionId === props.decision.id))
const thirdPartyCount = computed(() =>
assents.value.filter(a => a.personId !== props.decision.authorId).length)
const iAssented = computed(() =>
meId.value !== null && assents.value.some(a => a.personId === meId.value))
function assent() { store.assentTo(props.decision.id) }
// ── Objection de fond ──
const objectOpen = ref(false)
const objectArg = ref('')
const feedback = ref('')
function object() {
const result = store.objectTo(props.decision.id, 'content', objectArg.value)
if ('ok' in result) { feedback.value = result.reason; return }
objectOpen.value = false
objectArg.value = ''
feedback.value = ''
}
// ── Avis ──
const myAdvice = computed(() =>
col.advices.find(a => a.decisionId === props.decision.id && a.personId === meId.value))
const adviceNote = ref('')
function advise(position: Advice['position']) {
const note = adviceNote.value.trim()
store.adviseOn(props.decision.id, position, note.length > 0 ? note : undefined)
adviceNote.value = ''
}
/** The author decides once instructed — the ONE state gate answers. */
function authorAdopts() {
const result = store.transition(props.decision.id, 'adopted')
feedback.value = result.ok ? '' : result.reason
}
</script>
<template>
<!-- ld-v2 -->
<section class="win">
<div class="win__head">
<h2 class="win__title">
{{ decision.status === 'advice' ? 'Fenêtre d\'avis' : 'Fenêtre d\'objection' }}
</h2>
<LdCountdown
v-if="decision.windowEndsAt"
:ends-at="decision.windowEndsAt"
:suspended-at="decision.windowSuspendedAt"
/>
</div>
<template v-if="decision.status === 'objection'">
<p v-if="decision.reversibility === 'easy'" class="win__rule">
À l'échéance sans objection, c'est adopté le silence vaut accord,
parce que c'est facilement réversible.
</p>
<template v-else>
<p class="win__rule">
{{ thirdPartyCount }} accord{{ thirdPartyCount > 1 ? 's' : '' }} explicite{{ thirdPartyCount > 1 ? 's' : '' }}
hors du réversible, l'accord est un geste, pas une absence.
</p>
<p v-if="thirdPartyCount === 0" class="win__missing">{{ ASSENT_MISSING }}</p>
</template>
<div class="win__actions no-print">
<button
type="button"
class="ld-btn"
:disabled="iAssented"
@click="assent()"
>
<UIcon name="i-lucide-check" />
<span>{{ iAssented ? `${WINDOW_OK} — déposé` : WINDOW_OK }}</span>
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="objectOpen = !objectOpen">
{{ WINDOW_OBJECT }}
</button>
</div>
<div v-if="objectOpen" class="win__form no-print">
<textarea
v-model="objectArg"
rows="2"
placeholder="Une objection s'argumente écris pourquoi."
/>
<button
type="button"
class="ld-btn"
:disabled="objectArg.trim().length === 0"
@click="object()"
>
Déposer l'objection
</button>
</div>
</template>
<template v-else-if="decision.status === 'advice'">
<p class="win__rule">
L'auteur·e écoute, puis décide — les avis restent annexés à la décision.
</p>
<div v-if="!myAdvice" class="win__form no-print">
<textarea
v-model="adviceNote"
rows="2"
placeholder="Un mot pour éclairer (optionnel)"
/>
<div class="win__actions">
<button type="button" class="ld-btn" @click="advise('favorable')">
{{ ADVICE_LABELS.favorable }}
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('reserved')">
{{ ADVICE_LABELS.reserved }}
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="advise('unfavorable')">
{{ ADVICE_LABELS.unfavorable }}
</button>
</div>
</div>
<p v-else class="win__rule">Ton avis est déposé — {{ ADVICE_LABELS[myAdvice.position] }}.</p>
<div v-if="isAuthor" class="win__actions no-print">
<button type="button" class="ld-btn" @click="authorAdopts()">
<UIcon name="i-lucide-check-check" />
<span>Je décide — adopter</span>
</button>
</div>
</template>
<p v-if="feedback" class="win__feedback">{{ feedback }}</p>
</section>
</template>
<style scoped>
.win { display: flex; flex-direction: column; gap: 0.75rem; }
.win__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.win__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.win__rule {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.win__missing {
margin: 0;
width: fit-content;
padding: 4px 12px;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-surface);
background: var(--mood-status-fenetre);
}
.win__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.win__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.win__form textarea {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
resize: vertical;
}
.win__feedback {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-status-fenetre);
}
</style>
@@ -1,147 +0,0 @@
<script setup lang="ts">
/**
* Visual stepper/timeline showing the decision workflow.
*
* Displays each step with its type icon, status badge, and dates.
* The active step is highlighted, completed steps show a checkmark.
*/
import type { DecisionStep } from '~/stores/decisions'
const props = defineProps<{
steps: DecisionStep[]
currentStatus: string
}>()
const emit = defineEmits<{
'create-vote-session': [step: DecisionStep]
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'vote': return 'Vote'
case 'execution': return 'Execution'
case 'reporting': return 'Compte rendu'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'i-lucide-check-square'
case 'review': return 'i-lucide-eye'
case 'vote': return 'i-lucide-vote'
case 'execution': return 'i-lucide-play'
case 'reporting': return 'i-lucide-file-text'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="step in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<StatusBadge :status="step.status" type="decision" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div class="text-xs text-gray-500">
Cree le {{ formatDate(step.created_at) }}
</div>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<!-- Vote session actions -->
<div class="flex items-center gap-2 mt-2">
<UButton
v-if="step.vote_session_id"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
<UButton
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-plus"
label="Creer une session de vote"
@click.stop="emit('create-vote-session', step)"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,98 @@
/**
* Shared UI helpers of the decisions screens (registry + fiche) — v2.
* Labels that complement app/lexicon.ts locally (short weight forms, chain
* kinds, circle-kind iconography) live here; the lexicon stays the single
* source for everything it already names.
*/
import type {
ChainKind,
CircleKind,
Decision,
DecisionStatus,
VoteSession,
Weight,
} from '~/types/domain'
import { FROZEN_LABEL, STATUS_LABELS } from '~/lexicon'
/** Short weight forms — registry pills and card metadata. */
export const WEIGHT_SHORT: Record<Weight, string> = {
light: 'léger',
binding: 'engageant',
structural: 'structurant',
}
/** Chain kinds in plain French (fiche chain block). */
export const CHAIN_LABELS: Record<ChainKind, string> = {
ratification: 'ratification',
revision: 'révision',
revocation: 'révocation',
element: 'élément',
}
/** Circle kind iconography — lieu / thème / équipe (never a right). */
export const KIND_ICONS: Record<CircleKind, string> = {
place: 'i-lucide-map-pin',
theme: 'i-lucide-tag',
team: 'i-lucide-users-round',
}
export const KIND_LABELS: Record<CircleKind, string> = {
place: 'Lieux',
theme: 'Thèmes',
team: 'Équipes',
}
/** Advice positions in plain French. */
export const ADVICE_LABELS = {
favorable: 'favorable',
reserved: 'réservé',
unfavorable: 'défavorable',
} as const
/** Display status: 'frozen' overlays 'voting' when the session is frozen. */
export type DisplayStatus = DecisionStatus | 'frozen'
export function displayStatus(decision: Decision, session?: VoteSession): DisplayStatus {
if (decision.status === 'voting' && session?.status === 'frozen') return 'frozen'
return decision.status
}
export function displayStatusLabel(status: DisplayStatus): string {
return status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status as DecisionStatus]
}
/** Timeline color mapping — the ONE state→color mapping (moods.css tokens). */
export const STATUS_TOKEN: Record<DisplayStatus, string> = {
draft: 'prepa',
advice: 'fenetre',
objection: 'fenetre',
framing: 'prepa',
voting: 'vote',
frozen: 'fige',
adopted: 'vigueur',
rejected: 'clos',
revoked: 'revoque',
transmitted: 'fige',
closed: 'clos',
}
/** Case/accent-insensitive folding — same logic as useSearch. */
export function fold(text: string): string {
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
/** Short French date (« 12 août 2026 »). */
export function dateFr(iso?: string): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
})
}
/** French date + time for timeline milestones. */
export function dateTimeFr(iso?: string): string {
if (!iso) return ''
return new Date(iso).toLocaleString('fr-FR', {
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
})
}