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
@@ -0,0 +1,165 @@
<script setup lang="ts">
// « Je choisis autrement » — the permanent alternatives, each with its cost.
// Weighting up is one tap; lightening REQUIRES a note and imposes a window.
import type { ParamSpec, Verdict } from '~/types/domain'
import { CHOOSE_OTHERWISE, PARAMETRIC_ALT, RECORD_HOW } from '~/lexicon'
type AltKey = 'parametric' | 'record' | 'binary'
const props = defineProps<{
alternatives: Verdict['alternatives']
parametricHint: boolean
chosen: AltKey | null
lightening: boolean
decidedHow: string
overrideNote: string
spec?: ParamSpec
}>()
const emit = defineEmits<{
(e: 'update:chosen', value: AltKey | null): void
(e: 'update:decidedHow' | 'update:overrideNote', value: string): void
(e: 'update:spec', value: ParamSpec): void
}>()
const open = ref(false)
const keyed = computed(() =>
props.alternatives.map((alt) => ({
...alt,
key: (alt.route === 'record'
? 'record'
: alt.label === PARAMETRIC_ALT ? 'parametric' : 'binary') as AltKey,
})),
)
function pick(key: AltKey) {
emit('update:chosen', props.chosen === key ? null : key)
}
</script>
<template>
<!-- ld-v2 -->
<section class="alt">
<button class="alt__toggle" type="button" @click="open = !open">
<UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" />
<span>{{ CHOOSE_OTHERWISE }}</span>
<span v-if="chosen && !open" class="alt__chosen-hint">1 choix actif</span>
</button>
<div v-if="open" class="alt__body">
<div class="alt__list">
<button
v-for="alt in keyed"
:key="alt.key"
class="alt__item"
:class="{
'alt__item--on': chosen === alt.key,
'alt__item--hint': alt.key === 'parametric' && parametricHint,
}"
type="button"
@click="pick(alt.key)"
>
<span class="alt__label">{{ alt.label }}</span>
<span class="alt__cost">{{ alt.cost }}</span>
</button>
</div>
<div v-if="chosen === 'parametric'" class="alt__detail">
<CheminParamEditor
:model-value="spec"
@update:model-value="emit('update:spec', $event)"
/>
</div>
<div v-else-if="chosen === 'record'" class="alt__detail">
<label class="alt__field">
<span>{{ RECORD_HOW }}</span>
<input
:value="decidedHow"
type="text"
placeholder="Décidé au café, comme d'habitude…"
@input="emit('update:decidedHow', ($event.target as HTMLInputElement).value)"
>
</label>
</div>
<div v-if="lightening" class="alt__lighten">
<p class="alt__lighten-mention">
<UIcon name="i-lucide-scale" />
Tu prends un chemin plus léger que celui du collectif : une fenêtre d'objection
de 48 h est imposée, et ta raison s'affiche.
</p>
<textarea
:value="overrideNote"
class="alt__note"
rows="2"
placeholder="Pourquoi alléger ? (obligatoire)"
@input="emit('update:overrideNote', ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</div>
</section>
</template>
<style scoped>
.alt { display: flex; flex-direction: column; gap: 0.625rem; }
.alt__toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: none;
padding: 0.375rem 0.25rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text-muted);
cursor: pointer;
align-self: flex-start;
border-radius: var(--r-input);
}
.alt__toggle:hover { color: var(--mood-text); }
.alt__chosen-hint { font-size: 0.75rem; font-weight: 600; color: var(--mood-accent); }
.alt__body { display: flex; flex-direction: column; gap: 0.75rem; }
.alt__list { display: flex; flex-direction: column; gap: 0.5rem; }
.alt__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
padding: 0.625rem 0.875rem;
border-radius: var(--r-icon);
background: var(--mood-input-bg);
cursor: pointer;
text-align: left;
transition: transform 0.1s ease;
}
.alt__item:hover { transform: translateY(-1px); }
.alt__item--hint { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--mood-accent) 55%, transparent); }
.alt__item--on {
background: var(--mood-accent-soft);
box-shadow: 0 0 0 2px var(--mood-accent);
}
.alt__label { font-size: 0.9063rem; font-weight: 700; color: var(--mood-text); }
.alt__cost { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.alt__detail { padding-left: 0.25rem; }
.alt__field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.875rem; font-weight: 700; }
.alt__field input { padding: 0.625rem 0.75rem; font-size: 0.9375rem; width: 100%; }
.alt__lighten {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg);
}
.alt__lighten-mention {
display: flex;
align-items: flex-start;
gap: 0.4rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-fenetre);
}
.alt__note { width: 100%; padding: 0.625rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
// The three tunnel chips — Concernés / Réversible ? / Ça engage quoi ? —
// one unfolded at a time (mobile-first), plus the discreet urgency toggle,
// the never-blocking « Ce que ça engage » suggestion line and the
// « Aujourd'hui : … » status-quo line.
import type { Decision, Person, Reversibility, Weight } from '~/types/domain'
import {
BASELINE_PREFIX,
ENGAGES_LABEL,
REVERSIBILITY_LABELS,
URGENT_TOGGLE,
WEIGHT_LABELS,
} from '~/lexicon'
defineProps<{
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
engagesOpen: boolean
baselineSuggested: boolean
}>()
const scope = defineModel<Decision['scope']>('scope', { required: true })
const reversibility = defineModel<Reversibility>('reversibility', { required: true })
const weight = defineModel<Weight>('weight', { required: true })
const urgent = defineModel<boolean>('urgent', { required: true })
const resourceNote = defineModel<string>('resourceNote', { required: true })
const resourceAmount = defineModel<number | null>('resourceAmount', { required: true })
const resourceUnit = defineModel<string>('resourceUnit', { required: true })
const baselineNote = defineModel<string>('baselineNote', { required: true })
const openChip = ref<'scope' | 'rev' | 'weight' | null>(null)
const REV_OPTIONS = Object.keys(REVERSIBILITY_LABELS) as Reversibility[]
const WEIGHT_OPTIONS = Object.keys(WEIGHT_LABELS) as Weight[]
function toggle(chip: 'scope' | 'rev' | 'weight') {
openChip.value = openChip.value === chip ? null : chip
}
</script>
<template>
<!-- ld-v2 -->
<div class="chips">
<div class="chips__row">
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'scope' }"
type="button"
@click="toggle('scope')"
>
<span class="chips__name">Concernés</span>
<span class="chips__value">
{{ scope.selfOnly ? 'moi seul' : `${stack.length} personne${stack.length > 1 ? 's' : ''}` }}
</span>
</button>
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'rev' }"
type="button"
@click="toggle('rev')"
>
<span class="chips__name">Réversible ?</span>
<span class="chips__value">{{ REVERSIBILITY_LABELS[reversibility] }}</span>
</button>
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'weight' }"
type="button"
@click="toggle('weight')"
>
<span class="chips__name">Ça engage quoi ?</span>
<span class="chips__value">{{ WEIGHT_LABELS[weight] }}</span>
</button>
<label class="chips__urgent" :class="{ 'chips__urgent--on': urgent }">
<input v-model="urgent" type="checkbox">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_TOGGLE }}</span>
</label>
</div>
<div v-if="openChip === 'scope'" class="ld-card chips__panel">
<CheminScope v-model:scope="scope" :stack="stack" />
</div>
<div v-else-if="openChip === 'rev'" class="ld-card chips__panel">
<div class="chips__options">
<button
v-for="opt in REV_OPTIONS"
:key="opt"
class="chips__option"
:class="{ 'chips__option--on': reversibility === opt }"
type="button"
@click="reversibility = opt"
>
{{ REVERSIBILITY_LABELS[opt] }}
</button>
</div>
</div>
<div v-else-if="openChip === 'weight'" class="ld-card chips__panel">
<div class="chips__options">
<button
v-for="opt in WEIGHT_OPTIONS"
:key="opt"
class="chips__option"
:class="{ 'chips__option--on': weight === opt }"
type="button"
@click="weight = opt"
>
{{ WEIGHT_LABELS[opt] }}
</button>
</div>
</div>
<!-- « Ce que ça engage » suggestion dépliée, JAMAIS bloquante -->
<div v-if="engagesOpen" class="chips__engages">
<span class="chips__engages-label">{{ ENGAGES_LABEL }}</span>
<input
v-model="resourceNote"
class="chips__engages-note"
type="text"
placeholder="En une phrase — suggéré, jamais exigé ici"
>
<div class="chips__engages-amount">
<input
v-model.number="resourceAmount"
type="number"
min="0"
placeholder="Montant"
>
<select v-model="resourceUnit">
<option value="heures">heures</option>
<option value="€"></option>
<option value="DU">DU</option>
<option value="jours">jours</option>
</select>
</div>
</div>
<!-- « Aujourd'hui : » le statu quo, suggéré sur avis/collectif -->
<label v-if="baselineSuggested" class="chips__baseline">
<span>{{ BASELINE_PREFIX }}</span>
<input
v-model="baselineNote"
type="text"
placeholder="ce qui se passe si rien ne change"
>
</label>
</div>
</template>
<style scoped>
.chips { display: flex; flex-direction: column; gap: 0.625rem; }
.chips__row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.5rem; }
.chips__chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.5rem 0.875rem;
border-radius: var(--r-icon);
background: var(--mood-surface);
box-shadow: var(--shadow-card);
cursor: pointer;
text-align: left;
min-height: 2.25rem;
transition: transform 0.1s ease;
}
.chips__chip:hover { transform: translateY(-1px); }
.chips__chip--open { box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card); }
.chips__name {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.chips__value { font-size: 0.875rem; font-weight: 700; color: var(--mood-text); }
.chips__urgent {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
padding: 0.4rem 0.875rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
align-self: center;
}
.chips__urgent input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.chips__urgent--on {
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 12%, transparent);
}
.chips__panel { padding: 1rem; }
.chips__options { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.chips__option {
padding: 0.5rem 1rem;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 600;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
min-height: 2.25rem;
}
.chips__option--on {
background: var(--mood-accent-soft);
color: var(--mood-accent);
box-shadow: 0 0 0 1.5px var(--mood-accent);
}
.chips__engages {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
}
.chips__engages-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent); }
.chips__engages-note { width: 100%; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__engages-amount { display: flex; gap: 0.5rem; }
.chips__engages-amount input { width: 7rem; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__engages-amount select { padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__baseline {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.chips__baseline input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
// Mini-editor of a « Réglage collectif »: up to 7 named sliders
// {label, min, max, step, unit, baseline}, plus the 100 % split option with
// its ONE named derived share (« calculé » — resolved, never voted).
import type { ParamDef, ParamSpec } from '~/types/domain'
const props = defineProps<{ modelValue?: ParamSpec }>()
const emit = defineEmits<{ (e: 'update:modelValue', spec: ParamSpec): void }>()
interface Row {
label: string
min: number
max: number
step: number
unit: string
baseline: number | null
}
const blankRow = (): Row => ({ label: '', min: 0, max: 100, step: 1, unit: '', baseline: null })
const rows = ref<Row[]>(
props.modelValue
? props.modelValue.params
.filter(p => !p.derived)
.map(p => ({
label: p.label,
min: p.min,
max: p.max,
step: p.step,
unit: p.unit ?? '',
baseline: p.baseline ?? null,
}))
: [blankRow()],
)
const sum100 = ref(props.modelValue?.constraint === 'sum100')
const derivedName = ref(
props.modelValue?.params.find(p => p.derived)?.label ?? 'Réserve (calculé)',
)
const maxRows = computed(() => (sum100.value ? 6 : 7))
const derivedBaseline = computed(() =>
Math.max(0, 100 - rows.value.reduce((sum, r) => sum + (r.baseline ?? 0), 0)),
)
function build(): ParamSpec {
const params: ParamDef[] = rows.value.map((row, i) => ({
key: `p${i + 1}`,
label: row.label.trim() || `Curseur ${i + 1}`,
kind: sum100.value ? 'share' : 'slider',
min: sum100.value ? 0 : row.min,
max: sum100.value ? 100 : row.max,
step: row.step > 0 ? row.step : 1,
...(sum100.value ? { unit: '%' } : row.unit.trim() ? { unit: row.unit.trim() } : {}),
...(row.baseline !== null ? { baseline: row.baseline } : {}),
}))
if (sum100.value) {
params.push({
key: 'derived',
label: derivedName.value.trim() || 'Réserve (calculé)',
kind: 'share',
min: 0,
max: 100,
step: 1,
unit: '%',
baseline: derivedBaseline.value,
derived: true,
})
}
return { params, constraint: sum100.value ? 'sum100' : 'none' }
}
function sync() {
emit('update:modelValue', build())
}
function addRow() {
if (rows.value.length >= maxRows.value) return
rows.value.push(blankRow())
sync()
}
function removeRow(index: number) {
rows.value.splice(index, 1)
if (rows.value.length === 0) rows.value.push(blankRow())
sync()
}
onMounted(sync)
</script>
<template>
<!-- ld-v2 -->
<div class="pe">
<div v-for="(row, i) in rows" :key="i" class="pe__row">
<input
v-model="row.label"
class="pe__label"
type="text"
:placeholder="`Curseur ${i + 1} — libellé métier`"
@input="sync"
>
<div class="pe__nums">
<template v-if="!sum100">
<label class="pe__num"><span>min</span>
<input v-model.number="row.min" type="number" @input="sync">
</label>
<label class="pe__num"><span>max</span>
<input v-model.number="row.max" type="number" @input="sync">
</label>
<label class="pe__num"><span>pas</span>
<input v-model.number="row.step" type="number" min="0" @input="sync">
</label>
<label class="pe__num"><span>unité</span>
<input v-model="row.unit" type="text" placeholder="€, h…" @input="sync">
</label>
</template>
<label class="pe__num"><span>aujourd'hui</span>
<input v-model.number="row.baseline" type="number" @input="sync">
</label>
<button
v-if="rows.length > 1"
class="pe__remove"
type="button"
aria-label="Retirer ce curseur"
@click="removeRow(i)"
>
<UIcon name="i-lucide-x" />
</button>
</div>
</div>
<button v-if="rows.length < maxRows" class="ld-btn ld-btn--quiet pe__add" type="button" @click="addRow">
<UIcon name="i-lucide-plus" /> Ajouter un curseur
</button>
<label class="pe__sum">
<input v-model="sum100" type="checkbox" @change="sync">
<span>Répartition 100 % — une part est calculée, jamais votée</span>
</label>
<div v-if="sum100" class="pe__derived">
<input v-model="derivedName" class="pe__label" type="text" @input="sync">
<span class="pe__derived-value">calculé — {{ derivedBaseline }} % aujourd'hui</span>
</div>
</div>
</template>
<style scoped>
.pe { display: flex; flex-direction: column; gap: 0.625rem; }
.pe__row {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.625rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
}
.pe__label { padding: 0.5rem 0.75rem; font-size: 0.9375rem; font-weight: 600; width: 100%; }
.pe__nums { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
.pe__num { display: flex; flex-direction: column; gap: 2px; }
.pe__num span {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pe__num input { width: 5.25rem; padding: 0.375rem 0.5rem; font-size: 0.875rem; }
.pe__remove {
background: none;
color: var(--mood-text-muted);
cursor: pointer;
padding: 0.375rem;
border-radius: var(--r-input);
min-height: 2.25rem;
}
.pe__remove:hover { color: var(--mood-error); background: var(--mood-accent-soft); }
.pe__add { align-self: flex-start; font-size: 0.875rem; }
.pe__sum {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.pe__sum input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.pe__derived { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
.pe__derived .pe__label { max-width: 16rem; }
.pe__derived-value {
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
// « Le chemin » — the live card. Big route icon, first-person route name,
// the ONE French explanation sentence (never a rule code), the perimeter /
// duration / threshold strip, the pre-checked follow-ups, ONE main button.
import type { DecisionRoute, Person, Verdict } from '~/types/domain'
import { PATH_CARD_TITLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE } from '~/lexicon'
const props = defineProps<{
path: Verdict
routeShown: DecisionRoute
overridden: boolean
overrideLabel?: string
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
selfOnly: boolean
durationLabel: string
thresholdLabel: string
mainLabel: string
reviewChecked: boolean
engraveChecked: boolean
reviewLocked: boolean
disabled: boolean
blockReason: string
}>()
const emit = defineEmits<{
(e: 'validate'): void
(e: 'update:reviewChecked' | 'update:engraveChecked', value: boolean): void
}>()
const tint = computed(() => `var(--route-${props.routeShown})`)
const showUrgentBadge = computed(
() => props.path.conservatoryChain === true,
)
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card pc" :style="{ '--pc-tint': tint }">
<h2 class="pc__caption">{{ PATH_CARD_TITLE }}</h2>
<div class="pc__head">
<span class="pc__icon"><UIcon :name="ROUTE_ICONS[routeShown]" /></span>
<div class="pc__names">
<span class="pc__route">{{ ROUTE_LABELS[routeShown] }}</span>
<span v-if="overridden" class="pc__override">
Tu choisis autrement{{ overrideLabel ? ` ${overrideLabel}` : '' }}
</span>
</div>
</div>
<p class="pc__explanation">{{ path.explanation }}</p>
<span v-if="showUrgentBadge" class="status-pill status-advice pc__urgent">
<UIcon name="i-lucide-siren" /> {{ URGENT_BADGE }}
</span>
<div class="pc__strip">
<div class="pc__cell">
<span class="pc__cell-label">Périmètre</span>
<span v-if="selfOnly" class="pc__cell-value">moi seul</span>
<LdAvatarStack v-else-if="stack.length > 0" :people="stack" :max="5" :size="24" />
<span v-else class="pc__cell-value">à préciser</span>
</div>
<div class="pc__cell">
<span class="pc__cell-label">Durée</span>
<span class="pc__cell-value">{{ durationLabel || 'aucune attente' }}</span>
</div>
<div v-if="thresholdLabel" class="pc__cell">
<span class="pc__cell-label">Seuil</span>
<span class="pc__cell-value">{{ thresholdLabel }}</span>
</div>
</div>
<div v-if="path.reviewRequired || path.engravingSuggested" class="pc__checks">
<label v-if="path.reviewRequired" class="pc__check">
<input
type="checkbox"
:checked="reviewChecked"
:disabled="reviewLocked"
@change="emit('update:reviewChecked', ($event.target as HTMLInputElement).checked)"
>
<span>Revoyure l'épreuve du réel sera posée</span>
</label>
<label v-if="path.engravingSuggested" class="pc__check">
<input
type="checkbox"
:checked="engraveChecked"
@change="emit('update:engraveChecked', ($event.target as HTMLInputElement).checked)"
>
<span>Gravure — empreinte locale à l'adoption</span>
</label>
</div>
<p v-if="blockReason" class="pc__block">{{ blockReason }}</p>
<button class="ld-btn pc__main" type="button" :disabled="disabled" @click="emit('validate')">
{{ mainLabel }}
</button>
</section>
</template>
<style scoped>
.pc {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
}
.pc__caption {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mood-text-muted);
}
.pc__head { display: flex; align-items: center; gap: 0.875rem; }
.pc__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
border-radius: var(--r-icon);
font-size: 1.625rem;
color: var(--pc-tint);
background: color-mix(in srgb, var(--pc-tint) 14%, transparent);
flex-shrink: 0;
}
.pc__names { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; }
.pc__route { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
.pc__override { font-size: 0.8125rem; font-weight: 600; color: var(--mood-accent); }
.pc__explanation { margin: 0; font-size: 1rem; line-height: 1.5; }
.pc__urgent { align-self: flex-start; }
.pc__strip {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
padding: 0.75rem 0.875rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--pc-tint) 6%, transparent);
}
.pc__cell { display: flex; flex-direction: column; gap: 0.25rem; }
.pc__cell-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.pc__cell-value { font-size: 0.875rem; font-weight: 600; }
.pc__checks { display: flex; flex-direction: column; gap: 0.4rem; }
.pc__check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.pc__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.pc__block { margin: 0; font-size: 0.8125rem; font-weight: 600; color: var(--mood-error); }
.pc__main { align-self: stretch; font-size: 1.0625rem; min-height: 2.75rem; }
@media (min-width: 480px) {
.pc__main { align-self: flex-start; padding: 0.5rem 2rem; }
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
// Q0 « déjà décidé ? » — inline results while typing, from the ONE search
// index (same as Cmd+K). Max 3: standing clauses first, then close decisions.
import type { Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useSearch } from '~/composables/useSearch'
const props = defineProps<{ title: string; linkedClauseId?: Id }>()
const emit = defineEmits<{ (e: 'contest', clauseId: Id): void }>()
const col = useCollectiveStore()
const { search } = useSearch()
interface Q0Hit {
kind: 'clause' | 'decision'
id: Id
label: string
to: string
}
const hits = computed<Q0Hit[]>(() => {
const needle = props.title.trim()
if (needle.length < 3) return []
const out: Q0Hit[] = []
for (const hit of search(needle)) {
if (out.length >= 3) break
if (hit.kind === 'clause') {
const clause = col.clauses.find(c => c.id === hit.id)
const doc = col.docs.find(d => d.id === clause?.docId)
out.push({
kind: 'clause',
id: hit.id,
label: hit.label,
to: doc ? `/textes/${doc.slug}` : '/textes',
})
} else if (hit.kind === 'decision') {
out.push({ kind: 'decision', id: hit.id, label: hit.label, to: `/decisions/${hit.id}` })
}
}
return out
})
</script>
<template>
<!-- ld-v2 -->
<div v-if="hits.length > 0" class="q0">
<div v-for="hit in hits" :key="hit.id" class="q0__row">
<template v-if="hit.kind === 'clause'">
<UIcon name="i-lucide-book-open-check" class="q0__icon" />
<NuxtLink :to="hit.to" class="q0__link">
C'est déjà décidé ({{ hit.label }}) — agis
</NuxtLink>
<button
class="q0__contest"
type="button"
:class="{ 'q0__contest--active': linkedClauseId === hit.id }"
@click="emit('contest', hit.id)"
>
Conteste la règle
</button>
</template>
<template v-else>
<UIcon name="i-lucide-history" class="q0__icon q0__icon--decision" />
<NuxtLink :to="hit.to" class="q0__link">
Décision proche : {{ hit.label }}
</NuxtLink>
</template>
</div>
</div>
</template>
<style scoped>
.q0 {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0;
}
.q0__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
}
.q0__icon { color: var(--mood-accent); flex-shrink: 0; }
.q0__icon--decision { color: var(--mood-text-muted); }
.q0__link {
color: var(--mood-text);
font-weight: 600;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--mood-accent) 45%, transparent);
text-underline-offset: 3px;
}
.q0__link:hover { color: var(--mood-accent); }
.q0__contest {
background: none;
padding: 2px 10px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
cursor: pointer;
}
.q0__contest--active { box-shadow: 0 0 0 2px var(--mood-accent); }
</style>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// « Concernés » — perimeter editor of the tunnel. The computed first-rank
// people are a FLOOR: no individual removal, ever. Widening is free.
import type { Decision, Person } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
const props = defineProps<{
scope: Decision['scope']
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
}>()
const emit = defineEmits<{ (e: 'update:scope', scope: Decision['scope']): void }>()
const col = useCollectiveStore()
const tappedReason = ref('')
const others = computed(() => col.people.filter(p => !p.isMe))
function patch(partial: Partial<Decision['scope']>) {
emit('update:scope', {
selfOnly: props.scope.selfOnly,
circleIds: [...props.scope.circleIds],
personIds: [...props.scope.personIds],
...partial,
})
}
function setSelfOnly(value: boolean) {
patch(value ? { selfOnly: true, circleIds: [], personIds: [] } : { selfOnly: false })
}
function toggleCircle(id: string) {
const ids = props.scope.circleIds.includes(id)
? props.scope.circleIds.filter(c => c !== id)
: [...props.scope.circleIds, id]
patch({ selfOnly: false, circleIds: ids })
}
function togglePerson(id: string) {
const ids = props.scope.personIds.includes(id)
? props.scope.personIds.filter(p => p !== id)
: [...props.scope.personIds, id]
patch({ selfOnly: false, personIds: ids })
}
function showReason(entry: { person: Person; reason?: string }) {
tappedReason.value = entry.reason
? `${entry.person.displayName}${entry.reason}`
: entry.person.displayName
}
</script>
<template>
<!-- ld-v2 -->
<div class="cs">
<div class="cs__modes">
<button
class="cs__mode"
:class="{ 'cs__mode--active': scope.selfOnly }"
type="button"
@click="setSelfOnly(true)"
>
Moi seul
</button>
<button
class="cs__mode"
:class="{ 'cs__mode--active': !scope.selfOnly }"
type="button"
@click="setSelfOnly(false)"
>
D'autres que moi
</button>
</div>
<template v-if="!scope.selfOnly">
<div v-if="stack.length > 0" class="cs__stack">
<LdAvatarStack :people="stack" :max="8" :size="30" @tap="showReason" />
<span class="cs__count">{{ stack.length }} concerné·e·s</span>
</div>
<p v-if="tappedReason" class="cs__reason">{{ tappedReason }}</p>
<div v-if="col.circles.length > 0" class="cs__group">
<span class="cs__label">Cercles</span>
<div class="cs__chips">
<button
v-for="circle in col.circles"
:key="circle.id"
class="cs__chip"
:class="{ 'cs__chip--on': scope.circleIds.includes(circle.id) }"
type="button"
@click="toggleCircle(circle.id)"
>
<UIcon
:name="circle.kind === 'place' ? 'i-lucide-map-pin'
: circle.kind === 'theme' ? 'i-lucide-tag' : 'i-lucide-users'"
/>
<span>{{ circle.name }}</span>
</button>
</div>
</div>
<div v-if="others.length > 0" class="cs__group">
<span class="cs__label">Personnes nommées</span>
<div class="cs__chips">
<button
v-for="person in others"
:key="person.id"
class="cs__chip"
:class="{ 'cs__chip--on': scope.personIds.includes(person.id) }"
type="button"
@click="togglePerson(person.id)"
>
{{ person.displayName }}
</button>
</div>
</div>
<p class="cs__floor">
On ne retire pas un·e concerné·e en premier lieu — le périmètre s'élargit librement,
jamais en dessous du calcul.
</p>
</template>
</div>
</template>
<style scoped>
.cs { display: flex; flex-direction: column; gap: 0.75rem; }
.cs__modes { display: flex; gap: 0.5rem; }
.cs__mode {
padding: 0.4rem 1rem;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 700;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
}
.cs__mode--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.cs__stack { display: flex; align-items: center; gap: 0.625rem; }
.cs__count { font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted); }
.cs__reason { margin: 0; font-size: 0.8125rem; color: var(--mood-accent); }
.cs__group { display: flex; flex-direction: column; gap: 0.4rem; }
.cs__label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.cs__chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.cs__chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 600;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
min-height: 2.25rem;
}
.cs__chip--on {
background: var(--mood-accent-soft);
color: var(--mood-accent);
box-shadow: 0 0 0 1.5px var(--mood-accent);
}
.cs__floor {
margin: 0;
font-size: 0.75rem;
font-style: italic;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// R6 / maturation overlay under the path card — a discreet suggestion,
// never blocking, never re-routing.
import type { Verdict } from '~/types/domain'
import { MATURATION_CARD } from '~/lexicon'
defineProps<{ suggestion: NonNullable<Verdict['suggestion']> }>()
const mandateAttached = defineModel<boolean>('mandateAttached', { required: true })
</script>
<template>
<!-- ld-v2 -->
<div class="ld-card sug">
<template v-if="suggestion.kind === 'claim-mandate'">
<UIcon name="i-lucide-repeat" class="sug__icon" />
<span>Encore cette décision réclame un mandat pour ne plus y revenir.</span>
<label class="sug__check">
<input v-model="mandateAttached" type="checkbox">
<span>Joindre la demande de mandat</span>
</label>
</template>
<template v-else-if="suggestion.kind === 'protocolize'">
<UIcon name="i-lucide-sprout" class="sug__icon" />
<NuxtLink to="/textes" class="sug__link">{{ MATURATION_CARD }}</NuxtLink>
</template>
<template v-else>
<UIcon name="i-lucide-book-open-check" class="sug__icon" />
<NuxtLink to="/textes" class="sug__link">
Cette pratique revient crée une règle en quelques phrases.
</NuxtLink>
</template>
</div>
</template>
<style scoped>
.sug {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.sug__icon { font-size: 1.125rem; color: var(--mood-accent); flex-shrink: 0; }
.sug__check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
color: var(--mood-accent);
}
.sug__check input { accent-color: var(--mood-accent); }
.sug__link { color: var(--mood-text); }
.sug__link:hover { color: var(--mood-accent); }
</style>
@@ -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',
})
}
@@ -0,0 +1,61 @@
<script setup lang="ts">
// One line of the collective's activity — route icon, title → fiche, status pill.
import type { Decision } from '~/types/domain'
import { ROUTE_ICONS, STATUS_LABELS } from '~/lexicon'
const props = defineProps<{ decision: Decision }>()
const when = computed(() =>
new Date(props.decision.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
}),
)
</script>
<template>
<!-- ld-v2 -->
<NuxtLink :to="`/decisions/${decision.id}`" class="ld-card ld-card--hover fac">
<UIcon
:name="ROUTE_ICONS[decision.route]"
class="fac__icon"
:style="{ color: `var(--route-${decision.route})` }"
/>
<span class="fac__title">{{ decision.title }}</span>
<span class="status-pill fac__pill" :class="`status-${decision.status}`">
{{ STATUS_LABELS[decision.status] }}
</span>
<span class="fac__when">{{ when }}</span>
</NuxtLink>
</template>
<style scoped>
.fac {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
text-decoration: none;
color: var(--mood-text);
}
.fac__icon { font-size: 1rem; flex-shrink: 0; }
.fac__title {
flex: 1;
min-width: 0;
font-size: 0.9063rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fac__pill { flex-shrink: 0; }
.fac__when {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--mood-text-muted);
display: none;
}
@media (min-width: 480px) {
.fac__when { display: inline; }
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
// Advice request card — three inline positions, one tap each.
import type { Advice, Decision } from '~/types/domain'
import { ROUTE_ICONS } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const error = ref('')
const POSITIONS: { value: Advice['position']; label: string; icon: string }[] = [
{ value: 'favorable', label: 'Favorable', icon: 'i-lucide-thumbs-up' },
{ value: 'reserved', label: 'Réservé', icon: 'i-lucide-minus' },
{ value: 'unfavorable', label: 'Défavorable', icon: 'i-lucide-thumbs-down' },
]
const mine = computed(() =>
col.advices.find(a => a.decisionId === props.decision.id && a.personId === col.me?.id),
)
const mineLabel = computed(
() => POSITIONS.find(p => p.value === mine.value?.position)?.label ?? '',
)
function give(position: Advice['position']) {
const res = decisions.adviseOn(props.decision.id, position)
error.value = 'ok' in res ? res.reason : ''
}
</script>
<template>
<!-- ld-v2 -->
<FeedItemCard
:title="decision.title"
:icon="ROUTE_ICONS.advice"
tint="var(--route-advice)"
:to="`/decisions/${decision.id}`"
>
<template #meta>
<LdCountdown :ends-at="decision.windowEndsAt" :suspended-at="decision.windowSuspendedAt" />
</template>
<p v-if="error" class="fa-error">{{ error }}</p>
<template #actions>
<span v-if="mine" class="fa-done">
<UIcon name="i-lucide-check" /> Ton avis est déposé {{ mineLabel }}
</span>
<template v-else>
<button
v-for="p in POSITIONS"
:key="p.value"
class="ld-btn ld-btn--ghost fa-btn"
type="button"
@click="give(p.value)"
>
<UIcon :name="p.icon" />
<span>{{ p.label }}</span>
</button>
</template>
</template>
</FeedItemCard>
</template>
<style scoped>
.fa-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
.fa-done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-status-vigueur);
}
.fa-btn { font-size: 0.875rem; padding: 0.4rem 0.875rem; }
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
// Sticky capture bar — the ONE entry of every decision (gesture 1 of 2).
// Enter → /decider?titre=… ; nothing else is mandatory.
import { CAPTURE_PLACEHOLDER } from '~/lexicon'
const text = ref('')
function go() {
const title = text.value.trim()
if (title.length === 0) return
navigateTo({ path: '/decider', query: { titre: title } })
}
</script>
<template>
<!-- ld-v2 -->
<div class="feed-capture">
<div class="ld-card feed-capture__card">
<UIcon name="i-lucide-zap" class="feed-capture__icon" />
<input
v-model="text"
type="text"
class="feed-capture__input"
:placeholder="CAPTURE_PLACEHOLDER"
:aria-label="CAPTURE_PLACEHOLDER"
enterkeyhint="go"
@keydown.enter.prevent="go"
>
<button
v-if="text.trim().length > 0"
class="ld-btn feed-capture__go"
type="button"
@click="go"
>
<UIcon name="i-lucide-arrow-right" />
</button>
</div>
</div>
</template>
<style scoped>
.feed-capture {
position: sticky;
top: 3.5rem;
z-index: 20;
padding: 0.5rem 0 0.75rem;
background: var(--mood-bg);
}
.feed-capture__card {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem 0.5rem 1rem;
}
.feed-capture__icon {
font-size: 1.125rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.feed-capture__input {
flex: 1;
min-width: 0;
background: none;
font-size: 1.0625rem;
font-weight: 600;
padding: 0.5rem 0;
}
.feed-capture__input:focus-visible { box-shadow: none; }
.feed-capture__input::placeholder {
color: var(--mood-text-muted);
font-weight: 500;
}
.feed-capture__go {
padding: 0.5rem 0.875rem;
min-height: 2.25rem;
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
// Generic compact card of the Fil: icon container, title (optionally a link),
// meta line (slot #meta), body (default slot), inline actions (slot #actions).
defineProps<{
title: string
icon?: string
tint?: string
to?: string
}>()
</script>
<template>
<!-- ld-v2 -->
<article class="ld-card feed-item">
<div v-if="icon" class="feed-item__stamp" :style="tint ? { color: tint } : undefined">
<UIcon :name="icon" />
</div>
<div class="feed-item__body">
<NuxtLink v-if="to" :to="to" class="feed-item__title feed-item__title--link">
{{ title }}
</NuxtLink>
<span v-else class="feed-item__title">{{ title }}</span>
<div v-if="$slots.meta" class="feed-item__meta">
<slot name="meta" />
</div>
<slot />
<div v-if="$slots.actions" class="feed-item__actions">
<slot name="actions" />
</div>
</div>
</article>
</template>
<style scoped>
.feed-item {
display: flex;
gap: 0.75rem;
padding: 0.875rem 1rem;
}
.feed-item__stamp {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--r-icon);
background: color-mix(in srgb, currentColor 12%, transparent);
font-size: 1.125rem;
color: var(--mood-accent);
}
.feed-item__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.feed-item__title {
font-size: 0.9688rem;
font-weight: 700;
line-height: 1.35;
color: var(--mood-text);
overflow-wrap: anywhere;
}
.feed-item__title--link { text-decoration: none; }
.feed-item__title--link:hover { color: var(--mood-accent); }
.feed-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.feed-item__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.125rem;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
// « L'épreuve du réel » card — « Le réel a-t-il suivi ? », three inline answers.
import type { Decision } from '~/types/domain'
import { REVIEW_QUESTION, REVIEW_VERDICTS } from '~/lexicon'
import { useDecisionsStore } from '~/stores/decisions'
const props = defineProps<{ decision: Decision }>()
const decisions = useDecisionsStore()
const error = ref('')
const answered = ref<'confirmed' | 'revise' | 'revoke' | null>(null)
// Aliased: the lexicon export name is a code identifier, kept out of templates.
const ANSWER_LABELS = REVIEW_VERDICTS
const ANSWERS = [
{ value: 'confirmed', icon: 'i-lucide-check' },
{ value: 'revise', icon: 'i-lucide-pencil' },
{ value: 'revoke', icon: 'i-lucide-undo-2' },
] as const
function answer(value: 'confirmed' | 'revise' | 'revoke') {
const res = decisions.reviewVerdict(props.decision.id, value)
if ('ok' in res) {
error.value = res.reason
return
}
error.value = ''
answered.value = value
}
</script>
<template>
<!-- ld-v2 -->
<FeedItemCard
:title="decision.title"
icon="i-lucide-flask-conical"
tint="var(--mood-tertiary)"
:to="`/decisions/${decision.id}`"
>
<template #meta>
<span class="fr-question">{{ REVIEW_QUESTION }}</span>
</template>
<p v-if="error" class="fr-error">{{ error }}</p>
<template #actions>
<span v-if="answered" class="fr-done">
<UIcon name="i-lucide-check" /> {{ ANSWER_LABELS[answered] }} c'est noté
</span>
<template v-else>
<button
v-for="a in ANSWERS"
:key="a.value"
class="ld-btn ld-btn--ghost fr-btn"
type="button"
@click="answer(a.value)"
>
<UIcon :name="a.icon" />
<span>{{ ANSWER_LABELS[a.value] }}</span>
</button>
</template>
</template>
</FeedItemCard>
</template>
<style scoped>
.fr-question { font-weight: 700; font-style: italic; color: var(--mood-tertiary, var(--mood-accent)); }
.fr-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
.fr-done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-status-vigueur);
}
.fr-btn { font-size: 0.875rem; padding: 0.4rem 0.875rem; }
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// One section of the Fil — header + stacked items. Empty sections are hidden
// by the PARENT (v-if on length), never here.
defineProps<{
title: string
icon?: string
discrete?: boolean
priority?: boolean
}>()
</script>
<template>
<!-- ld-v2 -->
<section
class="feed-section"
:class="{ 'feed-section--discrete': discrete, 'feed-section--priority': priority }"
>
<h2 class="feed-section__title">
<UIcon v-if="icon" :name="icon" class="feed-section__icon" />
<span>{{ title }}</span>
</h2>
<div class="feed-section__items">
<slot />
</div>
</section>
</template>
<style scoped>
.feed-section { margin-bottom: 1.75rem; }
.feed-section__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.625rem;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--mood-text-muted);
}
.feed-section--priority .feed-section__title { color: var(--mood-status-fenetre); }
.feed-section--discrete .feed-section__title {
font-size: 0.8125rem;
opacity: 0.8;
}
.feed-section__icon { font-size: 1rem; }
.feed-section__items {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.feed-section--discrete .feed-section__items { opacity: 0.85; }
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
// Open vote card — miniature participation gauge + link to the vote room.
// The gauge shows who already spoke, never a result (results live in the room).
import type { Decision, VoteSession } from '~/types/domain'
import { ROUTE_ICONS } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
const props = defineProps<{ session: VoteSession; decision?: Decision }>()
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const voted = computed(() => decisions.activeVotes(props.session.id).length)
const total = computed(() => Math.max(props.session.corpusSize, 1))
const pct = computed(() => Math.min(100, Math.round((voted.value / total.value) * 100)))
const iVoted = computed(() =>
decisions.activeVotes(props.session.id).some(v => v.voterId === col.me?.id),
)
const title = computed(() => props.decision?.title ?? 'Décision en vote')
const roomLink = computed(() =>
props.decision ? `/decisions/${props.decision.id}/vote` : undefined,
)
</script>
<template>
<!-- ld-v2 -->
<FeedItemCard
:title="title"
:icon="ROUTE_ICONS.collective"
tint="var(--route-collective)"
:to="roomLink"
>
<template #meta>
<LdCountdown :ends-at="session.closesAt" />
<span>{{ voted }} / {{ total }} se sont prononcés</span>
</template>
<div class="fv-gauge" role="img" :aria-label="`Participation : ${pct} %`">
<div class="fv-gauge__fill" :style="{ width: pct + '%' }" />
</div>
<template #actions>
<NuxtLink v-if="roomLink" :to="roomLink" class="ld-btn fv-btn">
<UIcon name="i-lucide-door-open" />
<span>{{ iVoted ? 'Revoir mon vote' : 'Entrer dans la salle' }}</span>
</NuxtLink>
</template>
</FeedItemCard>
</template>
<style scoped>
.fv-gauge {
height: 6px;
border-radius: 3px;
background: var(--mood-status-vote-bg);
overflow: hidden;
}
.fv-gauge__fill {
height: 100%;
border-radius: 3px;
background: var(--mood-status-vote);
transition: width 0.3s ease;
}
.fv-btn { font-size: 0.875rem; padding: 0.4rem 1rem; text-decoration: none; }
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
// Objection window card — « Ça me va » creates an Assent; « J'objecte » unfolds
// a small argument form (content or boundary). The countdown pauses when the
// boundary is contested; non-easy windows show the explicit-agreement mention.
import type { Decision } from '~/types/domain'
import { ASSENT_MISSING, ROUTE_ICONS, WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
const props = defineProps<{ decision: Decision }>()
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const objecting = ref(false)
const argument = ref('')
const error = ref('')
const myAssent = computed(() =>
col.assents.some(a => a.decisionId === props.decision.id && a.personId === col.me?.id),
)
const myObjection = computed(() =>
col.objections.some(
o => o.decisionId === props.decision.id && o.personId === col.me?.id && o.status === 'open',
),
)
/** Non-easy windows adopt only on an explicit third-party agreement. */
const needsExplicitAssent = computed(
() =>
props.decision.reversibility !== 'easy'
&& !col.assents.some(
a => a.decisionId === props.decision.id && a.personId !== props.decision.authorId,
),
)
function agree() {
const res = decisions.assentTo(props.decision.id)
error.value = 'ok' in res ? res.reason : ''
}
function sendObjection(kind: 'content' | 'boundary') {
const res = decisions.objectTo(props.decision.id, kind, argument.value)
if ('ok' in res) {
error.value = res.reason
return
}
error.value = ''
argument.value = ''
objecting.value = false
}
</script>
<template>
<!-- ld-v2 -->
<FeedItemCard
:title="decision.title"
:icon="ROUTE_ICONS[decision.route]"
:tint="`var(--route-${decision.route})`"
:to="`/decisions/${decision.id}`"
>
<template #meta>
<LdCountdown :ends-at="decision.windowEndsAt" :suspended-at="decision.windowSuspendedAt" />
<span v-if="needsExplicitAssent" class="fw-mention">{{ ASSENT_MISSING }}</span>
</template>
<p v-if="error" class="fw-error">{{ error }}</p>
<template #actions>
<template v-if="myAssent">
<span class="fw-done"><UIcon name="i-lucide-check" /> Ton accord est noté</span>
</template>
<template v-else-if="myObjection">
<span class="fw-done"><UIcon name="i-lucide-hand" /> Ton objection est déposée</span>
</template>
<template v-else-if="!objecting">
<button class="ld-btn fw-btn" type="button" @click="agree">{{ WINDOW_OK }}</button>
<button class="ld-btn ld-btn--ghost fw-btn" type="button" @click="objecting = true">
{{ WINDOW_OBJECT }}
</button>
</template>
<div v-else class="fw-form">
<textarea
v-model="argument"
class="fw-form__text"
rows="2"
placeholder="Une objection s'argumente — écris pourquoi."
/>
<div class="fw-form__row">
<button class="ld-btn fw-btn" type="button" @click="sendObjection('content')">
Sur le fond
</button>
<button class="ld-btn ld-btn--ghost fw-btn" type="button" @click="sendObjection('boundary')">
Sur la frontière
</button>
<button class="ld-btn ld-btn--quiet fw-btn" type="button" @click="objecting = false">
Annuler
</button>
</div>
</div>
</template>
</FeedItemCard>
</template>
<style scoped>
.fw-mention {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-status-fenetre);
}
.fw-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
.fw-done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-status-vigueur);
}
.fw-btn { font-size: 0.875rem; padding: 0.4rem 1rem; }
.fw-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
.fw-form__text { width: 100%; padding: 0.625rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
.fw-form__row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
</style>
+122 -72
View File
@@ -1,82 +1,132 @@
<script setup lang="ts">
/**
* Card component for displaying a mandate in a list.
*
* Shows title, type badge, status badge, mandatee, date range.
* Navigates to the mandate detail page on click.
*/
import type { Mandate } from '~/stores/mandates'
// <!-- ld-v2 --> Carte mandat — titulaire, domaine, échéance, prochain rapport,
// COMPTEURS BRUTS (Δ7 : aucune jauge, aucun agrégat — des faits comptés).
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import {
MANDATE_STATUS_LABELS,
MANDATE_STATUS_PILL,
formatDay,
mandateFacts,
} from './mandateUi'
const props = defineProps<{
mandate: Mandate
}>()
const props = defineProps<{ mandate: Mandate }>()
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
const col = useCollectiveStore()
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/mandates/${props.mandate.id}`)
}
const holder = computed(() => col.people.find(p => p.id === props.mandate.holderId))
const domainCircles = computed(() =>
props.mandate.domain.circleIds
.map(id => col.circles.find(c => c.id === id))
.filter(c => c !== undefined),
)
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
</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-user-check" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ mandate.title }}
</h3>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ mandate.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ mandate.steps.length }} etape(s)
</span>
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span class="block font-medium">Debut</span>
{{ formatDate(mandate.starts_at) }}
</div>
<div>
<span class="block font-medium">Fin</span>
{{ formatDate(mandate.ends_at) }}
</div>
<!-- ld-v2 -->
<NuxtLink :to="`/mandats/${mandate.id}`" class="mcard ld-card ld-card--hover">
<div class="mcard__head">
<LdAvatarStack v-if="holder" :people="[{ person: holder }]" :size="34" />
<div class="mcard__who">
<p class="mcard__title">{{ mandate.title }}</p>
<p v-if="holder" class="mcard__holder">{{ holder.displayName }}</p>
</div>
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
{{ MANDATE_STATUS_LABELS[mandate.status] }}
</span>
</div>
</UCard>
<div class="mcard__domain">
<span v-for="c in domainCircles" :key="c.id" class="mcard__chip">
<UIcon name="i-lucide-circle-dashed" />
{{ c.name }}
</span>
<span v-for="tag in mandate.domain.tags" :key="tag" class="mcard__tag">#{{ tag }}</span>
</div>
<div class="mcard__meta">
<span class="mcard__meta-item">
<UIcon name="i-lucide-calendar-range" />
jusqu'au {{ formatDay(mandate.endsAt) }}
</span>
<span v-if="facts.nextReportDueAt" class="mcard__meta-item mcard__meta-item--due">
<UIcon name="i-lucide-file-clock" />
rapport le {{ formatDay(facts.nextReportDueAt) }}
</span>
</div>
<p class="mcard__facts">{{ facts.line }}</p>
</NuxtLink>
</template>
<style scoped>
.mcard {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.125rem 1.25rem;
text-decoration: none;
color: var(--mood-text);
}
.mcard__head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.mcard__who { flex: 1; min-width: 0; }
.mcard__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.3;
}
.mcard__holder {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mcard__domain {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.mcard__chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
}
.mcard__tag {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mcard__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 1rem;
}
.mcard__meta-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mcard__meta-item--due { color: var(--mood-status-fenetre); font-weight: 600; }
.mcard__facts {
margin: 0;
padding-top: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--mood-text);
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-accent) 10%, transparent);
}
</style>
@@ -0,0 +1,248 @@
<script setup lang="ts">
// <!-- ld-v2 --> « Exercice du mandat » (Δ7) — des FAITS COMPTÉS, jamais
// synthétisés : décisions tracées, objections listées, rapports rendus/dus.
// Le titulaire rend son rapport ici quand il est dû — contenu dépliable.
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_EXERCISE, STATUS_LABELS } from '~/lexicon'
import {
OBJECTION_STATUS_LABELS,
formatDay,
mandateFacts,
reportIntervalMs,
} from './mandateUi'
const props = defineProps<{ mandate: Mandate }>()
const col = useCollectiveStore()
const facts = computed(() => mandateFacts(props.mandate, col.decisions, col.objections))
const objectionRows = computed(() =>
facts.value.objections.map(o => ({
id: o.id,
decisionId: o.decisionId,
decisionTitle: col.decisions.find(d => d.id === o.decisionId)?.title ?? '—',
argument: o.argument,
statusLabel: OBJECTION_STATUS_LABELS[o.status],
open: o.status === 'open',
})),
)
const sortedReports = computed(() =>
[...props.mandate.reports].sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1)),
)
/** Le rapport dû maintenant, à rendre par le titulaire (moi). */
const dueReport = computed(() => {
if (col.me?.id !== props.mandate.holderId) return undefined
if (props.mandate.status !== 'active') return undefined
return sortedReports.value.find(r => r.deliveredAt === undefined && r.dueAt <= col.now())
})
const reportText = ref('')
function deliverReport(): void {
const target = dueReport.value
if (!target || reportText.value.trim().length === 0) return
target.deliveredAt = col.now()
target.content = reportText.value.trim()
// Cadence : le prochain rapport garde l'espacement existant, borné à l'échéance.
const interval = reportIntervalMs(props.mandate)
if (interval !== undefined) {
const nextDue = new Date(new Date(target.dueAt).getTime() + interval).toISOString()
if (nextDue < props.mandate.endsAt) props.mandate.reports.push({ dueAt: nextDue })
}
col.stamp(props.mandate)
col.persist()
reportText.value = ''
}
</script>
<template>
<!-- ld-v2 -->
<section class="mex ld-card">
<h2 class="mex__title">
<UIcon name="i-lucide-list-checks" />
{{ MANDATE_EXERCISE }}
</h2>
<p class="mex__facts">{{ facts.line }}</p>
<!-- Décisions tracées -->
<div v-if="facts.traced.length" class="mex__block">
<h3 class="mex__block-title">Décisions tracées</h3>
<ul class="mex__list">
<li v-for="d in facts.traced" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="mex__row">
<span class="mex__row-title">{{ d.title }}</span>
<span class="status-pill" :class="`status-${d.status}`">
{{ STATUS_LABELS[d.status] }}
</span>
</NuxtLink>
</li>
</ul>
</div>
<!-- Objections liste cliquable -->
<div v-if="objectionRows.length" class="mex__block">
<h3 class="mex__block-title">Objections</h3>
<ul class="mex__list">
<li v-for="o in objectionRows" :key="o.id">
<NuxtLink :to="`/decisions/${o.decisionId}`" class="mex__row">
<span class="mex__row-title">{{ o.argument }}</span>
<span class="mex__obj-status" :class="{ 'mex__obj-status--open': o.open }">
{{ o.statusLabel }}
</span>
</NuxtLink>
</li>
</ul>
</div>
<!-- Rapports rendus dépliables, saisie quand -->
<div v-if="sortedReports.length || dueReport" class="mex__block">
<h3 class="mex__block-title">
Rapports {{ facts.reportsDelivered }}/{{ facts.reportsTotal }} rendus
</h3>
<ul class="mex__list">
<li v-for="r in sortedReports" :key="r.dueAt">
<details v-if="r.deliveredAt" class="mex__report">
<summary class="mex__report-summary">
<UIcon name="i-lucide-file-check-2" />
<span>Rapport rendu le {{ formatDay(r.deliveredAt) }}</span>
<span class="mex__report-due"> le {{ formatDay(r.dueAt) }}</span>
</summary>
<p class="mex__report-content">{{ r.content }}</p>
</details>
<div v-else class="mex__report mex__report--pending">
<UIcon name="i-lucide-file-clock" />
<span>Rapport le {{ formatDay(r.dueAt) }}</span>
</div>
</li>
</ul>
<form v-if="dueReport" class="mex__deliver" @submit.prevent="deliverReport">
<label class="mex__deliver-label" for="mex-report">
Ton rapport est quelques phrases suffisent.
</label>
<textarea
id="mex-report"
v-model="reportText"
rows="4"
lang="fr"
spellcheck="true"
placeholder="Ce qui a été fait, ce qui engage la suite…"
/>
<button type="submit" class="ld-btn" :disabled="reportText.trim().length === 0">
<UIcon name="i-lucide-send" />
Rendre le rapport
</button>
</form>
</div>
</section>
</template>
<style scoped>
.mex { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
.mex__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
}
.mex__facts {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.mex__block { display: flex; flex-direction: column; gap: 0.5rem; }
.mex__block-title {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.mex__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.mex__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
}
.mex__row:hover { transform: translateY(-1px); }
.mex__row-title {
font-size: 0.875rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mex__obj-status {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.mex__obj-status--open { color: var(--mood-status-fenetre); }
.mex__report {
border-radius: var(--r-input);
background: var(--mood-accent-soft);
padding: 0.5rem 0.75rem;
}
.mex__report-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
list-style: none;
}
.mex__report-summary::-webkit-details-marker { display: none; }
.mex__report-due { margin-left: auto; font-size: 0.75rem; color: var(--mood-text-muted); }
.mex__report-content {
margin: 0.625rem 0 0.25rem;
font-size: 0.875rem;
line-height: 1.55;
color: var(--mood-text);
white-space: pre-line;
}
.mex__report--pending {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
}
.mex__deliver { display: flex; flex-direction: column; gap: 0.625rem; margin-top: 0.375rem; }
.mex__deliver-label { font-size: 0.875rem; font-weight: 600; }
.mex__deliver textarea {
width: 100%;
padding: 0.625rem 0.875rem;
font: inherit;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-input-bg);
border: none;
border-radius: var(--r-input);
box-shadow: inset 0 0 0 1px var(--mood-input-border);
resize: vertical;
}
.mex__deliver textarea:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--mood-input-focus);
}
.mex__deliver .ld-btn { align-self: flex-start; }
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
// <!-- ld-v2 --> Étape 2 du wizard mandat — les 6 modalités de nomination,
// avec leurs pédagogies courtes (pro / limite). Pour l'élection sans candidat,
// la règle de clôture est affichée : vous départagez, jamais l'outil.
import type { NominationMethod } from '~/types/domain'
import { NOMINATION_LABELS } from '~/lexicon'
import { NOMINATION_PEDAGOGY } from './mandateUi'
const props = defineProps<{ modelValue: NominationMethod | null }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: NominationMethod): void }>()
const METHODS = Object.keys(NOMINATION_LABELS) as NominationMethod[]
function select(method: NominationMethod): void {
emit('update:modelValue', method)
}
</script>
<template>
<!-- ld-v2 -->
<div class="mnom">
<button
v-for="method in METHODS"
:key="method"
type="button"
class="mnom__card"
:class="{ 'mnom__card--active': props.modelValue === method }"
@click="select(method)"
>
<div class="mnom__head">
<span class="mnom__icon">
<UIcon :name="NOMINATION_PEDAGOGY[method].icon" />
</span>
<span class="mnom__name">{{ NOMINATION_LABELS[method] }}</span>
<UIcon
:name="props.modelValue === method ? 'i-lucide-circle-check' : 'i-lucide-circle'"
class="mnom__check"
/>
</div>
<p class="mnom__desc">{{ NOMINATION_PEDAGOGY[method].desc }}</p>
<p class="mnom__pro">
<UIcon name="i-lucide-plus" />
{{ NOMINATION_PEDAGOGY[method].pro }}
</p>
<p class="mnom__con">
<UIcon name="i-lucide-minus" />
{{ NOMINATION_PEDAGOGY[method].con }}
</p>
<p v-if="NOMINATION_PEDAGOGY[method].rule" class="mnom__rule">
<UIcon name="i-lucide-scale" />
{{ NOMINATION_PEDAGOGY[method].rule }}
</p>
</button>
</div>
</template>
<style scoped>
.mnom {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
@media (min-width: 768px) {
.mnom { grid-template-columns: 1fr 1fr; }
}
.mnom__card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem 1.125rem;
text-align: left;
background: var(--mood-surface);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
cursor: pointer;
color: var(--mood-text);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mnom__card:hover { transform: translateY(-3px); box-shadow: var(--shadow-card-hover); }
.mnom__card:active { transform: translateY(0); }
.mnom__card--active {
box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card);
background: var(--mood-accent-soft);
}
.mnom__head { display: flex; align-items: center; gap: 0.625rem; }
.mnom__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.125rem;
height: 2.125rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.0625rem;
flex-shrink: 0;
}
.mnom__name { flex: 1; font-size: 0.9375rem; font-weight: 700; }
.mnom__check { color: var(--mood-accent); flex-shrink: 0; }
.mnom__desc { margin: 0; font-size: 0.8438rem; line-height: 1.5; color: var(--mood-text-muted); }
.mnom__pro,
.mnom__con,
.mnom__rule {
display: flex;
align-items: baseline;
gap: 0.375rem;
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
}
.mnom__pro { color: var(--mood-success); }
.mnom__con { color: var(--mood-text-muted); }
.mnom__rule {
margin-top: 0.25rem;
padding: 0.375rem 0.625rem;
border-radius: var(--r-input);
background: var(--mood-status-vote-bg);
color: var(--mood-status-vote);
font-weight: 600;
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
// <!-- ld-v2 --> Période du mandat — barre de temps simple : début, fin,
// marqueur aujourd'hui. Un fait daté, pas une jauge de quoi que ce soit.
import { formatDay } from './mandateUi'
const props = defineProps<{ startsAt: string; endsAt: string }>()
const pct = computed(() => {
const start = new Date(props.startsAt).getTime()
const end = new Date(props.endsAt).getTime()
if (end <= start) return 100
return Math.min(100, Math.max(0, ((Date.now() - start) / (end - start)) * 100))
})
</script>
<template>
<!-- ld-v2 -->
<div class="mperiod">
<div class="mperiod__track">
<div class="mperiod__fill" :style="{ width: `${pct}%` }" />
<div class="mperiod__today" :style="{ left: `${pct}%` }" title="aujourd'hui" />
</div>
<div class="mperiod__dates">
<span>{{ formatDay(startsAt) }}</span>
<span>{{ formatDay(endsAt) }}</span>
</div>
</div>
</template>
<style scoped>
.mperiod { display: flex; flex-direction: column; gap: 0.375rem; }
.mperiod__track {
position: relative;
height: 8px;
border-radius: 4px;
background: var(--mood-accent-soft);
}
.mperiod__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 4px 0 0 4px;
background: var(--mood-accent);
opacity: 0.55;
}
.mperiod__today {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mood-accent);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 2px var(--mood-surface);
}
.mperiod__dates {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--mood-text-muted);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
// <!-- ld-v2 --> « Feux de la rampe » (Δ7) — le flux public des décisions
// prises sous ce mandat, chacune avec sa fenêtre : contestables, donc visibles.
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_SPOTLIGHT, MANDATE_SPOTLIGHT_SUB, STATUS_LABELS } from '~/lexicon'
import { formatDay } from './mandateUi'
const props = defineProps<{ mandate: Mandate }>()
const col = useCollectiveStore()
const traced = computed(() =>
col.decisions
.filter(d => d.underMandateId === props.mandate.id)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
)
</script>
<template>
<!-- ld-v2 -->
<section class="mspot ld-card">
<h2 class="mspot__title">
<UIcon name="i-lucide-lamp" />
{{ MANDATE_SPOTLIGHT }}
</h2>
<p class="mspot__sub">{{ MANDATE_SPOTLIGHT_SUB }}</p>
<ul v-if="traced.length" class="mspot__list">
<li v-for="d in traced" :key="d.id" class="mspot__item">
<NuxtLink :to="`/decisions/${d.id}`" class="mspot__link">
<div class="mspot__main">
<span class="mspot__decision-title">{{ d.title }}</span>
<span class="mspot__date">{{ formatDay(d.decidedAt ?? d.createdAt) }}</span>
</div>
<div class="mspot__side">
<LdCountdown
v-if="d.windowEndsAt && !d.decidedAt"
:ends-at="d.windowEndsAt"
:suspended-at="d.windowSuspendedAt"
/>
<span class="status-pill" :class="`status-${d.status}`">
{{ STATUS_LABELS[d.status] }}
</span>
</div>
</NuxtLink>
</li>
</ul>
<p v-else class="mspot__empty">
Aucune décision sous ce mandat pour l'instant — les feux s'allumeront à la première trace.
</p>
</section>
</template>
<style scoped>
.mspot { padding: 1.375rem 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mspot__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
}
.mspot__sub {
margin: -0.375rem 0 0;
font-size: 0.875rem;
font-style: italic;
color: var(--mood-text-muted);
}
.mspot__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.mspot__link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
flex-wrap: wrap;
}
.mspot__link:hover { transform: translateY(-1px); }
.mspot__main { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; flex: 1; }
.mspot__decision-title {
font-size: 0.9375rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mspot__date { font-size: 0.75rem; color: var(--mood-text-muted); }
.mspot__side { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.mspot__empty { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); font-style: italic; }
</style>
@@ -1,138 +0,0 @@
<script setup lang="ts">
/**
* Visual timeline for mandate lifecycle steps.
*
* Displays each step with its type, status, and visual indicators.
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
*/
import type { MandateStep } from '~/stores/mandates'
const props = defineProps<{
steps: MandateStep[]
currentStatus: string
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'Candidature'
case 'voting': return 'Vote'
case 'active': return 'Actif'
case 'reporting': return 'Rapport'
case 'completed': return 'Termine'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'i-lucide-user-plus'
case 'voting': return 'i-lucide-vote'
case 'active': return 'i-lucide-shield-check'
case 'reporting': return 'i-lucide-file-text'
case 'completed': return 'i-lucide-check-circle'
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 ce mandat</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-red-500 border-red-500': step.status === 'revoked',
'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"
/>
<UIcon
v-else-if="step.status === 'revoked'"
name="i-lucide-x"
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="mandate" />
</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>
<div v-if="step.vote_session_id" class="mt-2">
<UButton
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
// <!-- ld-v2 --> Barres horizontales sobres de l'Observatoire — une mesure,
// une teinte (l'accent du mood, jamais codée en dur) ; l'identité est portée
// par le libellé, la grandeur par la longueur. Extrémité arrondie côté donnée,
// base carrée sur la ligne de base (specs dataviz).
export interface BarItem {
label: string
value: number
display?: string
}
const props = defineProps<{ items: BarItem[] }>()
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
function width(value: number): string {
if (value <= 0) return '0'
return `${Math.max(2, (value / max.value) * 100)}%`
}
</script>
<template>
<!-- ld-v2 -->
<div class="obars" role="img">
<div v-for="item in items" :key="item.label" class="obars__row">
<span class="obars__label">{{ item.label }}</span>
<span class="obars__plot">
<span class="obars__fill" :style="{ width: width(item.value) }" />
</span>
<span class="obars__value">{{ item.display ?? item.value }}</span>
</div>
</div>
</template>
<style scoped>
.obars { display: flex; flex-direction: column; gap: 0.5rem; }
.obars__row {
display: grid;
grid-template-columns: minmax(5.5rem, 8rem) 1fr auto;
align-items: center;
gap: 0.75rem;
}
.obars__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.obars__plot {
position: relative;
height: 14px;
/* ligne de base : filet discret, une teinte du mood */
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--mood-text-muted) 35%, transparent);
}
.obars__fill {
position: absolute;
inset: 1px auto 1px 0;
background: var(--mood-accent);
border-radius: 0 4px 4px 0; /* extrémité donnée arrondie, base carrée */
}
.obars__value {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
// <!-- ld-v2 --> Section de l'Observatoire — carte titrée, ton curieux.
defineProps<{ title: string; icon: string; sub?: string }>()
</script>
<template>
<!-- ld-v2 -->
<section class="osec ld-card">
<h2 class="osec__title">
<UIcon :name="icon" />
{{ title }}
</h2>
<p v-if="sub" class="osec__sub">{{ sub }}</p>
<slot />
</section>
</template>
<style scoped>
.osec {
padding: 1.25rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.osec__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1rem;
font-weight: 800;
}
.osec__title :deep(svg),
.osec__title :deep(span[class^='i-']) { color: var(--mood-accent); }
.osec__sub {
margin: -0.5rem 0 0;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
// <!-- ld-v2 --> Tuile de stat de l'Observatoire — libellé, valeur, note.
// Chiffres proportionnels (pas de tabular à cette taille), encre = tokens texte.
defineProps<{ label: string; value: string; hint?: string }>()
</script>
<template>
<!-- ld-v2 -->
<div class="ostat">
<p class="ostat__label">{{ label }}</p>
<p class="ostat__value">{{ value }}</p>
<p v-if="hint" class="ostat__hint">{{ hint }}</p>
</div>
</template>
<style scoped>
.ostat {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
min-width: 0;
}
.ostat__label {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mood-text-muted);
}
.ostat__value {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.15;
}
.ostat__hint { margin: 0; font-size: 0.75rem; color: var(--mood-text-muted); }
</style>
@@ -0,0 +1,150 @@
/**
* Shared UI helpers of the mandate screens (carte, fiche, wizard) — v2.
* INVARIANT Δ7 : no gauge, no score, no aggregate on a mandate — COUNTED
* FACTS only, never synthesized. The lexicon stays the single source for
* everything it already names (MANDATE_EXERCISE, NOMINATION_LABELS…).
*/
import type {
Decision,
Id,
Mandate,
MandateReport,
NominationMethod,
Objection,
} from '~/types/domain'
/** Mandate statuses in plain French — proposé / actif / expiré / révoqué. */
export const MANDATE_STATUS_LABELS: Record<Mandate['status'], string> = {
proposed: 'proposé',
active: 'actif',
expired: 'expiré',
revoked: 'révoqué',
}
/** Global status-pill classes reused for mandates (moods.css). */
export const MANDATE_STATUS_PILL: Record<Mandate['status'], string> = {
proposed: 'status-draft',
active: 'status-adopted',
expired: 'status-closed',
revoked: 'status-revoked',
}
/** Objection statuses in plain French (fiche mandat — listes cliquables). */
export const OBJECTION_STATUS_LABELS: Record<Objection['status'], string> = {
open: 'ouverte',
withdrawn: 'retirée',
integrated: 'intégrée',
escalated: 'escaladée',
}
/** Counted facts of a mandate — raw numbers, never a synthesis. */
export interface MandateFacts {
traced: Decision[]
objections: Objection[]
reportsDelivered: number
reportsTotal: number
nextReportDueAt?: string
line: string
}
export function mandateFacts(
mandate: Mandate,
decisions: Decision[],
objections: Objection[],
): MandateFacts {
const traced = decisions
.filter(d => d.underMandateId === mandate.id)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
const tracedIds = new Set<Id>(traced.map(d => d.id))
const own = objections.filter(o => tracedIds.has(o.decisionId))
const delivered = mandate.reports.filter(r => r.deliveredAt !== undefined).length
const total = mandate.reports.length
const next = mandate.reports
.filter(r => r.deliveredAt === undefined)
.sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0]
const parts = [
`${traced.length} décision${traced.length > 1 ? 's' : ''} tracée${traced.length > 1 ? 's' : ''}`,
`${own.length} objection${own.length > 1 ? 's' : ''}`,
]
if (total > 0) parts.push(`${delivered}/${total} rapport${total > 1 ? 's' : ''}`)
const facts: MandateFacts = {
traced,
objections: own,
reportsDelivered: delivered,
reportsTotal: total,
line: parts.join(' · '),
}
if (next) facts.nextReportDueAt = next.dueAt
return facts
}
/** Next report cadence, inferred from the existing dueAt spacing. */
export function reportIntervalMs(mandate: Mandate): number | undefined {
const dues = mandate.reports.map((r: MandateReport) => r.dueAt).sort()
const last = dues[dues.length - 1]
if (last === undefined) return undefined
const previous = dues.length >= 2 ? dues[dues.length - 2]! : mandate.startsAt
const gap = new Date(last).getTime() - new Date(previous).getTime()
return gap > 0 ? gap : undefined
}
/** Short pedagogy of the 6 nomination modalities (wizard step 2). */
export interface NominationPedagogy {
icon: string
desc: string
pro: string
con: string
rule?: string
}
export const ELECTION_CLOSE_RULE
= 'Pluralité, blanc possible — en cas d\'égalité, vous départagez, jamais l\'outil.'
export const NOMINATION_PEDAGOGY: Record<NominationMethod, NominationPedagogy> = {
'ratified-self': {
icon: 'i-lucide-user-check',
desc: 'Tu te proposes toi-même ; le cercle ratifie par consentement.',
pro: 'le plus rapide — l\'élan vient de la personne',
con: 'suppose un vrai espace d\'objection',
},
'election-no-candidate': {
icon: 'i-lucide-vote',
desc: 'Chacun désigne la personne qu\'il juge la plus indiquée — personne ne se déclare.',
pro: 'révèle la légitimité réelle, sans campagne',
con: 'demande un cercle qui se connaît',
rule: ELECTION_CLOSE_RULE,
},
'nuanced-vote': {
icon: 'i-lucide-list-ordered',
desc: 'Chaque nom proposé reçoit une nuance, de « Pas du tout » à « Tout à fait ».',
pro: 'mesure l\'adhésion, pas seulement la préférence',
con: 'plus long à dépouiller qu\'un consentement',
},
'consent': {
icon: 'i-lucide-handshake',
desc: 'La nomination passe si aucune objection argumentée ne tient.',
pro: 'sobre et robuste — l\'objection nourrit la décision',
con: 'le silence doit pouvoir être un vrai oui',
},
'draw': {
icon: 'i-lucide-dices',
desc: 'Le sort désigne parmi les volontaires du cercle électeur.',
pro: 'égalité parfaite — casse les notabilités',
con: 'demande d\'accompagner la personne tirée',
},
'rotation': {
icon: 'i-lucide-rotate-ccw',
desc: 'Le mandat tourne à échéance fixe entre les membres du cercle.',
pro: 'le pouvoir circule par construction',
con: 'transmission à soigner à chaque tour',
},
}
/** jj mois aaaa — the one date format of the mandate screens. */
export function formatDay(iso: string | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
})
}
@@ -0,0 +1,254 @@
<script setup lang="ts">
/**
* /donnees §1 — les collectifs de cette machine : export JSON (<slug>-<date>.json),
* suppression de la copie locale (confirmation), collectif actif marqué, lignée.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { CollectiveIndexEntry } from '~/stores/collective'
import { loadState, toBundle } from '~/data/persistence'
import { LINEAGE_PREFIX } from '~/lexicon'
const store = useCollectiveStore()
const confirmEntry = ref<CollectiveIndexEntry | null>(null)
const confirmOpen = computed({
get: () => confirmEntry.value !== null,
set: (open: boolean) => {
if (!open) confirmEntry.value = null
},
})
const activeLineage = computed(() => store.current?.collective.lineage ?? null)
async function exportEntry(entry: CollectiveIndexEntry) {
const now = new Date().toISOString()
let json: string | null = null
if (entry.id === store.activeId) {
json = store.exportJson()
} else {
const state = await loadState(entry.id)
if (state) json = JSON.stringify(toBundle(state, now), null, 2)
}
if (!json) return
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${entry.slug}-${now.slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
async function removeConfirmed() {
if (!confirmEntry.value) return
await store.removeCollective(confirmEntry.value.id)
confirmEntry.value = null
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-database" /> Collectifs sur cette machine
</h2>
<div v-if="!store.index.length" class="data-card__empty">
<p>Aucun collectif ici pour l'instant.</p>
<NuxtLink to="/creer" class="ld-btn ld-btn--ghost">
<UIcon name="i-lucide-sparkles" /> Créer un collectif
</NuxtLink>
</div>
<ul v-else class="col-list">
<li v-for="entry in store.index" :key="entry.id" class="col-row">
<span class="col-row__dot" :style="{ background: entry.color }" />
<span class="col-row__main">
<span class="col-row__name">
{{ entry.name }}
<span v-if="entry.id === store.activeId" class="status-pill status-adopted">actif</span>
</span>
<span class="col-row__slug">{{ entry.slug }}</span>
<span
v-if="entry.id === store.activeId && activeLineage"
class="col-row__lineage"
>
<UIcon name="i-lucide-sprout" /> {{ LINEAGE_PREFIX }} {{ activeLineage.sourceSlug }}
</span>
</span>
<span class="col-row__actions">
<button type="button" class="ld-btn ld-btn--ghost" @click="exportEntry(entry)">
<UIcon name="i-lucide-download" /> Exporter
</button>
<button
type="button"
class="ld-btn ld-btn--quiet col-row__delete"
@click="confirmEntry = entry"
>
<UIcon name="i-lucide-trash-2" /> Supprimer
</button>
</span>
</li>
</ul>
<UModal v-model:open="confirmOpen">
<template #content>
<div class="confirm">
<h3 class="confirm__title">Supprimer « {{ confirmEntry?.name }} » ?</h3>
<p class="confirm__text">
Ce geste supprime uniquement la copie locale, sur cette machine.
Un fichier exporté ou une copie ailleurs n'est pas touché.
</p>
<div class="confirm__actions">
<button type="button" class="ld-btn ld-btn--ghost" @click="confirmEntry = null">
Garder
</button>
<button type="button" class="ld-btn confirm__danger" @click="removeConfirmed">
<UIcon name="i-lucide-trash-2" /> Supprimer la copie locale
</button>
</div>
</div>
</template>
</UModal>
</section>
</template>
<style scoped>
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
color: var(--mood-text-muted);
font-size: 0.9375rem;
}
.data-card__empty p { margin: 0; }
.col-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.col-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
border-radius: var(--r-input);
background: var(--mood-bg);
}
.col-row__dot {
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.col-row__main {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.col-row__name {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-weight: 700;
color: var(--mood-text);
}
.col-row__slug {
font-size: 0.8125rem;
color: var(--mood-text-muted);
overflow: hidden;
text-overflow: ellipsis;
}
.col-row__lineage {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-tertiary);
}
.col-row__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
}
.col-row__delete:hover { color: var(--mood-error); }
.confirm {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: clamp(1.1rem, 3vw, 1.5rem);
}
.confirm__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.confirm__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--mood-text-muted);
}
.confirm__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.confirm__danger {
background: var(--mood-error);
color: #ffffff;
}
@media (max-width: 480px) {
.col-row {
flex-wrap: wrap;
}
.col-row__actions {
width: 100%;
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
/**
* /donnees §4 — mon identité locale : prénom (stamp + persist) et mes
* attributs déclarés (clés/valeurs libres — nourrissent la carte « Pour moi »).
*/
import { useCollectiveStore } from '~/stores/collective'
import { ATTRIBUTES_HINT } from '~/lexicon'
const store = useCollectiveStore()
interface AttrRow {
key: string
value: number
}
const nameDraft = ref('')
const rows = ref<AttrRow[]>([])
// Re-seed the drafts only when the person changes (collective switch),
// never on our own commits — an in-progress empty row must survive.
watch(
() => store.me?.id,
() => {
const me = store.me
nameDraft.value = me?.displayName ?? ''
rows.value = Object.entries(me?.attributes ?? {}).map(([key, value]) => ({ key, value }))
},
{ immediate: true },
)
function commitName() {
const me = store.me
if (!me) return
const value = nameDraft.value.trim()
if (!value || value === me.displayName) {
nameDraft.value = me.displayName
return
}
me.displayName = value
store.stamp(me)
store.persist()
}
function commitAttributes() {
const me = store.me
if (!me) return
const attributes: Record<string, number> = {}
for (const row of rows.value) {
const key = row.key.trim()
if (!key) continue
attributes[key] = Number.isFinite(row.value) ? row.value : 0
}
me.attributes = attributes
store.stamp(me)
store.persist()
}
function addRow() {
rows.value.push({ key: '', value: 0 })
}
function removeRow(index: number) {
rows.value.splice(index, 1)
commitAttributes()
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-user-round" /> Mon identité
</h2>
<p v-if="!store.me" class="data-card__empty">
Aucun collectif actif crée ou importe un collectif pour poser ton identité.
</p>
<template v-else>
<label class="field">
<span class="field__label">Comment tu t'appelles ici</span>
<input
v-model="nameDraft"
class="field__input"
type="text"
autocomplete="off"
@change="commitName"
@keydown.enter.prevent="commitName"
>
</label>
<div class="field">
<span class="field__label">Mes attributs</span>
<p class="field__hint">{{ ATTRIBUTES_HINT }}</p>
<ul v-if="rows.length" class="attr-list">
<li v-for="(row, i) in rows" :key="i" class="attr-row">
<input
v-model="row.key"
class="field__input attr-row__key"
type="text"
placeholder="heures/mois"
autocomplete="off"
@change="commitAttributes"
>
<input
v-model.number="row.value"
class="field__input attr-row__value"
type="number"
step="any"
@change="commitAttributes"
>
<button
type="button"
class="attr-row__x"
:aria-label="`Retirer l'attribut ${row.key || i + 1}`"
@click="removeRow(i)"
>
<UIcon name="i-lucide-x" />
</button>
</li>
</ul>
<button type="button" class="ld-btn ld-btn--ghost attr-add" @click="addRow">
<UIcon name="i-lucide-plus" /> Ajouter un attribut
</button>
</div>
</template>
</section>
</template>
<style scoped>
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__empty {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field__label {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mood-text-muted);
}
.field__hint {
margin: 0;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.field__input {
min-height: 2.25rem;
padding: 0.45rem 0.8rem;
font-size: 0.9375rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
border-radius: var(--r-input);
}
.field__input:focus-visible {
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
}
.attr-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.attr-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.attr-row__key { flex: 1; min-width: 0; }
.attr-row__value { width: 6.5rem; flex-shrink: 0; }
.attr-row__x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: 50%;
cursor: pointer;
background: none;
color: var(--mood-text-muted);
transition: background 0.1s ease, color 0.1s ease;
}
.attr-row__x:hover {
background: var(--mood-accent-soft);
color: var(--mood-error);
}
.attr-add { align-self: flex-start; }
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
/**
* /donnees §2 — importer un collectif : fichier ou glisser-déposer.
* Issues en français (bloquantes / avertissements), lignée « essaimé de … ».
*/
import { useCollectiveStore } from '~/stores/collective'
import type { ImportIssue } from '~/data/persistence'
import { LINEAGE_PREFIX } from '~/lexicon'
const store = useCollectiveStore()
const dragging = ref(false)
const busy = ref(false)
const issues = ref<ImportIssue[]>([])
const importedName = ref<string | null>(null)
const importedLineage = ref<string | null>(null)
const errors = computed(() => issues.value.filter(i => i.level === 'error'))
const warnings = computed(() => issues.value.filter(i => i.level === 'warning'))
async function importFile(file: File | null | undefined) {
if (!file || busy.value) return
busy.value = true
importedName.value = null
importedLineage.value = null
const text = await file.text()
const result = await store.importJson(text)
issues.value = result.issues
if (result.state && !result.collided) {
importedName.value = result.state.collective.name
importedLineage.value = result.state.collective.lineage?.sourceSlug ?? null
}
busy.value = false
}
function onDrop(ev: DragEvent) {
dragging.value = false
importFile(ev.dataTransfer?.files?.[0])
}
function onPick(ev: Event) {
const input = ev.target as HTMLInputElement
importFile(input.files?.[0])
input.value = ''
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-file-down" /> Importer un collectif
</h2>
<label
class="dropzone"
:class="{ 'dropzone--over': dragging, 'dropzone--busy': busy }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<UIcon name="i-lucide-upload" class="dropzone__icon" />
<span class="dropzone__text">
Dépose un fichier .json ici, ou <span class="dropzone__link">choisis-le</span>
</span>
<span class="dropzone__sub">Un export libreDecision, d'ici ou d'ailleurs.</span>
<input
type="file"
accept=".json,application/json"
class="dropzone__input"
:disabled="busy"
@change="onPick"
>
</label>
<ul v-if="errors.length" class="issues issues--error">
<li v-for="issue in errors" :key="issue.message">
<UIcon name="i-lucide-circle-alert" /> {{ issue.message }}
</li>
</ul>
<ul v-if="warnings.length" class="issues issues--warning">
<li v-for="issue in warnings" :key="issue.message">
<UIcon name="i-lucide-triangle-alert" /> {{ issue.message }}
</li>
</ul>
<div v-if="importedName" class="imported">
<p class="imported__line">
<UIcon name="i-lucide-circle-check" /> « {{ importedName }} » est arrivé sur cette machine c'est maintenant le collectif actif.
</p>
<p v-if="importedLineage" class="imported__lineage">
<UIcon name="i-lucide-sprout" /> {{ LINEAGE_PREFIX }} {{ importedLineage }}
</p>
</div>
</section>
</template>
<style scoped>
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: clamp(1.25rem, 4vw, 2rem);
border-radius: var(--r-input);
background: var(--mood-bg);
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
cursor: pointer;
text-align: center;
transition: box-shadow 0.12s ease, background 0.12s ease;
}
.dropzone--over {
background: var(--mood-accent-soft);
box-shadow: inset 0 0 0 2px var(--mood-accent);
}
.dropzone--busy {
opacity: 0.6;
cursor: progress;
}
.dropzone__icon {
font-size: 1.5rem;
color: var(--mood-accent);
}
.dropzone__text {
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text);
}
.dropzone__link {
color: var(--mood-accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.dropzone__sub {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.dropzone__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.issues {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
border-radius: var(--r-input);
font-size: 0.875rem;
font-weight: 600;
}
.issues li {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.issues--error {
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
}
.issues--warning {
background: var(--mood-status-fenetre-bg);
color: var(--mood-status-fenetre);
}
.imported {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.75rem 1rem;
border-radius: var(--r-input);
background: var(--mood-status-vigueur-bg);
}
.imported__line {
display: flex;
align-items: flex-start;
gap: 0.45rem;
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--mood-status-vigueur);
}
.imported__lineage {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-tertiary);
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts">
/** The six accent swatches offered at creation — well-water field, shared with /creer. */
export interface ColorChoice {
hex: string
label: string
}
export const COLOR_CHOICES: ColorChoice[] = [
{ hex: '#0f7fa8', label: 'Eau vive' },
{ hex: '#2e8a72', label: 'Eau végétale' },
{ hex: '#4f7a3c', label: 'Mousse' },
{ hex: '#96682a', label: 'Bronze' },
{ hex: '#b0563c', label: 'Terre cuite' },
{ hex: '#5c68b8', label: 'Iris' },
]
</script>
<script setup lang="ts">
/** Étape 1 — nom du collectif, couleur, ambiance. Entrée valide l'étape. */
const name = defineModel<string>('name', { required: true })
const color = defineModel<string>('color', { required: true })
const emit = defineEmits<{ next: [] }>()
const { moods, currentMood, setMood } = useLibreMood()
const nameInput = ref<HTMLInputElement | null>(null)
onMounted(() => nameInput.value?.focus())
</script>
<template>
<!-- ld-v2 -->
<section class="idstep">
<label class="field">
<span class="field__label">Le nom de votre collectif</span>
<input
ref="nameInput"
v-model="name"
class="field__input"
type="text"
placeholder="Les Jardins du Canal…"
autocomplete="off"
enterkeyhint="next"
@keydown.enter.prevent="emit('next')"
>
</label>
<div class="field">
<span class="field__label">Sa couleur</span>
<div class="swatches" role="group" aria-label="Couleur du collectif">
<button
v-for="c in COLOR_CHOICES"
:key="c.hex"
type="button"
class="swatch"
:class="{ 'swatch--active': color === c.hex }"
:style="{ background: c.hex }"
:aria-label="c.label"
:aria-pressed="color === c.hex"
:title="c.label"
@click="color = c.hex"
/>
</div>
</div>
<div class="field">
<span class="field__label">L'ambiance de tes écrans</span>
<div class="moods" role="group" aria-label="Ambiance">
<button
v-for="m in moods"
:key="m.id"
type="button"
class="mood-pill"
:class="{ 'mood-pill--active': currentMood === m.id }"
:aria-pressed="currentMood === m.id"
@click="setMood(m.id)"
>
<span class="mood-pill__dot" :style="{ background: m.color }" />
{{ m.label }}
</button>
</div>
<p class="field__hint">L'ambiance ne vaut que pour toi, sur cette machine.</p>
</div>
</section>
</template>
<style scoped>
.idstep {
display: flex;
flex-direction: column;
gap: clamp(1.25rem, 3vw, 1.75rem);
width: min(100%, 26rem);
margin-inline: auto;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field__label {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mood-text-muted);
}
.field__input {
min-height: 2.75rem;
padding: 0.6rem 0.9rem;
font-size: 1.0625rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
border-radius: var(--r-input);
}
.field__input:focus-visible {
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
}
.field__hint {
margin: 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.swatch {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
cursor: pointer;
padding: 0;
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35), 0 1px 3px var(--mood-shadow);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.swatch:hover { transform: translateY(-1px) scale(1.08); }
.swatch:active { transform: translateY(0); }
.swatch--active {
box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
0 0 0 2.5px var(--mood-bg),
0 0 0 5px var(--mood-accent);
}
.moods {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.mood-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.25rem;
padding: 0.35rem 0.9rem;
border-radius: var(--r-pill);
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
background: var(--mood-surface);
color: var(--mood-text);
box-shadow: var(--shadow-card);
transition: transform 0.1s ease, box-shadow 0.12s ease;
}
.mood-pill:hover { transform: translateY(-1px); }
.mood-pill:active { transform: translateY(0); }
.mood-pill--active {
box-shadow: var(--shadow-card), 0 0 0 2px var(--mood-accent);
color: var(--mood-accent);
}
.mood-pill__dot {
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
</style>
@@ -0,0 +1,167 @@
<script setup lang="ts">
/** Étape 3 — toi (Person isMe) + les premiers membres, ajout rapide au clavier. */
const meName = defineModel<string>('meName', { required: true })
const members = defineModel<string[]>('members', { required: true })
const newMember = ref('')
const meInput = ref<HTMLInputElement | null>(null)
const memberInput = ref<HTMLInputElement | null>(null)
onMounted(() => meInput.value?.focus())
function addMember() {
const value = newMember.value.trim()
if (!value) return
if (!members.value.includes(value)) members.value = [...members.value, value]
newMember.value = ''
}
function removeMember(index: number) {
members.value = members.value.filter((_, i) => i !== index)
}
</script>
<template>
<!-- ld-v2 -->
<section class="memstep">
<label class="field">
<span class="field__label">Toi ton prénom</span>
<input
ref="meInput"
v-model="meName"
class="field__input"
type="text"
placeholder="Camille"
autocomplete="given-name"
enterkeyhint="next"
@keydown.enter.prevent="memberInput?.focus()"
>
</label>
<div class="field">
<span class="field__label">Les premières personnes autour de la table</span>
<div class="member-add">
<input
ref="memberInput"
v-model="newMember"
class="field__input member-add__input"
type="text"
placeholder="Un prénom, puis Entrée"
autocomplete="off"
enterkeyhint="enter"
@keydown.enter.prevent="addMember"
>
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="!newMember.trim()"
@click="addMember"
>
<UIcon name="i-lucide-plus" /> Ajouter
</button>
</div>
<ul v-if="members.length" class="member-chips">
<li v-for="(m, i) in members" :key="m" class="member-chip">
{{ m }}
<button
type="button"
class="member-chip__x"
:aria-label="`Retirer ${m}`"
@click="removeMember(i)"
>
<UIcon name="i-lucide-x" />
</button>
</li>
</ul>
<p class="field__hint">Tu pourras en ajouter à tout moment les cercles se dessinent à l'usage.</p>
</div>
</section>
</template>
<style scoped>
.memstep {
display: flex;
flex-direction: column;
gap: clamp(1.25rem, 3vw, 1.75rem);
width: min(100%, 26rem);
margin-inline: auto;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field__label {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mood-text-muted);
}
.field__input {
min-height: 2.75rem;
padding: 0.6rem 0.9rem;
font-size: 1.0625rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
border-radius: var(--r-input);
}
.field__input:focus-visible {
box-shadow: inset 0 0 0 1.5px var(--mood-input-focus), 0 0 0 2.5px var(--mood-accent-soft);
}
.field__hint {
margin: 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.member-add {
display: flex;
gap: 0.5rem;
}
.member-add__input { flex: 1; min-width: 0; }
.member-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.25rem 0 0;
padding: 0;
list-style: none;
}
.member-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.25rem;
padding: 0.3rem 0.4rem 0.3rem 0.85rem;
border-radius: var(--r-pill);
font-size: 0.9375rem;
font-weight: 600;
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.member-chip__x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
cursor: pointer;
background: none;
color: var(--mood-text-muted);
transition: background 0.1s ease, color 0.1s ease;
}
.member-chip__x:hover {
background: var(--mood-surface);
color: var(--mood-error);
}
</style>
@@ -0,0 +1,191 @@
<script lang="ts">
import type { TemplateId } from '~/data/templates'
/** Per-template icon — also becomes Collective.icon at creation (/creer). */
export const TEMPLATE_ICONS: Record<TemplateId, string> = {
'blank': 'i-lucide-notebook-pen',
'informal': 'i-lucide-coffee',
'association': 'i-lucide-heart-handshake',
'cooperative': 'i-lucide-wheat',
'commune': 'i-lucide-map-pinned',
'free-currency': 'i-lucide-coins',
'symmetric': 'i-lucide-scale',
}
/** « Qui doit être d'accord ? » — the concrete line of each card. */
const TEMPLATE_AGREEMENT: Record<TemplateId, string> = {
'blank': "celles et ceux que ça touche — sans objection maintenue, c'est adopté.",
'informal': "le groupe, par consentement ; rien d'exigé avant de décider.",
'association': 'les membres concernés ; nombreux, vous décidez en nuances — jamais deux camps.',
'cooperative': 'celles et ceux que ça engage ; les nuances décident des grandes questions.',
'commune': 'chaque périmètre à sa juste échelle ; les nuances au-delà du cercle proche.',
'free-currency': "la toile entière sur les grands sujets — le pour/contre inertiel hérité de Ğ1.",
'symmetric': 'tout le collectif, en pleine lumière — la transparence est la règle.',
}
</script>
<script setup lang="ts">
/** Étape 2 — les sept gabarits (blank en premier) + les deux collectifs à explorer. */
import { TEMPLATE_CARDS } from '~/data/templates'
import type { SeedName } from '~/stores/collective'
const selected = defineModel<TemplateId | null>({ required: true })
const emit = defineEmits<{ next: []; seed: [name: SeedName] }>()
</script>
<template>
<!-- ld-v2 -->
<section class="tplstep">
<div class="tpl-grid">
<button
v-for="card in TEMPLATE_CARDS"
:key="card.id"
type="button"
class="ld-card ld-card--hover tpl-card"
:class="{ 'tpl-card--active': selected === card.id, 'tpl-card--blank': card.id === 'blank' }"
:aria-pressed="selected === card.id"
@click="selected = card.id"
@dblclick="emit('next')"
>
<span class="tpl-card__top">
<span class="tpl-card__icon"><UIcon :name="TEMPLATE_ICONS[card.id]" /></span>
<span class="tpl-card__head">
<span class="tpl-card__title">{{ card.title }}</span>
<span class="tpl-card__subtitle">{{ card.subtitle }}</span>
</span>
</span>
<span class="tpl-card__desc">{{ card.description }}</span>
<span class="tpl-card__agree">
<strong>Qui doit être d'accord ?</strong>
{{ TEMPLATE_AGREEMENT[card.id] }}
</span>
</button>
</div>
<div class="tpl-seeds">
<p class="tpl-seeds__label">Ou entre d'abord dans un collectif déjà vivant :</p>
<div class="tpl-seeds__row">
<button type="button" class="ld-btn ld-btn--ghost" @click="emit('seed', 'duniter-g1')">
<UIcon name="i-lucide-coins" /> Explorer Duniter Ğ1
</button>
<button type="button" class="ld-btn ld-btn--ghost" @click="emit('seed', 'atelier-du-canal')">
<UIcon name="i-lucide-hammer" /> Explorer l'Atelier du Canal
</button>
</div>
</div>
</section>
</template>
<style scoped>
.tplstep {
display: flex;
flex-direction: column;
gap: clamp(1.25rem, 3vw, 1.75rem);
width: 100%;
}
.tpl-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.875rem;
}
@media (min-width: 768px) {
.tpl-grid { grid-template-columns: 1fr 1fr; }
.tpl-card--blank { grid-column: 1 / -1; }
}
.tpl-card {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.55rem;
padding: clamp(0.875rem, 2.5vw, 1.15rem);
text-align: left;
cursor: pointer;
color: var(--mood-text);
background: var(--mood-surface);
}
.tpl-card--active {
box-shadow: var(--shadow-card), 0 0 0 2.5px var(--mood-accent);
}
.tpl-card--blank {
background: var(--mood-accent-soft);
}
.tpl-card__top {
display: flex;
align-items: center;
gap: 0.7rem;
}
.tpl-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: var(--mood-accent-soft);
color: var(--mood-accent);
}
.tpl-card__head {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tpl-card__title {
font-size: 1.0625rem;
font-weight: 800;
line-height: 1.2;
}
.tpl-card__subtitle {
font-size: 0.8125rem;
font-weight: 600;
font-style: italic;
color: var(--mood-text-muted);
}
.tpl-card__desc {
font-size: 0.9rem;
line-height: 1.45;
color: var(--mood-text);
}
.tpl-card__agree {
font-size: 0.85rem;
line-height: 1.45;
color: var(--mood-text-muted);
}
.tpl-card__agree strong {
font-weight: 700;
color: var(--mood-accent);
}
.tpl-seeds {
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: center;
text-align: center;
}
.tpl-seeds__label {
margin: 0;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.tpl-seeds__row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem;
}
</style>
@@ -0,0 +1,524 @@
<script setup lang="ts">
// <!-- ld-v2 --> L'établi de l'Atelier — porté du FormulaEditor v1 (sliders,
// exposants Forgeron/Comité) et du FormulaDisplay v1 (rendu KaTeX), branché sur
// l'UNIQUE moteur ~/engine (wotThreshold, smithThreshold, techcommThreshold).
// La seule maison des lettres : W, T, M, B, G, C n'apparaissent qu'ici.
import { wotThreshold, smithThreshold, techcommThreshold } from '~/engine'
import { INERTIA_LABELS, thresholdSentence } from '~/lexicon'
import type { AtelierInit } from './textsModel'
import { INERTIA_COLORS, INERTIA_ORDER, INERTIA_PARAMS, formatInt } from './textsModel'
const props = defineProps<{ initial?: AtelierInit }>()
// ── L'état de l'établi ───────────────────────────────────────
const W = ref(Math.max(1, Math.round(props.initial?.W ?? 100)))
const T = ref(Math.max(0, Math.round(props.initial?.T ?? Math.max(1, Math.round(W.value / 3)))))
const M = ref(props.initial?.M ?? 50)
const B = ref(props.initial?.B ?? 0.1)
const G = ref(props.initial?.G ?? 0.2)
const C = ref(props.initial?.C ?? 0)
const withSmith = ref(props.initial?.S !== undefined)
const S = ref(props.initial?.S ?? 0.1)
const smithW = ref(12)
const withTech = ref(false)
const techExp = ref(0.1)
const techW = ref(6)
watch(W, (w) => { if (T.value > w) T.value = w })
/** Cas Forgeron 2026 — un clic : W=7224, T=120 ⇒ 94 pour requis. */
function loadForgeron2026(): void {
W.value = 7224
T.value = 120
M.value = 50
B.value = 0.1
G.value = 0.2
C.value = 0
withSmith.value = true
S.value = 0.1
withTech.value = false
}
// ── Les seuils — source unique ~/engine ──────────────────────
const threshold = computed(() => wotThreshold(W.value, T.value, M.value, B.value, G.value, C.value))
const thresholdPct = computed(() =>
T.value > 0 ? Math.round((threshold.value / T.value) * 100) : null)
const sentence = computed(() =>
T.value > 0 ? thresholdSentence(W.value, T.value, threshold.value) : null)
const smithRequired = computed(() =>
withSmith.value ? smithThreshold(Math.max(1, smithW.value), S.value) : null)
const techRequired = computed(() =>
withTech.value ? techcommThreshold(Math.max(1, techW.value), techExp.value) : null)
// ── KaTeX (chargé globalement — repli <code> sinon) ──────────
const MAIN_TEX = 'Seuil = C + B^{W} + \\left(M + (1-M)\\cdot\\left(1 - \\left(\\tfrac{T}{W}\\right)^{G}\\right)\\right)\\cdot\\max(0,\\,T - C)'
const katexTick = ref(0)
onMounted(() => {
const w = window as unknown as { katex?: unknown }
if (w.katex) return
const timer = setInterval(() => {
if (w.katex) { katexTick.value++; clearInterval(timer) }
}, 200)
setTimeout(() => clearInterval(timer), 6000)
})
function renderTex(tex: string): string {
const w = window as unknown as {
katex?: { renderToString: (t: string, o: object) => string }
}
if (typeof window !== 'undefined' && w.katex) {
return w.katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="lab__tex-fallback">${tex}</code>`
}
const mainFormulaHtml = computed(() => { void katexTick.value; return renderTex(MAIN_TEX) })
const smithFormulaHtml = computed(() => {
void katexTick.value
return withSmith.value ? renderTex(`Seuil_{forgerons} = \\lceil ${smithW.value}^{${S.value}} \\rceil = ${smithRequired.value}`) : null
})
const techFormulaHtml = computed(() => {
void katexTick.value
return withTech.value ? renderTex(`Seuil_{comit\\acute{e}} = \\lceil ${techW.value}^{${techExp.value}} \\rceil = ${techRequired.value}`) : null
})
// ── Table de participation : T croissant sur W donné ─────────
const PARTICIPATION_STEPS = [1, 2, 5, 10, 25, 50, 75, 100]
const participationRows = computed(() => {
const seen = new Set<number>()
return PARTICIPATION_STEPS.flatMap((pct) => {
const votes = Math.max(1, Math.round((W.value * pct) / 100))
if (seen.has(votes)) return []
seen.add(votes)
const required = wotThreshold(W.value, votes, M.value, B.value, G.value, C.value)
return [{
pct,
votes,
required,
ratio: Math.round((required / votes) * 100),
isCurrent: Math.abs(votes - T.value) === Math.min(
...PARTICIPATION_STEPS.map(p => Math.abs(Math.max(1, Math.round((W.value * p) / 100)) - T.value)),
),
}]
})
})
// ── Presets d'inertie comparés (câblage réel du domaine) ─────
const presetRows = computed(() => INERTIA_ORDER.map((preset) => {
const params = INERTIA_PARAMS[preset]
const required = T.value > 0
? wotThreshold(W.value, T.value, params.majorityPct, 0.1, params.gradientExponent, 0)
: 0
return {
preset,
label: INERTIA_LABELS[preset],
color: INERTIA_COLORS[preset],
majorityPct: params.majorityPct,
gradient: params.gradientExponent,
required,
ratio: T.value > 0 ? Math.round((required / T.value) * 100) : 0,
active: params.majorityPct === M.value && params.gradientExponent === G.value,
}
}))
/** Courbes seuil (%) / participation — portées de l'InertiaSlider v1. */
function curvePath(majorityPct: number, gradient: number): string {
const m = majorityPct / 100
const points: string[] = []
for (let i = 0; i <= 40; i++) {
const participation = i / 40
const ratio = m + (1 - m) * (1 - participation ** gradient)
points.push(`${(30 + participation * 170).toFixed(1)},${(10 + (1 - ratio) * 70).toFixed(1)}`)
}
return `M ${points.join(' L ')}`
}
const presetCurves = computed(() => presetRows.value.map(row => ({
...row,
path: curvePath(row.majorityPct, row.gradient),
})))
const currentCurve = computed(() => curvePath(M.value, G.value))
</script>
<template>
<!-- ld-v2 -->
<section class="lab">
<!-- La formule, rendue -->
<div class="lab__formula ld-card">
<div class="lab__tex" v-html="mainFormulaHtml" />
<div class="lab__legend">
<span><strong>W</strong> membres éligibles</span>
<span><strong>T</strong> votes exprimés</span>
<span><strong>M</strong> majorité cible</span>
<span><strong>B</strong> plancher dynamique</span>
<span><strong>G</strong> gradient d'inertie</span>
<span><strong>C</strong> plancher fixe</span>
</div>
</div>
<div class="lab__grid">
<!-- Les réglages -->
<div class="lab__controls ld-card">
<div class="lab__scenario">
<label class="lab__field">
<span class="lab__field-label">W — membres éligibles</span>
<input v-model.number="W" type="number" min="1" max="1000000" class="lab__number">
</label>
<label class="lab__field">
<span class="lab__field-label">T — votes exprimés</span>
<input v-model.number="T" type="number" min="0" :max="W" class="lab__number">
</label>
<button type="button" class="ld-btn ld-btn--ghost lab__forgeron" @click="loadForgeron2026">
<UIcon name="i-lucide-hammer" />
Cas Forgeron 2026
</button>
</div>
<label class="lab__slider">
<span class="lab__slider-head">
<span>M — majorité cible</span><strong>{{ M }} %</strong>
</span>
<input v-model.number="M" type="range" min="0" max="100" step="1">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>B — plancher dynamique</span><strong>{{ B }}</strong>
</span>
<input v-model.number="B" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>G — gradient d'inertie</span><strong>{{ G }}</strong>
</span>
<input v-model.number="G" type="range" min="0.01" max="2" step="0.01">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>C plancher fixe</span><strong>{{ C }}</strong>
</span>
<input v-model.number="C" type="range" min="0" max="100" step="1">
</label>
<div class="lab__optional">
<label class="lab__check">
<input v-model="withSmith" type="checkbox">
<span>Critère Forgeron</span>
</label>
<div v-if="withSmith" class="lab__sub">
<label class="lab__slider">
<span class="lab__slider-head"><span>S exposant</span><strong>{{ S }}</strong></span>
<input v-model.number="S" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__field lab__field--inline">
<span class="lab__field-label">forgerons actifs</span>
<input v-model.number="smithW" type="number" min="1" max="10000" class="lab__number">
</label>
</div>
<label class="lab__check">
<input v-model="withTech" type="checkbox">
<span>Critère Comité technique</span>
</label>
<div v-if="withTech" class="lab__sub">
<label class="lab__slider">
<span class="lab__slider-head"><span>exposant</span><strong>{{ techExp }}</strong></span>
<input v-model.number="techExp" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__field lab__field--inline">
<span class="lab__field-label">membres du comité</span>
<input v-model.number="techW" type="number" min="1" max="1000" class="lab__number">
</label>
</div>
</div>
</div>
<!-- Le résultat -->
<div class="lab__result ld-card">
<p class="lab__result-heading">Le seuil, ici et maintenant</p>
<p class="lab__result-figure">
<span class="lab__result-number">{{ formatInt(threshold) }}</span>
<span class="lab__result-unit">pour requis</span>
</p>
<p v-if="thresholdPct !== null" class="lab__result-pct">
soit {{ thresholdPct }} % des {{ formatInt(T) }} votes exprimés
</p>
<p v-if="sentence" class="lab__result-sentence">{{ sentence }}</p>
<div v-if="smithFormulaHtml" class="lab__criterion" v-html="smithFormulaHtml" />
<div v-if="techFormulaHtml" class="lab__criterion" v-html="techFormulaHtml" />
</div>
</div>
<!-- Table de participation -->
<div class="lab__table-card ld-card">
<p class="lab__block-title">Le seuil descend quand la participation monte</p>
<p class="lab__block-sub">T croissant, sur une toile de {{ formatInt(W) }} membres</p>
<div class="lab__table-scroll">
<table class="lab__table">
<thead>
<tr>
<th>participation</th>
<th>T</th>
<th>seuil</th>
<th>% des exprimés</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in participationRows"
:key="row.pct"
:class="{ 'lab__row--current': row.isCurrent }"
>
<td>{{ row.pct }} %</td>
<td>{{ formatInt(row.votes) }}</td>
<td class="lab__cell-strong">{{ formatInt(row.required) }}</td>
<td>{{ row.ratio }} %</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Presets d'inertie comparés -->
<div class="lab__presets ld-card">
<p class="lab__block-title">Les quatre inerties, comparées</p>
<p class="lab__block-sub">
mêmes W et T — seule l'inertie change ; ce sont les presets réels des clauses
</p>
<svg viewBox="0 0 230 100" class="lab__curves" role="img" aria-label="Courbes de seuil par inertie">
<line x1="30" y1="10" x2="30" y2="80" class="lab__axis" />
<line x1="30" y1="80" x2="200" y2="80" class="lab__axis" />
<path
v-for="curve in presetCurves"
:key="curve.preset"
:d="curve.path"
fill="none"
:style="{ stroke: curve.color }"
:stroke-width="curve.active ? 2.6 : 1.4"
:opacity="curve.active ? 1 : 0.55"
stroke-linecap="round"
/>
<path
v-if="!presetCurves.some(curve => curve.active)"
:d="currentCurve"
fill="none"
class="lab__curve-current"
stroke-width="2.2"
stroke-dasharray="5 4"
/>
<text x="14" y="14" class="lab__axis-label">100%</text>
<text x="18" y="84" class="lab__axis-label">M</text>
<text x="30" y="94" class="lab__axis-label">0%</text>
<text x="180" y="94" class="lab__axis-label">100%</text>
<text x="110" y="99" class="lab__axis-title">participation T/W</text>
<text x="4" y="52" class="lab__axis-title" transform="rotate(-90, 8, 52)">seuil</text>
</svg>
<div class="lab__preset-rows">
<button
v-for="row in presetRows"
:key="row.preset"
type="button"
class="lab__preset"
:class="{ 'lab__preset--active': row.active }"
:style="{ '--preset-color': row.color }"
@click="M = row.majorityPct; G = row.gradient; B = 0.1; C = 0"
>
<span class="lab__preset-dot" />
<span class="lab__preset-label">{{ row.label }}</span>
<span class="lab__preset-params">M{{ row.majorityPct }} · G{{ row.gradient }}</span>
<span class="lab__preset-required">{{ formatInt(row.required) }} pour ({{ row.ratio }} %)</span>
</button>
</div>
</div>
</section>
</template>
<style scoped>
.lab { display: flex; flex-direction: column; gap: 1rem; }
.lab__formula { padding: clamp(0.9rem, 3vw, 1.4rem); overflow-x: auto; }
.lab__tex { text-align: center; }
.lab__tex :deep(.lab__tex-fallback) {
font-family: monospace;
font-size: 0.8125rem;
color: var(--mood-text);
}
.lab__legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem 1rem;
margin-top: 0.6rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.lab__legend strong { color: var(--mood-accent); font-family: monospace; }
.lab__grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 768px) { .lab__grid { grid-template-columns: 3fr 2fr; } }
.lab__controls {
padding: clamp(0.9rem, 3vw, 1.3rem);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.lab__scenario { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
.lab__field { display: flex; flex-direction: column; gap: 0.25rem; }
.lab__field--inline { flex-direction: row; align-items: center; gap: 0.5rem; }
.lab__field-label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.lab__number {
width: 7.5rem;
padding: 0.4rem 0.7rem;
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.lab__forgeron { font-size: 0.8125rem; }
.lab__slider { display: flex; flex-direction: column; gap: 0.3rem; }
.lab__slider-head {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.lab__slider-head strong {
color: var(--mood-accent);
font-variant-numeric: tabular-nums;
font-family: monospace;
}
.lab__slider input[type='range'] {
width: 100%;
accent-color: var(--mood-accent);
background: transparent;
min-height: 1.4rem;
}
.lab__optional {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-top: 0.75rem;
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 8%, transparent);
}
.lab__check {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
cursor: pointer;
}
.lab__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.lab__sub {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 1rem;
}
.lab__result {
padding: clamp(0.9rem, 3vw, 1.3rem);
display: flex;
flex-direction: column;
gap: 0.5rem;
background:
linear-gradient(150deg, var(--mood-accent-soft), transparent 60%),
var(--mood-surface);
}
.lab__result-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
}
.lab__result-figure { display: flex; align-items: baseline; gap: 0.5rem; }
.lab__result-number {
font-size: clamp(2.2rem, 7vw, 3rem);
font-weight: 800;
color: var(--mood-accent);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.lab__result-unit { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.lab__result-pct { font-size: 0.875rem; color: var(--mood-text-muted); }
.lab__result-sentence {
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text);
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 4%, transparent);
}
.lab__criterion { overflow-x: auto; font-size: 0.875rem; }
.lab__table-card, .lab__presets { padding: clamp(0.9rem, 3vw, 1.3rem); }
.lab__block-title { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
.lab__block-sub { font-size: 0.75rem; color: var(--mood-text-muted); margin-bottom: 0.7rem; }
.lab__table-scroll { overflow-x: auto; }
.lab__table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.lab__table th {
text-align: left;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
padding: 0.35rem 0.6rem;
}
.lab__table td {
padding: 0.35rem 0.6rem;
font-variant-numeric: tabular-nums;
color: var(--mood-text);
}
.lab__table tbody tr:nth-child(odd) td {
background: color-mix(in srgb, var(--mood-text) 3%, transparent);
}
.lab__row--current td { background: var(--mood-accent-soft) !important; font-weight: 700; }
.lab__cell-strong { font-weight: 800; color: var(--mood-accent); }
.lab__curves { width: 100%; max-width: 26rem; height: auto; margin: 0 auto 0.5rem; display: block; }
.lab__axis { stroke: color-mix(in srgb, var(--mood-text) 25%, transparent); stroke-width: 1; }
.lab__axis-label { font-size: 5.5px; fill: var(--mood-text-muted); font-family: monospace; }
.lab__axis-title { font-size: 5.5px; fill: var(--mood-text-muted); font-weight: 600; }
.lab__curve-current { stroke: var(--mood-text); }
.lab__preset-rows { display: flex; flex-direction: column; gap: 0.4rem; }
.lab__preset {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.55rem 0.8rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--preset-color) 6%, transparent);
cursor: pointer;
text-align: left;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.lab__preset:hover { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
.lab__preset--active { box-shadow: 0 0 0 2px var(--preset-color); }
.lab__preset-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--preset-color);
flex-shrink: 0;
}
.lab__preset-label { font-size: 0.8125rem; font-weight: 800; color: var(--mood-text); }
.lab__preset-params {
font-size: 0.6875rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.lab__preset-required {
margin-left: auto;
font-size: 0.8125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--preset-color);
}
</style>
@@ -0,0 +1,163 @@
<script setup lang="ts">
// <!-- ld-v2 --> Mini-démo interactive du Réglage collectif : cinq curseurs-votes
// jouets, la médiane basse recalculée en direct par ~/engine (medianByElement),
// et la règle de cristallisation — l'agrégat éclaire, le geste cristallise.
import { medianByElement } from '~/engine'
import { CRYSTALLIZE_ACTION, MEDIAN_EXPLANATION } from '~/lexicon'
const TOY_VOTERS = ['Ana', 'Brice', 'Chloé', 'Dado', 'Elsa'] as const
const votes = ref<number[]>([20, 35, 40, 65, 80])
const median = computed(() => {
const result = medianByElement(votes.value.map(v => [v]))
return result[0] ?? 0
})
/** La médiane est toujours une valeur réellement votée — on la nomme. */
const medianVoters = computed(() =>
TOY_VOTERS.filter((_, i) => votes.value[i] === median.value))
</script>
<template>
<!-- ld-v2 -->
<div class="median-demo">
<div class="median-demo__sliders">
<label v-for="(name, i) in TOY_VOTERS" :key="name" class="median-demo__vote">
<span class="median-demo__voter">{{ name }}</span>
<input
v-model.number="votes[i]"
type="range"
min="0"
max="100"
step="5"
:aria-label="`Curseur-vote de ${name}`"
>
<span class="median-demo__value">{{ votes[i] }}</span>
</label>
</div>
<!-- Le faisceau : chaque point est un vote, le trait épais est la médiane -->
<div class="median-demo__strip" aria-hidden="true">
<span
v-for="(vote, i) in votes"
:key="i"
class="median-demo__dot"
:style="{ left: `${vote}%` }"
/>
<span class="median-demo__median-mark" :style="{ left: `${median}%` }" />
</div>
<p class="median-demo__readout">
Médiane basse : <strong>{{ median }}</strong>
<span v-if="medianVoters.length" class="median-demo__who">
c'est la position de {{ medianVoters.join(' et ') }}
</span>
</p>
<p class="median-demo__explain">{{ MEDIAN_EXPLANATION }}</p>
<div class="median-demo__rule">
<UIcon name="i-lucide-stamp" class="median-demo__rule-icon" />
<div>
<p class="median-demo__rule-title">L'agrégat éclaire, le geste cristallise.</p>
<p class="median-demo__rule-body">
À la clôture, les votes sont figés et la médiane s'affiche mais rien n'est
adopté tant qu'un·e garant·e n'a pas fait le geste « {{ CRYSTALLIZE_ACTION }} »,
daté et signé. Jamais d'adoption automatique.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.median-demo { display: flex; flex-direction: column; gap: 0.9rem; }
.median-demo__sliders { display: flex; flex-direction: column; gap: 0.45rem; }
.median-demo__vote {
display: grid;
grid-template-columns: 3.4rem 1fr 2.4rem;
align-items: center;
gap: 0.6rem;
}
.median-demo__voter { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
.median-demo__vote input[type='range'] {
width: 100%;
accent-color: var(--mood-tertiary);
background: transparent;
min-height: 1.3rem;
}
.median-demo__value {
font-size: 0.8125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--mood-text);
}
.median-demo__strip {
position: relative;
height: 2rem;
margin: 0 0.3rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 5%, transparent);
}
.median-demo__dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--mood-tertiary);
opacity: 0.55;
transition: left 0.15s ease;
}
.median-demo__median-mark {
position: absolute;
top: 3px;
bottom: 3px;
transform: translateX(-50%);
width: 4px;
border-radius: 2px;
background: var(--mood-accent);
transition: left 0.15s ease;
}
.median-demo__readout { font-size: 0.9375rem; color: var(--mood-text); }
.median-demo__readout strong {
color: var(--mood-accent);
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.median-demo__who { font-size: 0.8125rem; color: var(--mood-text-muted); }
.median-demo__explain {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--mood-text-muted);
}
.median-demo__rule {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.8rem 0.95rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
}
.median-demo__rule-icon {
font-size: 1.2rem;
color: var(--mood-accent);
margin-top: 2px;
flex-shrink: 0;
}
.median-demo__rule-title { font-size: 0.875rem; font-weight: 800; color: var(--mood-text); }
.median-demo__rule-body {
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
margin-top: 2px;
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
// <!-- ld-v2 --> Carte d'une clause dans la vue structurée : code, titre,
// mini-jauge de session réelle, pastille d'inertie câblée, statut — tap pour
// déplier le détail complet (ClauseDetail).
import type { ClauseView } from './textsModel'
defineProps<{
view: ClauseView
expanded: boolean
}>()
defineEmits<{ (e: 'toggle'): void }>()
</script>
<template>
<!-- ld-v2 -->
<article class="clause-card ld-card">
<button type="button" class="clause-card__row" @click="$emit('toggle')">
<span class="clause-card__code">{{ view.clause.code }}</span>
<span class="clause-card__title">{{ view.clause.title }}</span>
<span class="clause-card__side">
<SessionMiniGauge v-if="view.gauge" :gauge="view.gauge" @click.stop />
<InertiaBadge
:preset="view.clause.inertia"
:amend-protocol="view.amendProtocol"
:protected-clause="view.protectedClause"
compact
/>
<span class="status-pill clause-card__status" :class="view.status.css">
{{ view.status.label }}
</span>
</span>
</button>
<p v-if="view.settingText && !expanded" class="clause-card__setting">
{{ view.settingText }}
</p>
<ClauseDetail
v-if="expanded"
class="clause-card__detail"
:clause="view.clause"
:current-version="view.current"
:proposed="view.proposed"
:founding="view.founding"
:amendments="view.amendments"
:setting-text="view.settingText"
:gauge="view.gauge"
:atelier-link="view.atelierLink"
/>
</article>
</template>
<style scoped>
.clause-card { overflow: hidden; }
.clause-card__row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 0.9rem;
background: none;
cursor: pointer;
text-align: left;
flex-wrap: wrap;
}
.clause-card__row:hover { background: color-mix(in srgb, var(--mood-accent) 4%, transparent); }
.clause-card__code {
font-family: monospace;
font-size: 0.75rem;
font-weight: 800;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 8px;
border-radius: 7px;
flex-shrink: 0;
}
.clause-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
flex: 1;
min-width: 10rem;
}
.clause-card__side { display: inline-flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.clause-card__status { font-size: 0.6875rem; padding: 2px 9px; }
.clause-card__setting {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
padding: 0 0.9rem 0.7rem;
}
.clause-card__detail { margin: 0 0.55rem 0.55rem; }
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
// <!-- ld-v2 --> Détail dépliable d'une clause : version courante (markdown
// simple), versions proposées avec diff (contenu d'abord, auteur replié),
// décision fondatrice et amendements chaînés, valeur en clair des clauses de
// réglage du Pacte (« effet immédiat à l'adoption »), geste « Proposer une version ».
import type { Clause, ClauseVersion } from '~/types/domain'
import { STATUS_LABELS } from '~/lexicon'
import type { ChainEntry, ClauseSessionGauge, ProposedEntry } from './textsModel'
import { formatDateFr } from './textsModel'
const props = withDefaults(defineProps<{
clause: Clause
currentVersion?: ClauseVersion
proposed?: ProposedEntry[]
founding?: ChainEntry
amendments?: ChainEntry[]
/** Valeur en clair (clauses de réglage du Pacte). */
settingText?: string | null
gauge?: ClauseSessionGauge | null
/** Atelier pré-réglé sur le protocole d'amendement de cette clause. */
atelierLink?: string | null
}>(), { proposed: () => [], amendments: () => [], settingText: null, gauge: null, atelierLink: null })
const hasChain = computed(() => props.founding !== undefined || props.amendments.length > 0)
</script>
<template>
<!-- ld-v2 -->
<div class="clause-detail">
<!-- Valeur en clair Pacte -->
<div v-if="settingText" class="clause-detail__setting">
<UIcon name="i-lucide-settings-2" class="clause-detail__setting-icon" />
<div>
<p class="clause-detail__setting-value">{{ settingText }}</p>
<p class="clause-detail__setting-note">Effet immédiat à l'adoption — le Pacte est le réglage.</p>
</div>
</div>
<!-- Session en cours -->
<SessionMiniGauge v-if="gauge" :gauge="gauge" with-title class="clause-detail__gauge" />
<!-- Version courante -->
<div v-if="currentVersion" class="clause-detail__current">
<p class="clause-detail__heading">
Version en vigueur
<span class="clause-detail__version-label">{{ currentVersion.versionLabel }}</span>
<span v-if="currentVersion.adoptedAt" class="clause-detail__date">
adoptée le {{ formatDateFr(currentVersion.adoptedAt) }}
</span>
</p>
<MarkdownRenderer :content="currentVersion.content" class="clause-detail__content" />
</div>
<p v-else class="clause-detail__empty">Cette clause n'a pas encore de version en vigueur.</p>
<!-- Versions proposées : le contenu d'abord, l'auteur replié -->
<div v-if="proposed.length" class="clause-detail__proposals">
<p class="clause-detail__heading">
{{ proposed.length > 1 ? `${proposed.length} versions proposées` : 'Une version est proposée' }}
</p>
<article
v-for="entry in proposed"
:key="entry.version.id"
class="clause-detail__proposal"
>
<ClauseVersionDiff
:current="currentVersion?.content ?? ''"
:proposed="entry.version.content"
/>
<details class="clause-detail__author">
<summary class="clause-detail__author-summary">
<UIcon name="i-lucide-user" />
Qui propose ? <span class="clause-detail__version-label">{{ entry.version.versionLabel }}</span>
</summary>
<p class="clause-detail__author-body">
Proposée par <strong>{{ entry.authorName }}</strong>
<template v-if="entry.decisionId">
dans le cadre de
<NuxtLink :to="`/decisions/${entry.decisionId}`" class="clause-detail__link">
{{ entry.decisionTitle ?? 'la décision liée' }}
</NuxtLink>
</template>
</p>
</details>
</article>
</div>
<!-- Décision fondatrice + amendements chaînés -->
<div v-if="hasChain" class="clause-detail__chain">
<p class="clause-detail__heading">D'où vient cette clause</p>
<ol class="clause-detail__chain-list">
<li v-if="founding" class="clause-detail__chain-item">
<UIcon name="i-lucide-sprout" class="clause-detail__chain-icon" />
<NuxtLink :to="`/decisions/${founding.id}`" class="clause-detail__link">
{{ founding.title }}
</NuxtLink>
<span v-if="founding.decidedAt" class="clause-detail__date">
{{ formatDateFr(founding.decidedAt) }}
</span>
</li>
<li
v-for="amendment in amendments"
:key="amendment.id"
class="clause-detail__chain-item"
>
<UIcon name="i-lucide-git-commit-vertical" class="clause-detail__chain-icon" />
<NuxtLink :to="`/decisions/${amendment.id}`" class="clause-detail__link">
{{ amendment.title }}
</NuxtLink>
<span class="status-pill" :class="`status-${amendment.status}`">
{{ STATUS_LABELS[amendment.status] }}
</span>
</li>
</ol>
</div>
<!-- Le geste -->
<div class="clause-detail__actions">
<NuxtLink :to="`/decider?clause=${clause.id}`" class="ld-btn clause-detail__propose">
<UIcon name="i-lucide-pen-line" />
Proposer une version
</NuxtLink>
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--quiet clause-detail__propose">
<UIcon name="i-lucide-flask-conical" />
Comprendre ce seuil
</NuxtLink>
<p class="clause-detail__actions-note">
Amender, c'est décider le chemin s'ouvre avec l'inertie de cette clause.
</p>
</div>
</div>
</template>
<style scoped>
.clause-detail {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.9rem 1rem 1.1rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 2.5%, var(--mood-surface));
}
.clause-detail__setting {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.7rem 0.9rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
}
.clause-detail__setting-icon { color: var(--mood-accent); font-size: 1.1rem; margin-top: 2px; }
.clause-detail__setting-value { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
.clause-detail__setting-note { font-size: 0.75rem; color: var(--mood-text-muted); margin-top: 2px; }
.clause-detail__gauge { align-self: flex-start; }
.clause-detail__heading {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
margin-bottom: 0.4rem;
}
.clause-detail__version-label {
font-family: monospace;
font-size: 0.6875rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text);
text-transform: none;
letter-spacing: 0;
}
.clause-detail__date {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: none;
letter-spacing: 0;
}
.clause-detail__content { font-size: 0.875rem; }
.clause-detail__empty { font-size: 0.8125rem; color: var(--mood-text-muted); font-style: italic; }
.clause-detail__proposal { display: flex; flex-direction: column; gap: 0.4rem; }
.clause-detail__proposal + .clause-detail__proposal { margin-top: 0.75rem; }
.clause-detail__author-summary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
user-select: none;
}
.clause-detail__author-body { font-size: 0.8125rem; color: var(--mood-text); margin-top: 0.35rem; }
.clause-detail__chain-list { display: flex; flex-direction: column; gap: 0.35rem; }
.clause-detail__chain-item {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.8125rem;
}
.clause-detail__chain-icon { color: var(--mood-accent); font-size: 0.95rem; flex-shrink: 0; }
.clause-detail__chain-item .status-pill { font-size: 0.6875rem; padding: 2px 9px; }
.clause-detail__link { color: var(--mood-accent); text-decoration: none; font-weight: 600; }
.clause-detail__link:hover { text-decoration: underline; }
.clause-detail__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.clause-detail__propose { text-decoration: none; font-size: 0.875rem; }
.clause-detail__actions-note { font-size: 0.75rem; color: var(--mood-text-muted); }
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
// <!-- ld-v2 --> Diff inline +/ entre la version courante et une version
// proposée — contenu d'abord, calculé localement (LCS ligne à ligne, sans lib).
const props = defineProps<{
current: string
proposed: string
}>()
interface DiffLine {
text: string
type: 'kept' | 'removed' | 'added'
}
/** Line-based LCS diff — texts are clause-sized, the DP table stays tiny. */
function lineDiff(a: string[], b: string[]): DiffLine[] {
const n = a.length
const m = b.length
// lcs[i][j] = LCS length of a[i:] / b[j:]
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i]![j] = a[i] === b[j]
? lcs[i + 1]![j + 1]! + 1
: Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ text: a[i]!, type: 'kept' })
i++
j++
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
out.push({ text: a[i]!, type: 'removed' })
i++
} else {
out.push({ text: b[j]!, type: 'added' })
j++
}
}
while (i < n) out.push({ text: a[i++]!, type: 'removed' })
while (j < m) out.push({ text: b[j++]!, type: 'added' })
return out
}
const lines = computed(() => {
const a = props.current.split('\n')
const b = props.proposed.split('\n')
return lineDiff(a, b).filter(l => !(l.type === 'kept' && l.text.trim() === ''))
})
const changedCount = computed(() => lines.value.filter(l => l.type !== 'kept').length)
</script>
<template>
<!-- ld-v2 -->
<div class="clause-diff">
<div v-if="changedCount === 0" class="clause-diff__same">
Aucun changement de texte les deux versions sont identiques.
</div>
<div
v-for="(line, index) in lines"
v-else
:key="index"
class="clause-diff__line"
:class="`clause-diff__line--${line.type}`"
>
<span class="clause-diff__marker" aria-hidden="true">
{{ line.type === 'added' ? '+' : line.type === 'removed' ? '' : '' }}
</span>
<span class="clause-diff__text">{{ line.text }}</span>
</div>
</div>
</template>
<style scoped>
.clause-diff {
border-radius: var(--r-input);
overflow: hidden;
background: color-mix(in srgb, var(--mood-text) 3%, var(--mood-surface));
font-size: 0.8125rem;
line-height: 1.55;
}
.clause-diff__same {
padding: 0.6rem 0.9rem;
color: var(--mood-text-muted);
font-style: italic;
}
.clause-diff__line {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0.9rem;
}
.clause-diff__line--removed {
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: color-mix(in srgb, var(--mood-error) 80%, var(--mood-text));
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.clause-diff__line--added {
background: color-mix(in srgb, var(--mood-success) 11%, transparent);
color: color-mix(in srgb, var(--mood-success) 75%, var(--mood-text));
}
.clause-diff__marker {
flex-shrink: 0;
width: 0.9rem;
font-weight: 800;
font-family: monospace;
text-align: center;
}
.clause-diff__text { white-space: pre-wrap; word-break: break-word; }
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
// <!-- ld-v2 --> Pastille d'inertie d'une clause — CÂBLÉE au protocole
// d'amendement résolu par le Pacte (settings.protocolByRange.clauseByInertia) :
// le tooltip dit par quel protocole cette clause s'amende, en français,
// sans aucune lettre de formule (elles vivent à l'Atelier seulement).
import type { InertiaPreset } from '~/types/domain'
import { INERTIA_LABELS } from '~/lexicon'
import { INERTIA_COLORS, INERTIA_ORDER } from './textsModel'
const props = withDefaults(defineProps<{
preset: InertiaPreset
/** Résolu par la page : « Vote WoT standard — 30 jours ». */
amendProtocol?: string
/** Clause qui protège le réglage de l'inertie lui-même (pastille max). */
protectedClause?: boolean
compact?: boolean
}>(), { compact: false, protectedClause: false })
const color = computed(() => INERTIA_COLORS[props.preset])
const label = computed(() => INERTIA_LABELS[props.preset])
const steps = computed(() => INERTIA_ORDER.indexOf(props.preset) + 1)
const tooltip = computed(() => {
const lines: string[] = [label.value]
if (props.amendProtocol) lines.push(`S'amende par : ${props.amendProtocol}`)
if (props.protectedClause) lines.push('Clause protégée — elle garde les règles qui gardent les règles.')
return lines.join('\n')
})
</script>
<template>
<!-- ld-v2 -->
<span
class="inertia-badge"
:class="{ 'inertia-badge--compact': compact }"
:style="{ '--ib-color': color }"
:title="tooltip"
>
<span class="inertia-badge__dots" aria-hidden="true">
<span
v-for="i in 4"
:key="i"
class="inertia-badge__dot"
:class="{ 'inertia-badge__dot--on': i <= steps }"
/>
</span>
<span v-if="!compact" class="inertia-badge__label">{{ label }}</span>
<UIcon v-if="protectedClause" name="i-lucide-lock" class="inertia-badge__lock" />
</span>
</template>
<style scoped>
.inertia-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--ib-color) 11%, transparent);
color: var(--ib-color);
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
cursor: help;
user-select: none;
}
.inertia-badge--compact { padding: 3px 7px; gap: 0.3rem; }
.inertia-badge__dots {
display: inline-flex;
align-items: center;
gap: 2.5px;
}
.inertia-badge__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: color-mix(in srgb, var(--ib-color) 25%, transparent);
}
.inertia-badge__dot--on { background: var(--ib-color); }
.inertia-badge__label { letter-spacing: 0.01em; }
.inertia-badge__lock { font-size: 0.8rem; }
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
// <!-- ld-v2 --> Mini-jauge d'une session RÉELLE en cours sur une version
// proposée : positions déposées / liste arrêtée, échéance, état figé.
import { FROZEN_LABEL } from '~/lexicon'
import type { ClauseSessionGauge } from './textsModel'
import { formatDateFr } from './textsModel'
const props = withDefaults(defineProps<{
gauge: ClauseSessionGauge
/** Affiche le titre de la décision au-dessus de la jauge. */
withTitle?: boolean
}>(), { withTitle: false })
const ratio = computed(() => {
if (props.gauge.listSize <= 0) return 0
return Math.min(100, Math.round((props.gauge.positions / props.gauge.listSize) * 100))
})
const tooltip = computed(() =>
`${props.gauge.decisionTitle}\n${props.gauge.positions} position${props.gauge.positions > 1 ? 's' : ''} `
+ `sur une liste arrêtée de ${props.gauge.listSize} — clôture le ${formatDateFr(props.gauge.closesAt)}`)
</script>
<template>
<!-- ld-v2 -->
<NuxtLink class="mini-gauge" :to="`/decisions/${gauge.decisionId}`" :title="tooltip">
<span v-if="withTitle" class="mini-gauge__title">{{ gauge.decisionTitle }}</span>
<span class="mini-gauge__row">
<UIcon :name="gauge.frozen ? 'i-lucide-snowflake' : 'i-lucide-vote'" class="mini-gauge__icon" />
<span class="mini-gauge__track">
<span class="mini-gauge__fill" :style="{ width: `${ratio}%` }" />
</span>
<span class="mini-gauge__count">{{ gauge.positions }}/{{ gauge.listSize }}</span>
<span v-if="gauge.frozen" class="mini-gauge__frozen">{{ FROZEN_LABEL }}</span>
</span>
</NuxtLink>
</template>
<style scoped>
.mini-gauge {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
text-decoration: none;
color: inherit;
}
.mini-gauge__title {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 15rem;
}
.mini-gauge__row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.mini-gauge__icon {
font-size: 0.85rem;
color: var(--mood-status-vote);
flex-shrink: 0;
}
.mini-gauge__track {
position: relative;
width: 3.5rem;
height: 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
overflow: hidden;
}
.mini-gauge__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 3px;
background: var(--mood-status-vote);
transition: width 0.3s ease;
}
.mini-gauge__count {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-status-vote);
font-variant-numeric: tabular-nums;
}
.mini-gauge__frozen {
font-size: 0.625rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-pill);
background: var(--mood-status-fige-bg);
color: var(--mood-status-fige);
}
</style>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// <!-- ld-v2 --> Carte d'un document de la bibliothèque : titre, rôle,
// nombre de clauses, mini-jauges des sessions en cours sur ses clauses,
// provenance résumée (version, vote d'origine), badge 井 si gravures.
import type { TextDoc } from '~/types/domain'
import { PACT_BADGE } from '~/lexicon'
import type { ClauseSessionGauge } from './textsModel'
import { formatInt } from './textsModel'
const props = withDefaults(defineProps<{
doc: TextDoc
clauseCount: number
gauges?: ClauseSessionGauge[]
engraved?: number
/** Carte épinglée du Pacte — mise en avant. */
pinned?: boolean
}>(), { gauges: () => [], engraved: 0, pinned: false })
const roleLabel = computed(() => (props.doc.role === 'pact' ? PACT_BADGE : 'document de référence'))
const version = computed(() =>
props.doc.provenance?.sources.find(s => s.version)?.version)
const voteSummary = computed(() => {
const r = props.doc.provenance?.voteRecord?.result
if (!r) return null
return `${formatInt(r.for)} pour · ${formatInt(r.against)} contre — toile de ${formatInt(r.wotSize)}`
})
const visibleGauges = computed(() => props.gauges.slice(0, 3))
</script>
<template>
<!-- ld-v2 -->
<NuxtLink
:to="`/textes/${doc.slug}`"
class="doc-card ld-card ld-card--hover"
:class="{ 'doc-card--pinned': pinned }"
>
<header class="doc-card__head">
<span class="doc-card__role" :class="{ 'doc-card__role--pact': doc.role === 'pact' }">
<UIcon :name="doc.role === 'pact' ? 'i-lucide-scroll' : 'i-lucide-book-open'" />
{{ roleLabel }}
</span>
<span v-if="engraved > 0" class="doc-card__engraved" title="Décisions gravées sur ce texte">
{{ engraved }}
</span>
</header>
<h3 class="doc-card__title">{{ doc.title }}</h3>
<p class="doc-card__desc">{{ doc.description }}</p>
<div class="doc-card__meta">
<span class="doc-card__chip">
<UIcon name="i-lucide-list" />
{{ clauseCount }} clause{{ clauseCount > 1 ? 's' : '' }}
</span>
<span v-if="version" class="doc-card__chip doc-card__chip--mono">v{{ version }}</span>
<span v-if="voteSummary" class="doc-card__chip doc-card__chip--vote">
<UIcon name="i-lucide-vote" />
{{ voteSummary }}
</span>
</div>
<div v-if="visibleGauges.length" class="doc-card__sessions">
<p class="doc-card__sessions-heading">
{{ gauges.length > 1 ? `${gauges.length} sessions en cours` : 'Une session en cours' }}
</p>
<div class="doc-card__gauges">
<SessionMiniGauge
v-for="gauge in visibleGauges"
:key="gauge.decisionId"
:gauge="gauge"
@click.stop
/>
</div>
</div>
</NuxtLink>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: clamp(1rem, 3vw, 1.35rem);
text-decoration: none;
color: inherit;
}
.doc-card--pinned {
background:
linear-gradient(140deg, var(--mood-accent-soft), transparent 55%),
var(--mood-surface);
}
.doc-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__role {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-card__role--pact { background: var(--mood-accent-soft); color: var(--mood-accent); }
.doc-card__engraved {
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-status-vigueur);
}
.doc-card__title {
font-size: clamp(1rem, 2.5vw, 1.15rem);
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
line-height: 1.25;
}
.doc-card__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.doc-card__meta { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.15rem; }
.doc-card__chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.71875rem;
font-weight: 700;
padding: 3px 9px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-card__chip--mono { font-family: monospace; }
.doc-card__chip--vote {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
}
.doc-card__sessions {
margin-top: 0.35rem;
padding-top: 0.6rem;
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 7%, transparent);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.doc-card__sessions-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-status-vote);
}
.doc-card__gauges { display: flex; flex-direction: column; gap: 0.3rem; }
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
// <!-- ld-v2 --> Provenance d'un document — le bloc de genèse porté du v1 :
// sources datées, vote d'origine (résultats bruts, période, lien), contributeurs,
// notes (les typos des textes votés sont signalées, jamais corrigées).
// Aucune lettre de formule ici : « comprendre ce seuil » mène à l'Atelier pré-réglé.
import type { Provenance } from '~/types/domain'
import { parseModeParams } from '~/engine'
import { formatInt } from './textsModel'
const props = defineProps<{ provenance: Provenance }>()
const record = computed(() => props.provenance.voteRecord)
/** Query Atelier pré-réglée depuis le vote d'origine (lettres côté URL seulement). */
const atelierLink = computed(() => {
const r = record.value
if (!r) return null
const p = parseModeParams(r.modeParams)
const query = new URLSearchParams({
W: String(r.result.wotSize),
T: String(r.result.for + r.result.against),
M: String(p.majority_pct),
B: String(p.base_exponent),
G: String(p.gradient_exponent),
C: String(p.constant_base),
})
if (p.smith_exponent !== null) query.set('S', String(p.smith_exponent))
return `/textes/formules?${query.toString()}`
})
const sourcesOpen = ref(false)
</script>
<template>
<!-- ld-v2 -->
<section class="prov ld-card">
<header class="prov__header">
<UIcon name="i-lucide-landmark" class="prov__header-icon" />
<div>
<h2 class="prov__title">Provenance</h2>
<p class="prov__sub">d'où vient ce texte, et par quel geste il fait loi</p>
</div>
</header>
<!-- Vote d'origine -->
<div v-if="record" class="prov__vote">
<p class="prov__vote-heading">Le vote d'origine</p>
<div class="prov__vote-figures">
<span class="prov__figure prov__figure--for">{{ formatInt(record.result.for) }} pour</span>
<span class="prov__figure prov__figure--against">{{ formatInt(record.result.against) }} contre</span>
<span v-if="record.result.invalid" class="prov__figure prov__figure--muted">
{{ formatInt(record.result.invalid) }} invalides
</span>
</div>
<p class="prov__vote-line">
Toile de {{ formatInt(record.result.wotSize) }} membres —
seuil requis : {{ formatInt(record.result.thresholdRequired) }} pour.
Résultat : <strong>{{ record.result.status }}</strong>.
</p>
<p class="prov__vote-line prov__vote-line--muted">Période : {{ record.period }}</p>
<div class="prov__vote-actions">
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--ghost prov__btn">
<UIcon name="i-lucide-flask-conical" />
Comprendre ce seuil
</NuxtLink>
<a :href="record.url" target="_blank" rel="noopener" class="ld-btn ld-btn--quiet prov__btn">
<UIcon name="i-lucide-external-link" />
Le fil du vote
</a>
</div>
</div>
<!-- Sources -->
<div v-if="provenance.sources.length" class="prov__block">
<button type="button" class="prov__toggle" @click="sourcesOpen = !sourcesOpen">
<UIcon name="i-lucide-library" />
{{ provenance.sources.length }} source{{ provenance.sources.length > 1 ? 's' : '' }}
<UIcon
name="i-lucide-chevron-down"
class="prov__chevron"
:class="{ 'prov__chevron--open': sourcesOpen }"
/>
</button>
<ul v-show="sourcesOpen" class="prov__sources">
<li v-for="source in provenance.sources" :key="source.url" class="prov__source">
<a :href="source.url" target="_blank" rel="noopener" class="prov__source-link">
{{ source.title }}
</a>
<span v-if="source.version" class="prov__source-meta">v{{ source.version }}</span>
<span v-if="source.date" class="prov__source-meta">{{ source.date }}</span>
</li>
</ul>
</div>
<!-- Contributeurs -->
<div v-if="provenance.contributors?.length" class="prov__block">
<p class="prov__block-heading">
<UIcon name="i-lucide-users" />
Contributeurs
</p>
<ul class="prov__contributors">
<li v-for="person in provenance.contributors" :key="person" class="prov__contributor">
{{ person }}
</li>
</ul>
</div>
<!-- Notes -->
<details v-if="provenance.notes" class="prov__notes">
<summary class="prov__notes-summary">
<UIcon name="i-lucide-sticky-note" />
Notes de provenance — les coquilles des textes votés sont signalées, jamais corrigées
</summary>
<p class="prov__notes-body">{{ provenance.notes }}</p>
</details>
</section>
</template>
<style scoped>
.prov {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.prov__header { display: flex; align-items: center; gap: 0.75rem; }
.prov__header-icon {
font-size: 1.4rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: var(--r-icon);
padding: 0.5rem;
box-sizing: content-box;
}
.prov__title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
.prov__sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.prov__vote {
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prov__vote-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
}
.prov__vote-figures { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.prov__figure {
padding: 3px 11px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
}
.prov__figure--for { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
.prov__figure--against { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
.prov__figure--muted { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
.prov__vote-line { font-size: 0.875rem; color: var(--mood-text); }
.prov__vote-line--muted { font-size: 0.8125rem; color: var(--mood-text-muted); }
.prov__vote-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.25rem; }
.prov__btn { font-size: 0.8125rem; min-height: 2rem; padding: 0.35rem 0.9rem; text-decoration: none; }
.prov__block { display: flex; flex-direction: column; gap: 0.5rem; }
.prov__block-heading,
.prov__toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.prov__toggle { background: none; cursor: pointer; padding: 0; align-self: flex-start; }
.prov__chevron { transition: transform 0.2s ease; color: var(--mood-text-muted); }
.prov__chevron--open { transform: rotate(180deg); }
.prov__sources { display: flex; flex-direction: column; gap: 0.3rem; }
.prov__source { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; }
.prov__source-link {
font-size: 0.8125rem;
color: var(--mood-accent);
text-decoration: none;
}
.prov__source-link:hover { text-decoration: underline; }
.prov__source-meta { font-size: 0.6875rem; color: var(--mood-text-muted); font-family: monospace; }
.prov__contributors { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.prov__contributor {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
color: var(--mood-secondary);
}
.prov__notes-summary {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
user-select: none;
}
.prov__notes-body {
margin-top: 0.6rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--mood-text-muted);
white-space: pre-wrap;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
/**
* Shared pure helpers for the texts screens — library (/textes), living
* document (/textes/[slug]) and formula atelier (/textes/formules).
* No I/O, no store access: pages assemble, these functions compute.
*/
import type {
Clause,
ClauseVersion,
CollectiveSettings,
Decision,
DecisionStatus,
Id,
InertiaPreset,
ISODate,
Json,
Person,
Protocol,
Vote,
VoteSession,
} from '~/types/domain'
import { INERTIA_LABELS } from '~/lexicon'
// ── Shared view-model shapes (SFC scripts cannot export types) ──
/** A proposed clause version, content first — author folded behind it. */
export interface ProposedEntry {
version: ClauseVersion
authorName: string
decisionId?: Id
decisionTitle?: string
}
/** One link of a clause's decision chain (founding, then amendments). */
export interface ChainEntry {
id: Id
title: string
status: DecisionStatus
decidedAt?: string
}
/** Query-preset state of the formula atelier (?W=&T=&M=&B=&G=&C=&S=). */
export interface AtelierInit {
W?: number
T?: number
M?: number
B?: number
G?: number
C?: number
S?: number
}
// ── Inertia presets — REAL wiring (types/domain.ts) ──────────
export const INERTIA_PARAMS: Record<InertiaPreset, { majorityPct: number; gradientExponent: number }> = {
low: { majorityPct: 50, gradientExponent: 0.1 },
standard: { majorityPct: 50, gradientExponent: 0.2 },
high: { majorityPct: 60, gradientExponent: 0.4 },
max: { majorityPct: 66, gradientExponent: 0.6 },
}
/** Tint per preset — derived from the mood custom properties. */
export const INERTIA_COLORS: Record<InertiaPreset, string> = {
low: 'var(--mood-success)',
standard: 'var(--mood-accent)',
high: 'var(--mood-warning)',
max: 'var(--mood-error)',
}
export const INERTIA_ORDER: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
// ── Sections — labels + icons for known tags, graceful fallback ──
const SECTION_META: Record<string, { label: string; icon: string }> = {
preambule: { label: 'Préambule', icon: 'i-lucide-compass' },
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
mission: { label: 'Mission', icon: 'i-lucide-target' },
composition: { label: 'Composition', icon: 'i-lucide-users' },
engagements: { label: 'Engagements', icon: 'i-lucide-heart-handshake' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
// the raw tag never reaches the UI — this label replaces it
triage: { label: 'Seuils et fenêtres', icon: 'i-lucide-route' },
protocoles: { label: 'Protocoles de vote', icon: 'i-lucide-vote' },
}
export function sectionMeta(tag: string): { label: string; icon: string } {
const known = SECTION_META[tag]
if (known) return known
const label = tag.charAt(0).toUpperCase() + tag.slice(1)
return { label, icon: 'i-lucide-file-text' }
}
// ── Formatting ───────────────────────────────────────────────
export function formatDateFr(iso: ISODate | undefined): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
export function formatInt(n: number): string {
return n.toLocaleString('fr-FR')
}
// ── Live sessions on clause amendments — the mini-gauges ─────
export interface ClauseSessionGauge {
clauseId: Id
clauseCode: string
decisionId: Id
decisionTitle: string
positions: number // last active votes cast
listSize: number // arrested voter list size
closesAt: ISODate
frozen: boolean
}
/** Last-active-vote count of a session (supersedes chains resolved). */
function activeVoteCount(sessionId: Id, votes: Vote[]): number {
const sessionVotes = votes.filter(v => v.sessionId === sessionId)
const superseded = new Set(
sessionVotes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined),
)
return sessionVotes.filter(v => !superseded.has(v.id)).length
}
/**
* Real running sessions (open or frozen) over decisions that amend one of
* `clauses` — one gauge per amending decision, latest session wins.
*/
export function liveClauseGauges(
clauses: Clause[],
decisions: Decision[],
sessions: VoteSession[],
votes: Vote[],
): ClauseSessionGauge[] {
const byId = new Map(clauses.map(c => [c.id, c]))
const gauges: ClauseSessionGauge[] = []
for (const decision of decisions) {
if (!decision.amendsClauseId) continue
const clause = byId.get(decision.amendsClauseId)
if (!clause) continue
const latest = sessions
.filter(s => s.decisionId === decision.id)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
if (!latest || (latest.status !== 'open' && latest.status !== 'frozen')) continue
gauges.push({
clauseId: clause.id,
clauseCode: clause.code,
decisionId: decision.id,
decisionTitle: decision.title,
positions: activeVoteCount(latest.id, votes),
listSize: latest.corpusSize,
closesAt: latest.closesAt,
frozen: latest.status === 'frozen',
})
}
return gauges
}
/** Engraved decisions linked to these clauses (井 filter of the library). */
export function engravedCount(clauses: Clause[], decisions: Decision[]): number {
const ids = new Set(clauses.map(c => c.id))
return decisions.filter(d => d.engraving && d.amendsClauseId && ids.has(d.amendsClauseId)).length
}
// ── Versions per clause ──────────────────────────────────────
/** The 'current' version of a clause, preferring clause.currentVersionId. */
export function currentVersionOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion | undefined {
const mine = versions.filter(v => v.clauseId === clause.id)
if (clause.currentVersionId) {
const pinned = mine.find(v => v.id === clause.currentVersionId)
if (pinned) return pinned
}
return mine.find(v => v.status === 'current')
}
export function proposedVersionsOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion[] {
return versions
.filter(v => v.clauseId === clause.id && v.status === 'proposed')
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
}
// ── Pact setting clauses — the VALUE IN CLEAR ────────────────
const EFFECTS_LABELS: Record<string, string> = {
none: 'aucune exigence',
structural: 'décisions structurantes',
binding: 'décisions engageantes et structurantes',
}
/**
* One French sentence stating the current value of a Pact setting clause
* (« Petit groupe : jusqu'à 5 personnes »). `protocolName` resolves the
* protocols.* keys; returns null when nothing readable can be said.
*/
export function settingSentence(
key: string,
value: Json | undefined,
protocolName?: string,
): string | null {
if (value === undefined && !key.startsWith('protocols.')) return null
switch (key) {
case 'triage.smallGroupMax': return `Petit groupe : jusqu'à ${value} personnes`
case 'triage.collectiveMin': return `Grand corps : à partir de ${value} personnes`
case 'triage.consentMax': return `Tour de consentement : jusqu'à ${value} personnes`
case 'triage.objectionWindowHours': return `Fenêtre d'objection : ${value} heures`
case 'triage.adviceWindowHours': return `Fenêtre d'avis : ${value} heures`
case 'triage.framingDays': return `Temps de formulation : ${value} jours`
case 'triage.concernEscalateRatio':
return `Affluence : traitement obligatoire à partir de ${Math.round(Number(value) * 100)} % de concernés`
case 'triage.recurrenceThreshold':
return `Récurrence : ${value} décisions semblables suggèrent une règle`
case 'triage.reviewDelayDays': return `Épreuve du réel : ${value} jours après adoption`
case 'triage.requireEffects':
return `Matière exigée : ${EFFECTS_LABELS[String(value)] ?? String(value)}`
}
if (key.startsWith('protocols.clauseByInertia.')) {
const preset = key.slice('protocols.clauseByInertia.'.length) as InertiaPreset
const label = INERTIA_LABELS[preset]
return protocolName && label ? `Amender une clause en ${label} : ${protocolName}` : null
}
if (key.startsWith('protocols.')) {
return protocolName ? `Protocole retenu : ${protocolName}` : null
}
return null
}
// ── Full clause view assembly (page /textes/[slug]) ──────────
export interface ClauseViewCtx {
versions: ClauseVersion[]
decisions: Decision[]
people: Person[]
protocols: Protocol[]
settings: CollectiveSettings | null
gauges: Map<Id, ClauseSessionGauge>
isPact: boolean
memberCount: number
}
export interface ClauseView {
clause: Clause
current?: ClauseVersion
proposed: ProposedEntry[]
founding?: ChainEntry
amendments: ChainEntry[]
settingText: string | null
gauge: ClauseSessionGauge | null
/** « Vote WoT standard — 30 jours » — le protocole d'amendement résolu. */
amendProtocol?: string
atelierLink: string | null
protectedClause: boolean
status: { label: string; css: string }
}
/** The amendment protocol a clause resolves to, via the Pact settings. */
function amendProtocolOf(preset: InertiaPreset, ctx: ClauseViewCtx): Protocol | undefined {
const id = ctx.settings?.protocolByRange.clauseByInertia?.[preset]
?? ctx.settings?.protocolByRange.consent
return ctx.protocols.find(p => p.id === id)
}
/** Everything a clause row + detail needs, computed once per clause. */
export function buildClauseView(clause: Clause, ctx: ClauseViewCtx): ClauseView {
const current = currentVersionOf(clause, ctx.versions)
const proposedVersions = proposedVersionsOf(clause, ctx.versions)
const proposed: ProposedEntry[] = proposedVersions.map((version) => {
const decision = ctx.decisions.find(d => d.id === version.decisionId)
const author = ctx.people.find(p => p.id === decision?.authorId)
return {
version,
authorName: author?.displayName ?? 'quelqu\'un du collectif',
...(decision !== undefined
? { decisionId: decision.id, decisionTitle: decision.title }
: {}),
}
})
const chain: ChainEntry[] = ctx.decisions
.filter(d => d.amendsClauseId === clause.id)
.sort((a, b) => ((a.decidedAt ?? a.createdAt) < (b.decidedAt ?? b.createdAt) ? -1 : 1))
.map(d => ({
id: d.id,
title: d.title,
status: d.status,
...(d.decidedAt !== undefined ? { decidedAt: d.decidedAt } : {}),
}))
const [founding, ...amendments] = chain
const protocolNameForValue = typeof current?.settingValue === 'string'
? ctx.protocols.find(p => p.id === current.settingValue)?.name
: undefined
const settingText = ctx.isPact && clause.settingKey
? settingSentence(clause.settingKey, current?.settingValue, protocolNameForValue)
: null
const protocol = amendProtocolOf(clause.inertia, ctx)
const atelierLink = protocol?.method === 'binary'
? `/textes/formules?W=${ctx.memberCount}&M=${protocol.formula.majorityPct}`
+ `&B=${protocol.formula.baseExponent}&G=${protocol.formula.gradientExponent}`
+ `&C=${protocol.formula.constantBase}`
: null
const gauge = ctx.gauges.get(clause.id) ?? null
const status = gauge
? { label: 'en vote', css: 'status-voting' }
: proposed.length > 0
? { label: 'version proposée', css: 'status-framing' }
: { label: 'en vigueur', css: 'status-adopted' }
return {
clause,
...(current !== undefined ? { current } : {}),
proposed,
...(founding !== undefined ? { founding } : {}),
amendments,
settingText,
gauge,
...(protocol !== undefined
? { amendProtocol: `${protocol.name}${protocol.durationDays} jours` }
: {}),
atelierLink,
protectedClause: clause.settingKey?.startsWith('protocols.clauseByInertia.') === true,
status,
}
}
+183 -122
View File
@@ -1,142 +1,203 @@
<script setup lang="ts">
/**
* Binary vote component: Pour / Contre.
*
* Displays two large buttons for binary voting with confirmation modal.
* Integrates with the votes store and auth store for submission and access control.
*/
// <!-- ld-v2 --> Binaire inertiel hérité — jauge SIGNATURE : arc 270°, le
// remplissage suit les voix pour, le curseur de seuil DESCEND quand la
// participation monte (wotThreshold recalculé en direct). Les lettres de la
// formule vivent à l'Atelier ; ici, des chiffres clairs et une phrase.
import { wotThreshold } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { thresholdSentence } from '~/lexicon'
import type { Decision, Id, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
sessionId: string
disabled?: boolean
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const col = useCollectiveStore()
const store = useDecisionsStore()
const submitting = ref(false)
const pendingVote = ref<'pour' | 'contre' | null>(null)
const showConfirm = ref(false)
const eligible = computed(() => Math.max(props.session.corpusSize, 1))
const active = computed(() => store.activeVotes(props.session.id))
const votesFor = computed(() => active.value.filter(v => v.value === 'for').length)
const votesAgainst = computed(() => active.value.filter(v => v.value === 'against').length)
const total = computed(() => votesFor.value + votesAgainst.value)
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
const formula = computed(() => props.protocol.formula)
const threshold = computed(() => wotThreshold(
eligible.value, total.value,
formula.value.majorityPct, formula.value.baseExponent,
formula.value.gradientExponent, formula.value.constantBase,
))
const sentence = computed(() =>
total.value > 0 ? thresholdSentence(eligible.value, total.value, threshold.value) : '')
const formulaLink = computed(() => {
const f = formula.value
return `/textes/formules?W=${eligible.value}&T=${total.value}&M=${f.majorityPct}`
+ `&B=${f.baseExponent}&G=${f.gradientExponent}&C=${f.constantBase}`
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
// ── Arc 270° : de 135° à 405°, sens horaire ──
const R = 46
function pt(frac: number): { x: number; y: number } {
const a = ((135 + 270 * frac) * Math.PI) / 180
return { x: 60 + R * Math.cos(a), y: 60 + R * Math.sin(a) }
}
function arc(from: number, to: number): string {
const p1 = pt(from)
const p2 = pt(to)
const large = (to - from) > 2 / 3 ? 1 : 0
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} A ${R} ${R} 0 ${large} 1 ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`
}
function tick(frac: number): string {
const a = ((135 + 270 * frac) * Math.PI) / 180
const x1 = 60 + (R - 11) * Math.cos(a)
const y1 = 60 + (R - 11) * Math.sin(a)
const x2 = 60 + (R + 11) * Math.cos(a)
const y2 = 60 + (R + 11) * Math.sin(a)
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} L ${x2.toFixed(2)} ${y2.toFixed(2)}`
}
const trackPath = arc(0, 1)
const forFrac = computed(() => (total.value > 0 ? votesFor.value / total.value : 0))
const againstFrac = computed(() => (total.value > 0 ? votesAgainst.value / total.value : 0))
const thrFrac = computed(() => (total.value > 0 ? Math.min(threshold.value / total.value, 1) : 1))
const fillPath = computed(() => (forFrac.value > 0 ? arc(0, forFrac.value) : ''))
const againstPath = computed(() => (againstFrac.value > 0 ? arc(1 - againstFrac.value, 1) : ''))
const thresholdTick = computed(() => tick(thrFrac.value))
function requestVote(value: 'pour' | 'contre') {
if (isDisabled.value) return
pendingVote.value = value
showConfirm.value = true
// ── Le geste ──
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const refusing = ref(false)
const comment = ref('')
const error = ref('')
function cast(value: 'for' | 'against') {
error.value = ''
const result = store.castVote(props.session.id, {
value,
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) { error.value = result.reason; return }
comment.value = ''
refusing.value = false
}
async function confirmVote() {
if (!pendingVote.value) return
showConfirm.value = false
submitting.value = true
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: pendingVote.value,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
pendingVote.value = null
}
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
function cancelVote() {
showConfirm.value = false
pendingVote.value = null
}
const confirmLabel = computed(() => {
return pendingVote.value === 'pour'
? 'Confirmer le vote POUR'
: 'Confirmer le vote CONTRE'
})
</script>
<template>
<div class="space-y-4">
<!-- Vote buttons -->
<div class="flex gap-4">
<UButton
size="xl"
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'pour'"
icon="i-lucide-thumbs-up"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('pour')"
>
<!-- ld-v2 -->
<section class="ld-card vb">
<!-- La jauge inertielle -->
<div class="vb__gauge">
<svg viewBox="0 0 120 114" class="vb__svg" aria-hidden="true">
<path :d="trackPath" class="vb__track" />
<path v-if="againstPath" :d="againstPath" class="vb__against" />
<path v-if="fillPath" :d="fillPath" class="vb__fill" />
<path :d="thresholdTick" class="vb__threshold" />
</svg>
<div class="vb__center">
<span class="vb__for">{{ votesFor.toLocaleString('fr-FR') }}</span>
<span class="vb__for-label">pour</span>
</div>
</div>
<!-- Les chiffres, clairs -->
<dl class="vb__stats">
<div><dt>seuil requis</dt><dd>{{ threshold.toLocaleString('fr-FR') }}</dd></div>
<div><dt>votants</dt><dd>{{ total.toLocaleString('fr-FR') }}</dd></div>
<div><dt>inscrits</dt><dd>{{ eligible.toLocaleString('fr-FR') }}</dd></div>
</dl>
<p v-if="sentence" class="vb__sentence">{{ sentence }}</p>
<p v-else class="vb__sentence vb__sentence--muted">Personne n'a encore voté — le seuil part de l'unanimité et descend avec la participation.</p>
<NuxtLink :to="formulaLink" class="vb__link">
<UIcon name="i-lucide-graduation-cap" />
<span>comprendre ce seuil</span>
</NuxtLink>
<!-- Le geste -->
<div v-if="session.status === 'open'" class="vb__actions">
<button class="ld-btn vb__yes" type="button" :disabled="!canAct" @click="refusing = false; cast('for')">
Pour
</UButton>
<UButton
size="xl"
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'contre'"
icon="i-lucide-thumbs-down"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('contre')"
>
</button>
<button class="ld-btn ld-btn--ghost vb__no" type="button" :disabled="!canAct" @click="refusing = !refusing">
Contre
</UButton>
</button>
<span v-if="myVote" class="vb__mine">
Ta position actuelle : {{ myVote.value === 'for' ? 'pour' : 'refus argumenté' }}
</span>
</div>
<div v-if="refusing && session.status === 'open'" class="vb__argue">
<textarea v-model="comment" rows="2" placeholder="Dis pourquoi — un commentaire accompagne toute position négative." />
<button class="ld-btn" type="button" :disabled="!comment.trim()" @click="cast('against')">
Déposer ce refus
</button>
</div>
<p v-if="error" class="vb__error">{{ error }}</p>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
</strong>.
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton
:color="pendingVote === 'pour' ? 'success' : 'error'"
@click="confirmVote"
>
{{ confirmLabel }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
<!-- Clôture : la distribution complète -->
<p v-if="session.status === 'closed'" class="vb__outcome">
{{ votesFor.toLocaleString('fr-FR') }} pour · {{ votesAgainst.toLocaleString('fr-FR') }} refus ·
seuil {{ threshold.toLocaleString('fr-FR') }}
{{ session.outcome === 'adopted' ? 'le collectif adopte.' : 'le collectif nadopte pas.' }}
</p>
<ul v-if="comments.length" class="vb__comments">
<li v-for="vote in comments" :key="vote.id">
<span v-if="!secret" class="vb__comment-who">{{ name(vote.voterId) }}</span>
<p>{{ vote.comment }}</p>
</li>
</ul>
</section>
</template>
<style scoped>
.vb { padding: 1.25rem; display: flex; flex-direction: column; align-items: center; gap: 0.8rem; }
.vb__gauge { position: relative; width: min(240px, 70vw); }
.vb__svg { width: 100%; display: block; }
.vb__track { fill: none; stroke: var(--mood-accent-soft); stroke-width: 9; stroke-linecap: round; }
.vb__fill { fill: none; stroke: var(--mood-accent); stroke-width: 9; stroke-linecap: round; transition: d 0.25s ease; }
.vb__against { fill: none; stroke: color-mix(in srgb, var(--mood-error) 45%, transparent); stroke-width: 9; stroke-linecap: round; }
.vb__threshold { fill: none; stroke: var(--mood-secondary); stroke-width: 3.5; stroke-linecap: round; transition: d 0.25s ease; }
.vb__center {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; pointer-events: none;
}
.vb__for { font-size: clamp(1.6rem, 8vw, 2.2rem); font-weight: 800; color: var(--mood-accent); line-height: 1; }
.vb__for-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
.vb__stats { display: flex; gap: 1.5rem; margin: 0; }
.vb__stats div { display: flex; flex-direction: column; align-items: center; }
.vb__stats dt { font-size: 0.72rem; font-weight: 700; color: var(--mood-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.vb__stats dd { margin: 0; font-size: 1.125rem; font-weight: 800; }
.vb__stats div:first-child dd { color: var(--mood-secondary); }
.vb__sentence { margin: 0; text-align: center; font-size: 0.9375rem; line-height: 1.5; max-width: 34rem; }
.vb__sentence--muted { color: var(--mood-text-muted); }
.vb__link {
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.8125rem;
font-weight: 700; color: var(--mood-accent); text-decoration: none;
}
.vb__link:hover { text-decoration: underline; }
.vb__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 0.75rem; }
.vb__yes { min-width: 7rem; }
.vb__no { min-width: 7rem; color: var(--mood-error); background: color-mix(in srgb, var(--mood-error) 10%, transparent); }
.vb__mine { font-size: 0.8125rem; color: var(--mood-text-muted); width: 100%; text-align: center; }
.vb__argue { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
.vb__argue textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
.vb__argue .ld-btn { align-self: flex-end; }
.vb__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vb__outcome { margin: 0; font-weight: 700; font-size: 0.9375rem; text-align: center; }
.vb__comments { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
.vb__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
.vb__comments p { margin: 0.2rem 0 0; }
.vb__comment-who { font-weight: 700; }
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
// <!-- ld-v2 --> Consentement — « Ça me va » est un geste stocké (Assent),
// l'objection s'argumente toujours. Zéro objection maintenue = adopté.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { WINDOW_OBJECT, WINDOW_OK } from '~/lexicon'
import type { Decision, Id, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const assents = computed(() => col.assents.filter(a => a.decisionId === props.decision.id))
const objections = computed(() => col.objections.filter(o => o.decisionId === props.decision.id))
const openObjections = computed(() => objections.value.filter(o => o.status === 'open'))
const liftedObjections = computed(() => objections.value.filter(o => o.status !== 'open'))
const assentPeople = computed(() =>
assents.value
.map(a => col.people.find(p => p.id === a.personId))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
.map(person => ({ person })),
)
const gaveAssent = computed(() => {
const id = props.asPersonId ?? col.me?.id
return assents.value.some(a => a.personId === id)
})
const objecting = ref(false)
const argument = ref('')
const error = ref('')
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
function assent() {
error.value = ''
const result = store.assentTo(props.decision.id, props.asPersonId)
if ('ok' in result) error.value = result.reason
}
function object() {
error.value = ''
const result = store.objectTo(props.decision.id, 'content', argument.value, props.asPersonId)
if ('ok' in result) { error.value = result.reason; return }
argument.value = ''
objecting.value = false
}
const STATUS_LABEL = { withdrawn: 'levée', integrated: 'intégrée', escalated: 'escaladée' } as const
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vc">
<!-- Les deux gestes -->
<div v-if="session.status === 'open'" class="vc__gestures">
<button class="vc__ok" type="button" :disabled="!canAct || gaveAssent" @click="assent()">
<UIcon name="i-lucide-check" class="vc__gesture-icon" />
<span>{{ WINDOW_OK }}</span>
<span v-if="gaveAssent" class="vc__done">c'est noté</span>
</button>
<button class="vc__no" type="button" :disabled="!canAct" @click="objecting = !objecting">
<UIcon name="i-lucide-hand" class="vc__gesture-icon" />
<span>{{ WINDOW_OBJECT }}</span>
</button>
</div>
<div v-if="objecting && session.status === 'open'" class="vc__argue">
<textarea
v-model="argument"
rows="3"
placeholder="Une objection s'argumente — écris pourquoi."
/>
<button class="ld-btn" type="button" :disabled="!argument.trim()" @click="object()">
Déposer l'objection
</button>
</div>
<p v-if="error" class="vc__error">{{ error }}</p>
<!-- Décomptes agrégés -->
<div class="vc__tally">
<span class="vc__count">
<strong>{{ assents.length }}</strong>
{{ assents.length > 1 ? 'accords' : 'accord' }}
</span>
<LdAvatarStack v-if="!secret && assentPeople.length" :people="assentPeople" :size="26" />
<span class="vc__count">
<strong>{{ openObjections.length }}</strong>
{{ openObjections.length > 1 ? 'objections ouvertes' : 'objection ouverte' }}
</span>
</div>
<!-- Objections ouvertes puis levées -->
<ul v-if="objections.length" class="vc__objections">
<li v-for="objection in openObjections" :key="objection.id" class="vc__objection vc__objection--open">
<span class="vc__objection-state">ouverte</span>
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
<p>{{ objection.argument }}</p>
</li>
<li v-for="objection in liftedObjections" :key="objection.id" class="vc__objection">
<span class="vc__objection-state vc__objection-state--lifted">
{{ STATUS_LABEL[objection.status as keyof typeof STATUS_LABEL] ?? objection.status }}
</span>
<span v-if="!secret" class="vc__objection-who">{{ name(objection.personId) }}</span>
<p>{{ objection.argument }}</p>
<p v-if="objection.resolutionNote" class="vc__resolution">{{ objection.resolutionNote }}</p>
</li>
</ul>
<!-- Clôture -->
<p v-if="session.status === 'closed'" class="vc__outcome">
<template v-if="session.outcome === 'adopted'">
Aucune objection maintenue à l'échéance le collectif consent.
</template>
<template v-else>
Des objections restaient ouvertes à l'échéance le collectif ne consent pas encore.
</template>
</p>
</section>
</template>
<style scoped>
.vc { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.vc__gestures { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.vc__ok, .vc__no {
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
padding: 1.1rem 1rem; border-radius: var(--r-card); cursor: pointer;
font-size: 1.0625rem; font-weight: 800;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.vc__ok { background: color-mix(in srgb, var(--mood-success) 14%, var(--mood-surface)); color: var(--mood-success); }
.vc__no { background: color-mix(in srgb, var(--mood-warning) 13%, var(--mood-surface)); color: var(--mood-warning); }
.vc__ok:hover:not(:disabled), .vc__no:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
.vc__ok:active, .vc__no:active { transform: translateY(0); }
.vc__ok:disabled, .vc__no:disabled { opacity: 0.55; cursor: not-allowed; }
.vc__gesture-icon { font-size: 1.4rem; }
.vc__done { font-size: 0.75rem; font-weight: 600; opacity: 0.8; }
.vc__argue { display: flex; flex-direction: column; gap: 0.5rem; }
.vc__argue textarea { padding: 0.6rem 0.8rem; font-size: 0.9375rem; resize: vertical; }
.vc__argue .ld-btn { align-self: flex-end; }
.vc__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vc__tally { display: flex; align-items: center; flex-wrap: wrap; gap: 0.9rem; }
.vc__count { font-size: 0.9375rem; color: var(--mood-text-muted); }
.vc__count strong { color: var(--mood-text); font-size: 1.0625rem; }
.vc__objections { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.vc__objection { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.6rem 0.85rem; font-size: 0.9rem; }
.vc__objection--open { background: color-mix(in srgb, var(--mood-warning) 9%, var(--mood-bg)); }
.vc__objection p { margin: 0.25rem 0 0; }
.vc__objection-state {
font-size: 0.75rem; font-weight: 700; color: var(--mood-warning);
text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.5rem;
}
.vc__objection-state--lifted { color: var(--mood-success); }
.vc__objection-who { font-weight: 700; font-size: 0.8125rem; }
.vc__resolution { color: var(--mood-text-muted); font-style: italic; }
.vc__outcome { margin: 0; font-weight: 600; font-size: 0.9375rem; }
@media (max-width: 480px) {
.vc__gestures { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,198 @@
<script setup lang="ts">
// <!-- ld-v2 --> Élection — désignation par avatar parmi la liste arrêtée,
// vote blanc (participation, pas désignation), règle affichée en une phrase.
// Égalité : l'outil ne départage JAMAIS — session de départage chaînée.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { BLANK_VOTE, ELECTION_RULE } from '~/lexicon'
import type { Decision, Id, Person, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const eligible = computed<Person[]>(() =>
col.people.filter(p => props.session.corpusPersonIds.includes(p.id)))
const active = computed(() => store.activeVotes(props.session.id))
const counts = computed(() => {
const tallyMap: Record<Id, number> = {}
for (const vote of active.value) {
if (vote.choicePersonId) tallyMap[vote.choicePersonId] = (tallyMap[vote.choicePersonId] ?? 0) + 1
}
return tallyMap
})
const blanks = computed(() => active.value.filter(v => !v.choicePersonId).length)
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const selected = ref<Id | null>(null)
const error = ref('')
function cast(choicePersonId?: Id) {
error.value = ''
const result = store.castVote(props.session.id, {
...(choicePersonId !== undefined ? { choicePersonId } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) error.value = result.reason
else selected.value = null
}
const result = computed(() => {
if (props.session.status !== 'closed') return null
const tallied = store.tally(props.session)
return !('ok' in tallied) && tallied.method === 'election' ? tallied.result : null
})
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
function initials(personName: string): string {
return personName.split(/[\s-]+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase()
}
function tint(id: string): string {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
return `oklch(0.72 0.09 ${h})`
}
function openRunoff() {
navigateTo({
path: '/decider',
query: {
parent: props.decision.id,
chain: 'revision',
title: `Départager — ${props.decision.title}`,
},
})
}
const drawPlanned = computed(() => props.protocol.formula.tieBreak === 'draw')
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card ve">
<p class="ve__rule">
<UIcon name="i-lucide-scale" />
<span>{{ ELECTION_RULE }}</span>
</p>
<!-- La grille de désignation -->
<div class="ve__grid" role="radiogroup" aria-label="Désigner une personne">
<button
v-for="person in eligible"
:key="person.id"
type="button"
class="ve__tile"
:class="{
've__tile--picked': selected === person.id,
've__tile--mine': myVote?.choicePersonId === person.id,
've__tile--winner': result?.outcome === 'elected' && result.winnerId === person.id,
}"
:disabled="!canAct || session.status !== 'open'"
@click="selected = selected === person.id ? null : person.id"
>
<span class="ve__avatar" :style="{ background: tint(person.id) }">{{ initials(person.displayName) }}</span>
<span class="ve__name">{{ person.displayName }}</span>
<span v-if="(counts[person.id] ?? 0) > 0" class="ve__count">{{ counts[person.id] }}</span>
</button>
</div>
<div v-if="session.status === 'open'" class="ve__actions">
<button class="ld-btn" type="button" :disabled="!canAct || !selected" @click="cast(selected ?? undefined)">
{{ selected ? `Désigner ${name(selected)}` : 'Désigner' }}
</button>
<button class="ld-btn ld-btn--ghost" type="button" :disabled="!canAct" @click="cast()">
{{ BLANK_VOTE }}
</button>
<span v-if="myVote" class="ve__mine">
Ton geste actuel : {{ myVote.choicePersonId ? name(myVote.choicePersonId) : BLANK_VOTE.toLowerCase() }}
</span>
</div>
<p v-if="error" class="ve__error">{{ error }}</p>
<p class="ve__tally">
{{ active.length }} participant{{ active.length > 1 ? 's' : '' }}
· {{ blanks }} vote{{ blanks > 1 ? 's' : '' }} blanc{{ blanks > 1 ? 's' : '' }}
<template v-if="protocol.formula.electionMinParticipants !== undefined">
· quorum : {{ protocol.formula.electionMinParticipants }}
</template>
</p>
<!-- Clôture -->
<div v-if="result" class="ve__result">
<p v-if="result.outcome === 'elected'" class="ve__elected">
<UIcon name="i-lucide-award" />
<span><strong>{{ name(result.winnerId) }}</strong> est désigné·e à la pluralité simple.</span>
</p>
<template v-else-if="result.outcome === 'tie'">
<p class="ve__tie">
Égalité entre {{ result.exAequoIds.map(name).join(' et ') }}
{{ drawPlanned ? 'le Pacte prévoit un tirage au sort entre ex æquo.' : 'à vous de départager.' }}
</p>
<button class="ld-btn" type="button" @click="openRunoff()">
Ouvrir la session de départage
</button>
</template>
<p v-else class="ve__rejected">
<template v-if="result.reason === 'quorum'">
{{ result.participants }} participant{{ result.participants > 1 ? 's' : '' }} sur
{{ result.required }} requis le quorum n'est pas atteint.
</template>
<template v-else>
Personne n'a été désigné·e tous les votes sont blancs.
</template>
</p>
</div>
</section>
</template>
<style scoped>
.ve { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.ve__rule {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5;
}
.ve__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: 0.6rem;
}
.ve__tile {
position: relative; display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
padding: 0.8rem 0.5rem; border-radius: var(--r-icon); cursor: pointer;
background: var(--mood-bg); transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.ve__tile:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
.ve__tile:disabled { cursor: default; }
.ve__tile--picked { box-shadow: 0 0 0 2.5px var(--mood-accent); background: var(--mood-accent-soft); }
.ve__tile--mine:not(.ve__tile--picked) { box-shadow: inset 0 0 0 2px var(--mood-accent); }
.ve__tile--winner { background: var(--mood-status-vigueur-bg); box-shadow: 0 0 0 2.5px var(--mood-status-vigueur); }
.ve__avatar {
width: 2.75rem; height: 2.75rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 0.9375rem; color: rgba(255, 255, 255, 0.95);
}
.ve__name { font-size: 0.8125rem; font-weight: 600; text-align: center; line-height: 1.2; }
.ve__count {
position: absolute; top: 0.4rem; right: 0.4rem;
min-width: 1.35rem; height: 1.35rem; padding: 0 0.3rem;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--r-pill); font-size: 0.75rem; font-weight: 800;
background: var(--mood-accent); color: var(--mood-accent-text);
}
.ve__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
.ve__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
.ve__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.ve__tally { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.ve__result { display: flex; flex-direction: column; gap: 0.6rem; }
.ve__elected { display: flex; align-items: baseline; gap: 0.45rem; margin: 0; font-size: 1rem; color: var(--mood-status-vigueur); }
.ve__tie { margin: 0; font-weight: 600; font-size: 0.9375rem; color: var(--mood-warning); }
.ve__result .ld-btn { align-self: flex-start; }
.ve__rejected { margin: 0; font-weight: 600; font-size: 0.9375rem; color: var(--mood-text-muted); }
</style>
@@ -1,122 +0,0 @@
<script setup lang="ts">
/**
* Vote history list for a session.
*
* Displays a table of all votes cast in a session, sorted by date descending.
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
*/
import type { Vote } from '~/stores/votes'
const props = defineProps<{
votes: Vote[]
}>()
/** Nuanced level labels matching VoteNuanced component. */
const nuancedLabels: Record<number, { label: string; color: string }> = {
0: { label: 'CONTRE', color: 'error' },
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
2: { label: 'PAS D\'ACCORD', color: 'warning' },
3: { label: 'NEUTRE', color: 'neutral' },
4: { label: 'D\'ACCORD', color: 'success' },
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
}
/** Sorted votes by date descending. */
const sortedVotes = computed(() => {
return [...props.votes].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
function truncateAddress(address: string): string {
if (address.length <= 16) return address
return `${address.slice(0, 8)}...${address.slice(-6)}`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function voteLabel(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
}
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
}
function voteColor(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
}
return vote.vote_value === 'pour' ? 'success' : 'error'
}
</script>
<template>
<div>
<div v-if="sortedVotes.length === 0" class="text-center py-8">
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun vote enregistre</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="vote in sortedVotes"
:key="vote.id"
class="border-b border-gray-100 dark:border-gray-800"
>
<!-- Voter address -->
<td class="px-4 py-3">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncateAddress(vote.voter_id) }}
</span>
</td>
<!-- Vote value -->
<td class="px-4 py-3">
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
{{ voteLabel(vote) }}
</UBadge>
</td>
<!-- Smith / TechComm badges -->
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
Smith
</UBadge>
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
TechComm
</UBadge>
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
Membre
</span>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-xs text-gray-500">
{{ formatDate(vote.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
// <!-- ld-v2 --> Bloc « S'instruire » condensé — résumé toujours visible
// (titre, effets recherchés, ce que ça engage) + dépliables (texte, avis et
// objections, provenance). Auto-replié aux visites suivantes (localStorage).
import { useCollectiveStore } from '~/stores/collective'
import { BASELINE_PREFIX, ENGAGES_LABEL, INSTRUCT_BLOCK, ROUTE_LABELS } from '~/lexicon'
import type { Decision, Id } from '~/types/domain'
const props = defineProps<{ decision: Decision; secret?: boolean }>()
const col = useCollectiveStore()
const open = ref(true)
onMounted(() => {
const key = `ld2-instruct-${props.decision.id}`
if (localStorage.getItem(key)) open.value = false
else localStorage.setItem(key, '1')
})
const effects = computed(() => props.decision.brief?.effects ?? [])
const advices = computed(() => col.advices.filter(a => a.decisionId === props.decision.id))
const objections = computed(() => col.objections.filter(o => o.decisionId === props.decision.id))
const parent = computed(() => col.decisions.find(d => d.id === props.decision.parentDecisionId))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
function day(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
}
const POSITION_LABELS = { favorable: 'favorable', reserved: 'réservé', unfavorable: 'défavorable' } as const
const OBJECTION_STATUS = { open: 'ouverte', withdrawn: 'levée', integrated: 'intégrée', escalated: 'escaladée' } as const
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vi">
<header class="vi__head">
<h2 class="vi__title">
<UIcon name="i-lucide-book-open" />
<span>{{ INSTRUCT_BLOCK }}</span>
</h2>
<button class="ld-btn ld-btn--quiet vi__toggle" type="button" @click="open = !open">
<span>{{ open ? 'Replier' : 'Voir le détail' }}</span>
<UIcon :name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
</button>
</header>
<!-- Résumé compact toujours visible -->
<p class="vi__sentence">{{ decision.title }}</p>
<ul v-if="effects.length" class="vi__effects">
<li v-for="(effect, i) in effects" :key="i">
<UIcon name="i-lucide-target" class="vi__effect-icon" />
<span>{{ effect.label }}</span>
<span v-if="effect.target" class="vi__target">{{ effect.target }}</span>
</li>
</ul>
<p v-if="decision.resources?.note" class="vi__engages">
<span class="vi__engages-label">{{ ENGAGES_LABEL }}</span>
<span>{{ decision.resources.note }}</span>
<strong v-if="decision.resources.amount !== undefined">
{{ decision.resources.amount.toLocaleString('fr-FR') }} {{ decision.resources.unit ?? '' }}
</strong>
</p>
<!-- Dépliables -->
<div v-if="open" class="vi__details">
<details v-if="decision.body || decision.baselineNote">
<summary>Le texte</summary>
<p v-if="decision.baselineNote" class="vi__baseline">
{{ BASELINE_PREFIX }} {{ decision.baselineNote }}
</p>
<p class="vi__body">{{ decision.body ?? decision.title }}</p>
</details>
<details v-if="advices.length || objections.length">
<summary>Avis et objections ({{ advices.length + objections.length }})</summary>
<ul class="vi__list">
<li v-for="advice in advices" :key="advice.id">
<span class="vi__who">{{ secret ? 'quelquun' : name(advice.personId) }}</span>
<span class="vi__pos">{{ POSITION_LABELS[advice.position] }}</span>
<span v-if="advice.note" class="vi__note">{{ advice.note }}</span>
</li>
<li v-for="objection in objections" :key="objection.id">
<span class="vi__who">{{ secret ? 'quelquun' : name(objection.personId) }}</span>
<span class="vi__pos vi__pos--objection">objection {{ OBJECTION_STATUS[objection.status] }}</span>
<span class="vi__note">{{ objection.argument }}</span>
</li>
</ul>
</details>
<details>
<summary>Provenance</summary>
<ul class="vi__list">
<li>Proposé par {{ name(decision.authorId) }} le {{ day(decision.createdAt) }}</li>
<li>Chemin : {{ ROUTE_LABELS[decision.route] }}</li>
<li v-if="parent">
Chaînée à
<NuxtLink :to="`/decisions/${parent.id}`" class="vi__link">{{ parent.title }}</NuxtLink>
</li>
<li v-if="decision.decidedHow">{{ decision.decidedHow }}</li>
</ul>
</details>
</div>
</section>
</template>
<style scoped>
.vi { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.6rem; }
.vi__head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.vi__title {
display: inline-flex; align-items: center; gap: 0.45rem; margin: 0;
font-size: 0.9375rem; font-weight: 700; color: var(--mood-accent);
}
.vi__toggle { padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
.vi__sentence { margin: 0; font-weight: 700; font-size: 1.0625rem; line-height: 1.35; }
.vi__effects { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
.vi__effects li { display: flex; align-items: baseline; gap: 0.45rem; font-size: 0.9375rem; }
.vi__effect-icon { color: var(--mood-tertiary); flex-shrink: 0; transform: translateY(2px); }
.vi__target {
font-size: 0.75rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
padding: 1px 8px; border-radius: var(--r-pill); white-space: nowrap;
}
.vi__engages {
margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem;
font-size: 0.9375rem; color: var(--mood-text);
background: var(--mood-accent-soft); border-radius: var(--r-input); padding: 0.5rem 0.75rem;
}
.vi__engages-label { font-size: 0.75rem; font-weight: 700; color: var(--mood-accent); text-transform: uppercase; letter-spacing: 0.04em; }
.vi__details { display: flex; flex-direction: column; gap: 0.35rem; }
.vi__details details { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; }
.vi__details summary { cursor: pointer; font-weight: 700; font-size: 0.875rem; color: var(--mood-text-muted); user-select: none; }
.vi__baseline { font-size: 0.875rem; color: var(--mood-text-muted); font-style: italic; margin: 0.5rem 0 0.25rem; }
.vi__body { white-space: pre-wrap; font-size: 0.9375rem; line-height: 1.5; margin: 0.5rem 0 0; }
.vi__list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.875rem; }
.vi__who { font-weight: 700; margin-right: 0.35rem; }
.vi__pos { color: var(--mood-tertiary); font-weight: 600; margin-right: 0.35rem; }
.vi__pos--objection { color: var(--mood-warning); }
.vi__note { color: var(--mood-text-muted); }
.vi__link { color: var(--mood-accent); font-weight: 600; text-decoration: none; }
.vi__link:hover { text-decoration: underline; }
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
// <!-- ld-v2 --> MON historique de re-votes — la chaîne supersedes n'est jamais
// publique : elle n'est visible que de son auteur·e, ici, dépliée à la demande.
import { useCollectiveStore } from '~/stores/collective'
import { BLANK_VOTE, NUANCED_LABELS } from '~/lexicon'
import type { Id, NuancedValue, Vote, VoteSession } from '~/types/domain'
const props = defineProps<{ session: VoteSession; voterId: Id }>()
const col = useCollectiveStore()
const mine = computed<Vote[]>(() =>
col.votes
.filter(v => v.sessionId === props.session.id && v.voterId === props.voterId)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
)
function label(vote: Vote): string {
if (vote.value === 'for') return 'pour'
if (vote.value === 'against') return 'refus argumenté'
if (typeof vote.value === 'number') return NUANCED_LABELS[vote.value as NuancedValue]
if (vote.values) return vote.values.map(v => v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })).join(' · ')
if (vote.choicePersonId) return col.people.find(p => p.id === vote.choicePersonId)?.displayName ?? '—'
return BLANK_VOTE
}
function when(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<!-- ld-v2 -->
<details v-if="mine.length" class="vh">
<summary>
<UIcon name="i-lucide-history" />
<span>Mon historique de re-votes ({{ mine.length }}) visible par moi seul</span>
</summary>
<ol class="vh__list">
<li v-for="(vote, i) in mine" :key="vote.id" :class="{ 'vh__old': i > 0 }">
<span class="vh__when">{{ when(vote.createdAt) }}</span>
<span class="vh__label">{{ label(vote) }}</span>
<span v-if="i === 0" class="vh__active">actif</span>
<span v-if="vote.comment" class="vh__comment">{{ vote.comment }}</span>
</li>
</ol>
</details>
</template>
<style scoped>
.vh { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.6rem 0.9rem; }
.vh summary {
display: flex; align-items: center; gap: 0.45rem; cursor: pointer; user-select: none;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
}
.vh__list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.vh__list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.875rem; }
.vh__old { opacity: 0.6; }
.vh__old .vh__label { text-decoration: line-through; }
.vh__when { font-size: 0.75rem; color: var(--mood-text-muted); font-variant-numeric: tabular-nums; }
.vh__label { font-weight: 700; }
.vh__active {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--mood-status-vigueur); background: var(--mood-status-vigueur-bg);
padding: 1px 8px; border-radius: var(--r-pill);
}
.vh__comment { width: 100%; color: var(--mood-text-muted); font-size: 0.8125rem; }
</style>
+162 -157
View File
@@ -1,187 +1,192 @@
<script setup lang="ts">
/**
* 6-level nuanced vote component.
*
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
* each with a distinctive color. Negative votes (0-2) optionally include
* a comment textarea.
*/
// <!-- ld-v2 --> Nuancé 6 niveaux — segments en gradient sémantique dérivé des
// custom properties du mood (color-mix error→success, jamais de couleurs crues),
// commentaire obligatoire sous 0-1, histogramme de la distribution en cours.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { NUANCED_LABELS } from '~/lexicon'
import type { Decision, Id, NuancedValue, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
sessionId: string
disabled?: boolean
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const col = useCollectiveStore()
const store = useDecisionsStore()
const LEVELS: NuancedValue[] = [0, 1, 2, 3, 4, 5]
/** % de success dans le mélange sémantique error→success, par niveau. */
const MIX = [6, 24, 44, 62, 80, 94] as const
function levelColor(level: number): string {
return `color-mix(in oklab, var(--mood-success) ${MIX[level] ?? 50}%, var(--mood-error))`
}
const active = computed(() => store.activeVotes(props.session.id))
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const pending = ref<NuancedValue | null>(null)
watch(myVote, (vote) => {
if (pending.value === null && typeof vote?.value === 'number') pending.value = vote.value as NuancedValue
}, { immediate: true })
const submitting = ref(false)
const selectedLevel = ref<number | null>(null)
const comment = ref('')
const showConfirm = ref(false)
const error = ref('')
const needsComment = computed(() => pending.value === 0 || pending.value === 1)
const canDeposit = computed(() =>
props.canAct && pending.value !== null && (!needsComment.value || comment.value.trim().length > 0),
)
interface NuancedLevel {
level: number
label: string
color: string
bgClass: string
textClass: string
ringClass: string
function deposit() {
if (pending.value === null) return
error.value = ''
const result = store.castVote(props.session.id, {
value: pending.value,
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) { error.value = result.reason; return }
comment.value = ''
}
const levels: NuancedLevel[] = [
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
]
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
/** Initialize selected level from existing vote. */
watchEffect(() => {
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
selectedLevel.value = userVote.value.nuanced_level
// ── Distribution (recalculée depuis les derniers votes actifs) ──
const counts = computed(() => {
const perLevel = [0, 0, 0, 0, 0, 0]
for (const vote of active.value) {
if (typeof vote.value === 'number') perLevel[vote.value] = (perLevel[vote.value] ?? 0) + 1
}
return perLevel
})
const maxCount = computed(() => Math.max(1, ...counts.value))
const total = computed(() => active.value.length)
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
const result = computed(() => {
const tallied = store.tally(props.session)
return !('ok' in tallied) && tallied.method === 'nuanced' ? tallied.result : null
})
const thresholdPct = computed(() => props.protocol.formula.nuancedThresholdPct ?? 80)
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
/** Whether the comment field should be shown (negative votes). */
const showComment = computed(() => {
return selectedLevel.value !== null && selectedLevel.value <= 2
})
function selectLevel(level: number) {
if (isDisabled.value) return
selectedLevel.value = level
showConfirm.value = true
}
async function confirmVote() {
if (selectedLevel.value === null) return
showConfirm.value = false
submitting.value = true
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: voteValue,
nuanced_level: selectedLevel.value,
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
}
}
function cancelVote() {
showConfirm.value = false
}
function getLevelLabel(level: number): string {
return levels.find(l => l.level === level)?.label ?? ''
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
</script>
<template>
<div class="space-y-4">
<!-- Level buttons -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<!-- ld-v2 -->
<section class="ld-card vn">
<!-- La jauge : 6 segments -->
<div class="vn__scale" role="radiogroup" aria-label="Nuance">
<button
v-for="lvl in levels"
:key="lvl.level"
:disabled="isDisabled"
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:class="[
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
? `ring-3 ${lvl.ringClass} border-transparent`
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
]"
@click="selectLevel(lvl.level)"
v-for="level in LEVELS"
:key="level"
type="button"
class="vn__segment"
:class="{ 'vn__segment--picked': pending === level, 'vn__segment--mine': typeof myVote?.value === 'number' && myVote.value === level }"
:style="{ '--seg': levelColor(level) }"
:disabled="!canAct"
@click="pending = level"
>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="lvl.bgClass"
>
{{ lvl.level }}
</div>
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
{{ lvl.label }}
</span>
<!-- Selected indicator -->
<div
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
:class="lvl.bgClass"
>
<UIcon name="i-lucide-check" class="w-3 h-3" />
</div>
<span class="vn__segment-value">{{ level }}</span>
<span class="vn__segment-label">{{ NUANCED_LABELS[level] }}</span>
</button>
</div>
<!-- Comment for negative votes -->
<div v-if="showComment" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Commentaire (optionnel pour les votes negatifs)
</label>
<UTextarea
v-model="comment"
placeholder="Expliquez votre position..."
:rows="3"
:disabled="isDisabled"
/>
<!-- Commentaire obligatoire sous 0-1 -->
<div v-if="needsComment && session.status === 'open'" class="vn__comment">
<label>Dis pourquoi un commentaire accompagne toute position négative.</label>
<textarea v-model="comment" rows="2" placeholder="Ton argument…" />
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
<div v-if="session.status === 'open'" class="vn__actions">
<button class="ld-btn" type="button" :disabled="!canDeposit" @click="deposit()">
{{ myVote ? 'Remplacer mon vote' : 'Déposer mon vote' }}
</button>
<span v-if="myVote && typeof myVote.value === 'number'" class="vn__mine">
Ta nuance actuelle : {{ NUANCED_LABELS[myVote.value as NuancedValue] }}
</span>
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
<p v-if="error" class="vn__error">{{ error }}</p>
<!-- Histogramme des 6 niveaux la distribution, pas seulement le résultat -->
<div class="vn__histogram" aria-label="Distribution des nuances">
<div v-for="level in LEVELS" :key="level" class="vn__bar-col">
<span class="vn__bar-count">{{ counts[level] }}</span>
<div
class="vn__bar"
:style="{ height: `${4 + ((counts[level] ?? 0) / maxCount) * 64}px`, background: levelColor(level) }"
/>
<span class="vn__bar-label">{{ level }}</span>
</div>
<span class="vn__total">{{ total }} vote{{ total > 1 ? 's' : '' }}</span>
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter :
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton color="primary" :loading="submitting" @click="confirmVote">
Confirmer le vote
</UButton>
</div>
</div>
<!-- Résultat -->
<p v-if="result && total > 0" class="vn__result" :class="{ 'vn__result--closed': session.status === 'closed' }">
{{ result.positive_count }} nuance{{ result.positive_count > 1 ? 's' : '' }} positive{{ result.positive_count > 1 ? 's' : '' }}
sur {{ result.total }} {{ result.positive_pct.toLocaleString('fr-FR') }} %
(seuil {{ thresholdPct.toLocaleString('fr-FR') }} %).
<template v-if="session.status === 'closed'">
{{ session.outcome === 'adopted' ? 'Le collectif adopte.' : 'Le collectif nadopte pas.' }}
</template>
</UModal>
</div>
</p>
<!-- Les arguments, toujours en liste -->
<ul v-if="comments.length" class="vn__comments">
<li v-for="vote in comments" :key="vote.id">
<span v-if="!secret" class="vn__comment-who">{{ name(vote.voterId) }}</span>
<span class="vn__comment-level" :style="{ color: levelColor(Number(vote.value)) }">
{{ typeof vote.value === 'number' ? NUANCED_LABELS[vote.value as NuancedValue] : '' }}
</span>
<p>{{ vote.comment }}</p>
</li>
</ul>
</section>
</template>
<style scoped>
.vn { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.vn__scale { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.vn__segment {
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
padding: 0.6rem 0.2rem; min-height: 3.5rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--seg) 14%, var(--mood-surface));
color: var(--seg); cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.vn__segment:hover:not(:disabled) { transform: translateY(-1px); }
.vn__segment:disabled { cursor: not-allowed; opacity: 0.6; }
.vn__segment--picked { background: var(--seg); color: var(--mood-surface); box-shadow: 0 2px 8px var(--mood-shadow); }
.vn__segment--mine:not(.vn__segment--picked) { box-shadow: inset 0 0 0 2px var(--seg); }
.vn__segment-value { font-size: 1.05rem; font-weight: 800; }
.vn__segment-label { font-size: 0.66rem; font-weight: 700; text-align: center; line-height: 1.15; }
.vn__comment { display: flex; flex-direction: column; gap: 0.35rem; }
.vn__comment label { font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning); }
.vn__comment textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
.vn__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; }
.vn__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
.vn__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vn__histogram {
display: flex; align-items: flex-end; gap: 6px;
padding: 0.75rem 0.5rem 0.4rem; background: var(--mood-bg); border-radius: var(--r-input);
}
.vn__bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
.vn__bar { width: 100%; max-width: 44px; border-radius: 4px 4px 2px 2px; opacity: 0.85; transition: height 0.2s ease; }
.vn__bar-count { font-size: 0.75rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__bar-label { font-size: 0.7rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__total { align-self: flex-end; font-size: 0.75rem; color: var(--mood-text-muted); padding: 0 0.25rem 0.35rem; white-space: nowrap; }
.vn__result { margin: 0; font-size: 0.9375rem; }
.vn__result--closed { font-weight: 700; }
.vn__comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.vn__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
.vn__comments p { margin: 0.2rem 0 0; }
.vn__comment-who { font-weight: 700; margin-right: 0.5rem; }
.vn__comment-level { font-weight: 700; font-size: 0.8125rem; }
@media (max-width: 480px) {
.vn__scale { grid-template-columns: repeat(3, 1fr); }
}
</style>
@@ -0,0 +1,187 @@
<script setup lang="ts">
// <!-- ld-v2 --> Un curseur du Réglage collectif : libellé métier, valeur en 700,
// statu quo losange creux, alternative clavier + champ numérique, poignée ≥2.25rem.
// Faisceau strip-plot : points translucides anonymes, médiane trait épais,
// baseline marqueur creux. Part « calculé » : rail estompé, jamais saisissable.
import { BIMODAL_BANNER, PARAM_DERIVED_LABEL } from '~/lexicon'
import type { ParamDef } from '~/types/domain'
const props = defineProps<{
param: ParamDef
value: number
dots?: number[]
median?: number
locked?: boolean
neutral?: boolean
bimodal?: boolean
}>()
const emit = defineEmits<{ (e: 'update:value', v: number): void }>()
function frac(v: number): number {
const span = props.param.max - props.param.min
if (span <= 0) return 0
return Math.min(1, Math.max(0, (v - props.param.min) / span))
}
function fmt(v: number): string {
return v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })
}
function onInput(event: Event) {
const raw = Number((event.target as HTMLInputElement).value)
if (Number.isFinite(raw)) emit('update:value', raw)
}
const inputId = `vps-${props.param.key}-${Math.random().toString(36).slice(2, 7)}`
</script>
<template>
<!-- ld-v2 -->
<div class="vps" :class="{ 'vps--derived': param.derived, 'vps--neutral': neutral, 'vps--locked': locked }">
<div class="vps__head">
<label class="vps__label" :for="inputId">{{ param.label }}</label>
<span v-if="param.derived" class="vps__tag">{{ PARAM_DERIVED_LABEL }}</span>
<span class="vps__value">
{{ fmt(value) }}<span v-if="param.unit" class="vps__unit">&nbsp;{{ param.unit }}</span>
</span>
</div>
<div class="vps__row">
<div class="vps__rail-zone">
<!-- Faisceau + repères, calés sur la course utile de la poignée -->
<div class="vps__overlay" aria-hidden="true">
<span
v-for="(dot, i) in dots ?? []"
:key="i"
class="vps__dot"
:style="{ '--p': frac(dot) }"
/>
<span
v-if="param.baseline !== undefined"
class="vps__baseline"
:style="{ '--p': frac(param.baseline) }"
:title="`Aujourd'hui : ${fmt(param.baseline)}`"
/>
<span
v-if="median !== undefined"
class="vps__median"
:style="{ '--p': frac(median) }"
:title="`Médiane : ${fmt(median)}`"
/>
</div>
<input
v-if="!param.derived"
:id="inputId"
class="vps__range"
type="range"
:min="param.min"
:max="param.max"
:step="param.step"
:value="value"
:disabled="locked"
:aria-label="param.label"
@input="onInput"
>
<div v-else class="vps__ghost">
<div class="vps__ghost-fill" :style="{ width: `${frac(value) * 100}%` }" />
</div>
</div>
<input
v-if="!param.derived"
class="vps__num"
type="number"
:min="param.min"
:max="param.max"
:step="param.step"
:value="value"
:disabled="locked"
:aria-label="`${param.label} saisie directe`"
@change="onInput"
>
</div>
<p v-if="bimodal" class="vps__bimodal">
<UIcon name="i-lucide-split" />
<span>{{ BIMODAL_BANNER(param.label) }}</span>
</p>
</div>
</template>
<style scoped>
.vps { display: flex; flex-direction: column; gap: 0.2rem; --vps-accent: var(--mood-accent); }
.vps--neutral { --vps-accent: var(--mood-text-muted); }
.vps__head { display: flex; align-items: baseline; gap: 0.5rem; }
.vps__label { font-size: 0.9375rem; font-weight: 600; }
.vps__tag {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--mood-text-muted); background: var(--mood-bg);
padding: 1px 8px; border-radius: var(--r-pill);
}
.vps__value { margin-left: auto; font-weight: 700; font-size: 1.0625rem; color: var(--vps-accent); }
.vps__unit { font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted); }
.vps__row { display: flex; align-items: center; gap: 0.75rem; }
.vps__rail-zone { position: relative; flex: 1; min-width: 0; }
/* Faisceau et repères : course utile = largeur poignée (2.25rem) */
.vps__overlay { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.vps__overlay > * { position: absolute; top: 50%; left: calc(1.125rem + (100% - 2.25rem) * var(--p)); }
.vps__dot {
width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%;
background: var(--vps-accent); opacity: 0.25;
}
.vps__median {
width: 4px; height: 20px; margin: -10px 0 0 -2px; border-radius: 2px;
background: var(--vps-accent);
}
.vps__baseline {
width: 10px; height: 10px; margin: -5px 0 0 -5px;
transform: rotate(45deg);
background: var(--mood-surface);
box-shadow: inset 0 0 0 2px var(--mood-text-muted);
}
/* Poignée tactile ≥ 2.25rem, clavier natif */
.vps__range {
appearance: none; -webkit-appearance: none;
display: block; width: 100%; height: 2.5rem;
background: transparent; cursor: pointer; position: relative; z-index: 2;
}
.vps__range:disabled { cursor: not-allowed; opacity: 0.6; }
.vps__range::-webkit-slider-runnable-track {
height: 8px; border-radius: 4px;
background: color-mix(in srgb, var(--vps-accent) 18%, var(--mood-bg));
}
.vps__range::-webkit-slider-thumb {
-webkit-appearance: none; width: 2.25rem; height: 2.25rem; margin-top: -14px;
border-radius: 50%;
background: radial-gradient(circle at center, var(--vps-accent) 0 7px, color-mix(in srgb, var(--vps-accent) 22%, var(--mood-surface)) 8px);
box-shadow: 0 1px 4px var(--mood-shadow);
}
.vps__range::-moz-range-track {
height: 8px; border-radius: 4px;
background: color-mix(in srgb, var(--vps-accent) 18%, var(--mood-bg));
}
.vps__range::-moz-range-thumb {
width: 2.25rem; height: 2.25rem; border: none; border-radius: 50%;
background: radial-gradient(circle at center, var(--vps-accent) 0 7px, color-mix(in srgb, var(--vps-accent) 22%, var(--mood-surface)) 8px);
box-shadow: 0 1px 4px var(--mood-shadow);
}
/* Part calculée : rail estompé, non saisissable */
.vps__ghost {
height: 8px; margin: 1.05rem 1.125rem;
border-radius: 4px; background: var(--mood-bg); opacity: 0.75; overflow: hidden;
}
.vps__ghost-fill { height: 100%; background: color-mix(in srgb, var(--mood-text-muted) 35%, transparent); transition: width 0.15s ease; }
.vps--derived .vps__value { color: var(--mood-text-muted); }
.vps__num {
width: 5.25rem; min-height: 2.25rem; padding: 0.25rem 0.5rem;
font-size: 0.9375rem; font-weight: 600; text-align: right;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.vps__num:focus-visible { box-shadow: inset 0 0 0 1.5px var(--mood-input-focus); }
.vps__bimodal {
display: flex; align-items: baseline; gap: 0.4rem; margin: 0.15rem 0 0;
font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning);
background: var(--mood-status-fenetre-bg); border-radius: var(--r-input); padding: 0.4rem 0.65rem;
}
</style>
@@ -0,0 +1,403 @@
<script setup lang="ts">
// <!-- ld-v2 --> Réglage collectif — décider au curseur. Faisceau anonyme,
// médiane basse nommée, carte « Pour moi » (jamais de chiffre inventé),
// sandbox Explorer, et la CRISTALLISATION : un geste humain daté du garant,
// solennel mais léger — jamais un automatisme.
import { computeMyImpact, detectBimodality, medianByElement, resolveDerived } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, EXPLORE_DISCLAIMER,
EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER,
MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL,
} from '~/lexicon'
import type { Decision, Id, ParamSpec, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
isSteward?: boolean
}>()
const emit = defineEmits<{ (e: 'adopted'): void }>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const spec = computed<ParamSpec>(() => props.decision.paramSpec!)
const votable = computed(() => spec.value.params.filter(p => p.derived !== true))
/** Index votable → index complet (ordre spec.params). */
const votableIndex = computed(() => {
const map = new Map<string, number>()
votable.value.forEach((p, i) => map.set(p.key, i))
return map
})
// ── Les positions déposées (derniers votes actifs, toujours recalculées) ──
const active = computed(() => store.activeVotes(props.session.id))
const vectors = computed(() => active.value
.map(v => v.values)
.filter((v): v is number[] => v !== undefined && v.length === votable.value.length))
const fullVectors = computed(() => vectors.value.map(v => resolveDerived(spec.value, v)))
const medianVotable = computed(() => medianByElement(vectors.value))
const medianFull = computed(() =>
medianVotable.value.length === votable.value.length && votable.value.length > 0
? resolveDerived(spec.value, medianVotable.value)
: null)
const baselineFull = computed(() => spec.value.params.map(p => p.baseline ?? p.min))
const bimodalKeys = computed(() => votable.value
.filter((_, i) => detectBimodality(vectors.value.map(v => v[i] ?? Number.NaN)))
.map(p => p.key))
function columnFor(fullIndex: number): number[] {
return fullVectors.value.map(v => v[fullIndex] ?? Number.NaN).filter(Number.isFinite)
}
// ── Ma position de travail ──
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const working = ref<number[]>(
(myVote.value?.values && [...myVote.value.values])
?? votable.value.map(p => p.baseline ?? p.min),
)
const workingFull = computed(() => resolveDerived(spec.value, working.value))
const round6 = (v: number) => Math.round(v * 1e6) / 1e6
/** Nouvelle liste avec la valeur vi ajustée : bornes, pas, et part calculée gardée dans ses bornes. */
function adjusted(list: number[], vi: number, raw: number): number[] {
const p = votable.value[vi]
if (!p || !Number.isFinite(raw)) return list
const snap = (v: number) => round6(p.min + Math.round((v - p.min) / p.step) * p.step)
let v = snap(Math.min(p.max, Math.max(p.min, raw)))
const next = [...list]
const derived = spec.value.params.find(q => q.derived === true)
if (spec.value.constraint === 'sum100' && p.kind === 'share' && derived) {
const otherSum = votable.value.reduce(
(sum, q, j) => sum + (q.kind === 'share' && j !== vi ? (next[j] ?? 0) : 0), 0)
const lo = 100 - derived.max - otherSum
const hi = 100 - derived.min - otherSum
v = snap(Math.min(hi, Math.max(lo, v)))
if (v > hi + 1e-6) v = round6(v - p.step)
if (v < lo - 1e-6) v = round6(v + p.step)
v = Math.min(p.max, Math.max(p.min, v))
}
next[vi] = v
return next
}
function setWorking(key: string, v: number) {
const vi = votableIndex.value.get(key)
if (vi !== undefined) working.value = adjusted(working.value, vi, v)
}
const error = ref('')
function deposit() {
error.value = ''
const result = store.castVote(props.session.id, {
values: [...working.value],
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) error.value = result.reason
}
// ── Carte « Pour moi » — estimation sur données déclarées, sinon rien ──
const attrKey = computed(() => spec.value.impactAttrKey)
const myAttr = computed(() =>
attrKey.value !== undefined ? col.me?.attributes?.[attrKey.value] : undefined)
const declaredAttrs = computed(() => {
const key = attrKey.value
if (key === undefined) return []
return col.people
.filter(p => props.session.corpusPersonIds.includes(p.id))
.map(p => p.attributes?.[key])
.filter((v): v is number => typeof v === 'number')
})
const resources = computed(() => props.decision.resources ?? { note: '' })
const impactMine = computed(() =>
computeMyImpact(spec.value, resources.value, workingFull.value, myAttr.value, declaredAttrs.value))
const impactMedian = computed(() => medianFull.value
? computeMyImpact(spec.value, resources.value, medianFull.value, myAttr.value, declaredAttrs.value)
: null)
const impactBase = computed(() =>
computeMyImpact(spec.value, resources.value, baselineFull.value, myAttr.value, declaredAttrs.value))
const unit = computed(() => props.decision.resources?.unit ?? '')
const fmtAmount = (v?: number) =>
v === undefined ? '—' : `${v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} ${unit.value}`
// ── Explorer — exploration, ne compte pas ──
const exploring = ref(false)
const sandbox = ref<number[]>([])
function toggleExplore() {
if (exploring.value) { exploring.value = false; return }
sandbox.value = [...working.value]
exploring.value = true
}
function setSandbox(key: string, v: number) {
const vi = votableIndex.value.get(key)
if (vi !== undefined) sandbox.value = adjusted(sandbox.value, vi, v)
}
const sandboxFull = computed(() =>
sandbox.value.length === votable.value.length ? resolveDerived(spec.value, sandbox.value) : [])
const seedMedian = () => { if (medianVotable.value.length) sandbox.value = [...medianVotable.value] }
const seedBase = () => { sandbox.value = votable.value.map(p => p.baseline ?? p.min) }
const seedMine = () => { sandbox.value = [...working.value] }
function makeMine() { working.value = [...sandbox.value]; exploring.value = false }
// ── Cristallisation — le geste du garant ──
const frozen = computed(() => props.session.status === 'frozen')
const locked = computed(() => props.session.status !== 'open' || !props.canAct)
const showCrystal = ref(false)
const quorum = computed(() => props.protocol.formula.parametricMinParticipants)
const quorumShort = computed(() =>
quorum.value !== undefined && vectors.value.length < quorum.value)
function doCrystallize() {
error.value = ''
const result = store.crystallize(props.session.id)
if ('ok' in result) { error.value = result.reason; return }
showCrystal.value = false
if (result.outcome === 'adopted') emit('adopted')
}
function split() {
navigateTo({ path: '/decider', query: { parent: props.decision.id, chain: 'element' } })
}
function reframe() {
error.value = ''
const result = store.transition(props.decision.id, 'framing')
if (!result.ok) { error.value = result.reason; return }
showCrystal.value = false
}
const crystallizer = computed(() => props.session.crystallizedById
? col.people.find(p => p.id === props.session.crystallizedById)?.displayName ?? '—'
: null)
const shareParams = computed(() => spec.value.constraint === 'sum100'
? spec.value.params.filter(p => p.kind === 'share')
: [])
const plainParams = computed(() => spec.value.constraint === 'sum100'
? spec.value.params.filter(p => p.kind !== 'share')
: spec.value.params)
function fullIndexOf(key: string): number {
return spec.value.params.findIndex(p => p.key === key)
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vp">
<p class="vp__explain">{{ MEDIAN_EXPLANATION }}</p>
<p v-if="frozen" class="vp__frozen">
<UIcon name="i-lucide-lock" />
<span>{{ FROZEN_BANNER }}</span>
</p>
<!-- Le cadre contraint (parts à 100) puis les curseurs libres -->
<div v-if="shareParams.length" class="vp__frame">
<span class="vp__frame-tag">{{ PARAM_CONSTRAINT_LABEL }} les parts totalisent 100</span>
<VoteParamSlider
v-for="p in shareParams"
:key="p.key"
:param="p"
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
:dots="columnFor(fullIndexOf(p.key))"
:median="medianFull?.[fullIndexOf(p.key)]"
:locked="locked"
:bimodal="bimodalKeys.includes(p.key)"
@update:value="setWorking(p.key, $event)"
/>
</div>
<VoteParamSlider
v-for="p in plainParams"
:key="p.key"
:param="p"
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
:dots="columnFor(fullIndexOf(p.key))"
:median="medianFull?.[fullIndexOf(p.key)]"
:locked="locked"
:bimodal="bimodalKeys.includes(p.key)"
@update:value="setWorking(p.key, $event)"
/>
<div v-if="session.status === 'open'" class="vp__actions">
<button class="ld-btn" type="button" :disabled="!canAct" @click="deposit()">
{{ myVote ? 'Remplacer ma position' : 'Déposer ma position' }}
</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="toggleExplore()">
<UIcon name="i-lucide-flask-conical" />
<span>{{ EXPLORE_LABEL }}</span>
</button>
</div>
<p v-if="error" class="vp__error">{{ error }}</p>
<!-- Sandbox Explorer -->
<div v-if="exploring" class="vp__sandbox">
<p class="vp__sandbox-banner">
<UIcon name="i-lucide-flask-conical" />
<span>{{ EXPLORE_DISCLAIMER }}</span>
</p>
<div class="vp__sandbox-seeds">
<button class="ld-btn ld-btn--quiet" type="button" :disabled="!medianVotable.length" @click="seedMedian()">Partir de la médiane</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="seedBase()">Partir du statu quo</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="seedMine()">Partir de ma position</button>
</div>
<VoteParamSlider
v-for="p in spec.params"
:key="p.key"
:param="p"
:value="sandboxFull[fullIndexOf(p.key)] ?? p.min"
neutral
@update:value="setSandbox(p.key, $event)"
/>
<button class="ld-btn" type="button" :disabled="session.status !== 'open'" @click="makeMine()">
En faire ma position
</button>
</div>
<!-- Carte « Pour moi » masquée si rien de déclaré, jamais de chiffre inventé -->
<div v-if="impactMine && impactBase" class="vp__impact">
<h3 class="vp__impact-title">{{ IMPACT_CARD_TITLE }}</h3>
<table class="vp__impact-table">
<thead>
<tr><th /><th>ma position</th><th>médiane</th><th>statu quo</th></tr>
</thead>
<tbody>
<tr v-for="(line, i) in impactMine.perParam" :key="line.key">
<th>{{ line.label }}</th>
<td>{{ fmtAmount(line.amount) }}</td>
<td>{{ fmtAmount(impactMedian?.perParam[i]?.amount) }}</td>
<td>{{ fmtAmount(impactBase.perParam[i]?.amount) }}</td>
</tr>
<tr class="vp__impact-total">
<th>total</th>
<td>{{ fmtAmount(impactMine.total) }}</td>
<td>{{ fmtAmount(impactMedian?.total) }}</td>
<td>{{ fmtAmount(impactBase.total) }}</td>
</tr>
</tbody>
</table>
<p class="vp__impact-note">{{ IMPACT_DISCLAIMER }}</p>
</div>
<p class="vp__count">
{{ vectors.length }} position{{ vectors.length > 1 ? 's' : '' }} déposée{{ vectors.length > 1 ? 's' : '' }}
<template v-if="quorum !== undefined"> · quorum : {{ quorum }}</template>
</p>
<!-- Le geste du garant -->
<div v-if="frozen && isSteward" class="vp__crystal">
<p class="vp__crystal-card">{{ CRYSTALLIZE_CARD }}</p>
<button class="vp__stamp-btn" type="button" @click="showCrystal = true">
<UIcon name="i-lucide-stamp" />
<span>{{ CRYSTALLIZE_ACTION }}</span>
</button>
</div>
<p v-if="crystallizer && session.crystallizedAt" class="vp__crystallized">
Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}.
</p>
<UModal v-model:open="showCrystal" :title="CRYSTALLIZE_ACTION">
<template #body>
<div class="vp__modal">
<p v-if="medianFull" class="vp__modal-median">
<template v-for="(p, i) in spec.params" :key="p.key">
<span>{{ p.label }} : <strong>{{ (medianFull[i] ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 2 }) }}</strong>{{ p.unit ? ` ${p.unit}` : '' }}</span>
</template>
</p>
<p v-if="quorumShort" class="vp__modal-warn">
Le quorum n'est pas atteint ({{ vectors.length }} sur {{ quorum }}) le geste constatera le rejet.
</p>
<p v-for="key in bimodalKeys" :key="key" class="vp__modal-warn">
<UIcon name="i-lucide-split" />
<span>{{ votable.find(p => p.key === key)?.label }} : deux positions distinctes se dessinent.</span>
</p>
<div class="vp__modal-choices">
<template v-if="bimodalKeys.length">
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_CHOICES[0] }}</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="split()">{{ CRYSTALLIZE_CHOICES[1] }}</button>
<button class="ld-btn ld-btn--ghost" type="button" @click="reframe()">{{ CRYSTALLIZE_CHOICES[2] }}</button>
</template>
<template v-else>
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_ACTION }}</button>
<button class="ld-btn ld-btn--quiet" type="button" @click="showCrystal = false">Annuler</button>
</template>
</div>
<p v-if="error" class="vp__error">{{ error }}</p>
</div>
</template>
</UModal>
</section>
</template>
<style scoped>
.vp { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.vp__explain { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5; }
.vp__frozen {
display: flex; align-items: center; gap: 0.5rem; margin: 0;
padding: 0.6rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
font-weight: 700; font-size: 0.9375rem;
}
.vp__frame {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 0.9rem; border-radius: var(--r-input);
background: var(--mood-bg);
}
.vp__frame-tag {
align-self: flex-start; font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: var(--mood-text-muted); background: var(--mood-surface);
padding: 2px 10px; border-radius: var(--r-pill);
}
.vp__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.vp__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vp__sandbox {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text-muted) 7%, var(--mood-bg));
}
.vp__sandbox-banner {
display: flex; align-items: center; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
text-transform: uppercase; letter-spacing: 0.04em;
}
.vp__sandbox-seeds { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.vp__sandbox > .ld-btn { align-self: flex-start; }
.vp__impact {
background: var(--mood-accent-soft); border-radius: var(--r-input);
padding: 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.5rem;
}
.vp__impact-title { margin: 0; font-size: 0.9375rem; font-weight: 800; color: var(--mood-accent); }
.vp__impact-table { border-collapse: collapse; font-size: 0.875rem; width: 100%; }
.vp__impact-table th, .vp__impact-table td { text-align: right; padding: 0.2rem 0.45rem; }
.vp__impact-table tbody th { text-align: left; font-weight: 600; }
.vp__impact-table thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mood-text-muted); }
.vp__impact-total { font-weight: 800; }
.vp__impact-note { margin: 0; font-size: 0.75rem; font-style: italic; color: var(--mood-text-muted); }
.vp__count { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.vp__crystal {
display: flex; flex-direction: column; align-items: center; gap: 0.6rem;
padding: 1rem; border-radius: var(--r-input); background: var(--mood-status-fige-bg);
}
.vp__crystal-card { margin: 0; font-weight: 600; font-size: 0.9375rem; text-align: center; }
.vp__stamp-btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.65rem 1.5rem; border-radius: 10px; cursor: pointer;
transform: rotate(-2deg);
font-weight: 800; font-size: 1rem; letter-spacing: 0.03em;
color: var(--mood-accent); background: var(--mood-surface);
box-shadow: inset 0 0 0 2.5px var(--mood-accent), 0 2px 8px var(--mood-shadow);
transition: transform 0.12s ease;
}
.vp__stamp-btn:hover { transform: rotate(-2deg) translateY(-1px); }
.vp__stamp-btn:active { transform: rotate(-2deg) translateY(0); }
.vp__crystallized { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-status-vigueur); }
.vp__modal { display: flex; flex-direction: column; gap: 0.75rem; }
.vp__modal-median { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.9375rem; }
.vp__modal-warn {
display: flex; align-items: baseline; gap: 0.4rem; margin: 0;
font-size: 0.875rem; font-weight: 600; color: var(--mood-warning);
}
.vp__modal-choices { display: flex; flex-wrap: wrap; gap: 0.5rem; }
</style>
+16 -1
View File
@@ -39,7 +39,9 @@ export const TRANSITIONS: Readonly<Partial<Record<DecisionStatus, readonly Decis
advice: ['adopted', 'voting'],
objection: ['adopted', 'framing', 'voting'],
framing: ['voting', 'closed'],
voting: ['adopted', 'rejected'],
voting: ['adopted', 'rejected', 'framing'],
// voting→framing: ONLY the « Reformuler » choice of the crystallization gesture —
// guarded below to a frozen, non-crystallized parametric session (Δ3).
adopted: ['revoked', 'closed'],
}
@@ -77,6 +79,8 @@ const REASONS = {
dossierPending:
'Des éléments du dossier sont encore en cours — le dossier se clôt quand tous ont abouti.',
crystallization: 'Les votes sont figés — la cristallisation attend son geste.',
reformulate:
'Reformuler nest possible que sur un réglage collectif figé, avant sa cristallisation.',
} as const
export interface TransitionContext {
@@ -230,6 +234,17 @@ export function canTransition(
}
}
// voting→framing is EXCLUSIVELY the « Reformuler » branch of the crystallization
// gesture (Δ3): a frozen, not-yet-crystallized parametric session may go back to
// formulation instead of being crystallized. Any other voting→framing is refused.
if (from === 'voting' && to === 'framing') {
const reformulable =
ctx.session?.status === 'frozen' && !ctx.session.crystallizedById
if (!reformulable) {
return refuse(REASONS.reformulate)
}
}
return { ok: true }
}
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* /creer — onboarding en 3 étapes (layout bare) : le premier usage EST le
* tutoriel. Étape 1 identité, étape 2 gabarit, étape 3 personnes.
* Atterrit sur / où la première décision « Adopter notre Pacte » attend.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import type { TemplateId } from '~/data/templates'
import { CREATE_SIGNATURE, FIRST_DECISION, OPENING_DREAM } from '~/lexicon'
import { COLOR_CHOICES } from '~/components/onboarding/OnboardingIdentity.vue'
import { TEMPLATE_ICONS } from '~/components/onboarding/OnboardingTemplates.vue'
definePageMeta({ layout: 'bare' })
const store = useCollectiveStore()
const step = ref<1 | 2 | 3>(1)
const name = ref('')
const color = ref(COLOR_CHOICES[0]!.hex)
const template = ref<TemplateId | null>(null)
const meName = ref('')
const members = ref<string[]>([])
const busy = ref(false)
const issues = ref<string[]>([])
const STEP_TITLES: Record<1 | 2 | 3, string> = {
1: 'Donne-lui un nom, une couleur, une ambiance.',
2: 'Choisis un point de départ — tout reste amendable par décision.',
3: 'Toi, puis les premières personnes autour de la table.',
}
const canContinue = computed(() => {
if (busy.value) return false
if (step.value === 1) return name.value.trim().length > 0
if (step.value === 2) return template.value !== null
return meName.value.trim().length > 0
})
function next() {
if (!canContinue.value) return
issues.value = []
if (step.value < 3) step.value++
else void create()
}
function back() {
if (busy.value || step.value === 1) return
issues.value = []
step.value--
}
/** « Les Jardins du Canal » → « les-jardins-du-canal ». */
function slugify(text: string): string {
return (
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'collectif'
)
}
function keepBlockingIssues(all: { level: string; message: string }[]): boolean {
const blocking = all.filter(i => i.level === 'error').map(i => i.message)
issues.value = blocking
return blocking.length > 0
}
async function create() {
if (!template.value || busy.value) return
busy.value = true
const result = await store.createFromTemplate(template.value, {
name: name.value.trim(),
slug: slugify(name.value),
color: color.value,
icon: TEMPLATE_ICONS[template.value],
meName: meName.value.trim(),
memberNames: members.value,
})
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
async function explore(seed: SeedName) {
if (busy.value) return
busy.value = true
issues.value = []
const result = await store.loadSeed(seed)
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key !== 'Enter') return
const target = ev.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(target.tagName)) return
next()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<!-- ld-v2 -->
<div class="creer">
<header class="creer__head">
<h1 class="creer__dream">{{ OPENING_DREAM }}</h1>
<p class="creer__step-title">{{ STEP_TITLES[step] }}</p>
</header>
<OnboardingIdentity
v-if="step === 1"
v-model:name="name"
v-model:color="color"
@next="next"
/>
<OnboardingTemplates
v-else-if="step === 2"
v-model="template"
@next="next"
@seed="explore"
/>
<OnboardingMembers
v-else
v-model:me-name="meName"
v-model:members="members"
/>
<ul v-if="issues.length" class="creer__issues">
<li v-for="msg in issues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
<footer class="creer__nav">
<button v-if="step > 1" type="button" class="ld-btn ld-btn--quiet" :disabled="busy" @click="back">
<UIcon name="i-lucide-arrow-left" /> Retour
</button>
<span v-else class="creer__spacer" />
<span class="creer__dots" role="status" :aria-label="`Étape ${step} sur 3`">
<span
v-for="n in 3"
:key="n"
class="creer__dot"
:class="{ 'creer__dot--active': step === n, 'creer__dot--done': step > n }"
/>
</span>
<button type="button" class="ld-btn" :disabled="!canContinue" @click="next">
<template v-if="step < 3">
Continuer <UIcon name="i-lucide-arrow-right" />
</template>
<template v-else>
<UIcon name="i-lucide-sparkles" /> Fonder le collectif
</template>
</button>
</footer>
<p v-if="step === 3" class="creer__hint">
La première décision t'attendra : « {{ FIRST_DECISION }} ».
</p>
<p v-if="step === 3" class="creer__signature">{{ CREATE_SIGNATURE }}</p>
</div>
</template>
<style scoped>
.creer {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2.25rem);
width: min(100%, 56rem);
margin-inline: auto;
padding-bottom: 3.5rem;
}
.creer__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.creer__dream {
margin: 0;
font-size: clamp(1.6rem, 5vw, 2.6rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.creer__step-title {
margin: 0;
font-size: clamp(0.9375rem, 2.5vw, 1.0625rem);
color: var(--mood-text-muted);
}
.creer__issues {
margin: 0;
padding: 0.875rem 1.1rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.9rem;
font-weight: 600;
}
.creer__issues li {
display: flex;
align-items: center;
gap: 0.45rem;
}
.creer__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.creer__spacer {
min-width: 5.5rem;
}
.creer__dots {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.creer__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--mood-text-muted);
opacity: 0.3;
transition: transform 0.12s ease, opacity 0.12s ease, background 0.12s ease;
}
.creer__dot--active {
background: var(--mood-accent);
opacity: 1;
transform: scale(1.25);
}
.creer__dot--done {
background: var(--mood-accent);
opacity: 0.55;
}
.creer__hint {
margin: -0.75rem 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.creer__signature {
margin: 0;
text-align: center;
font-size: 0.875rem;
font-style: italic;
opacity: 0.65;
color: var(--mood-accent);
}
@media (max-width: 480px) {
.creer__nav {
flex-wrap: wrap;
justify-content: center;
}
.creer__dots {
order: -1;
width: 100%;
justify-content: center;
}
.creer__spacer { display: none; }
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
/**
* « /decider — Le chemin » : une phrase → un chemin exécutable en un tap.
* Le moteur pur recalcule à chaque geste ; tout est expliqué en une phrase,
* tout est contournable (alourdir libre, alléger motivé). Chemin nominal :
* phrase + Entrée (au Fil) puis LE bouton — 2 gestes.
*/
import type { ApplyPathEdits } from '~/stores/decisions'
import type {
ChainKind, Decision, DecisionRoute, Id, ParamSpec, Reversibility,
TriageInput, Verdict, Weight,
} from '~/types/domain'
import { computeConcerned } from '~/engine'
import {
ADOPTED_STAMP, ADOPTED_TOAST, METHOD_LABELS,
CAPTURE_PLACEHOLDER, ROUTE_LABELS, WHY_DISCLOSURE,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
type AltKey = 'parametric' | 'record' | 'binary'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
// ── State of the tunnel ──
const title = ref('')
const scope = ref<Decision['scope']>({ selfOnly: true, circleIds: [], personIds: [] })
const reversibility = ref<Reversibility>('easy')
const weight = ref<Weight>('light')
const urgent = ref(false)
const amendsClauseId = ref<Id | undefined>()
const parentId = ref<Id | undefined>()
const chainKind = ref<ChainKind | undefined>()
const baselineNote = ref('')
const resourceNote = ref('')
const resourceAmount = ref<number | null>(null)
const resourceUnit = ref('heures')
const chosenAlt = ref<AltKey | null>(null)
const decidedHow = ref('')
const overrideNote = ref('')
const spec = ref<ParamSpec | undefined>()
const reviewChecked = ref(false)
const engraveChecked = ref(false)
const mandateAttached = ref(false)
const error = ref('')
const stamping = ref(false)
// ── Prefill from the query (?titre ?clause ?parent ?chain ?mandat) ──
const q = route.query
if (typeof q.titre === 'string') title.value = q.titre
if (typeof q.clause === 'string') amendsClauseId.value = q.clause
if (typeof q.parent === 'string') {
const prefill = decisions.reopen(q.parent)
if (!('ok' in prefill)) {
parentId.value = prefill.parentDecisionId
chainKind.value = prefill.chainKind
if (title.value.length === 0) title.value = prefill.title
scope.value = prefill.scope
reversibility.value = prefill.reversibility
weight.value = prefill.weight
if (prefill.amendsClauseId) amendsClauseId.value = prefill.amendsClauseId
}
}
if (typeof q.chain === 'string') chainKind.value = q.chain as ChainKind
if (typeof q.mandat === 'string') {
const mandate = col.mandates.find(m => m.id === q.mandat)
if (mandate) {
scope.value = { selfOnly: false, circleIds: [...mandate.domain.circleIds], personIds: [] }
}
}
// ── The live path — the pure engine recomputes at every gesture ──
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
const tags = computed(() => [...title.value.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()))
const engineInput = computed<TriageInput>(() => ({
title: title.value,
tags: tags.value,
scope: scope.value,
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
...(amendsClauseId.value ? { amendsClauseId: amendsClauseId.value } : {}),
}))
const chemin = computed<Verdict | null>(() => {
const res = decisions.runTriage(engineInput.value)
return 'ok' in res ? null : res
})
watch(() => chemin.value?.reviewRequired, v => { reviewChecked.value = v === true }, { immediate: true })
watch(() => chemin.value?.engravingSuggested, v => { engraveChecked.value = v === true }, { immediate: true })
// ── Concernés pré-calculés (plancher) ──
const stack = computed(() => {
if (scope.value.selfOnly || !col.me) return []
const active = col.mandates.filter(m => m.status === 'active')
return computeConcerned(scope.value, col.circles, active, col.me.id).flatMap((entry) => {
const person = col.people.find(p => p.id === entry.personId)
return person ? [{ person, origin: 'computed' as const, reason: entry.reason }] : []
})
})
// ── Route effective & asymétrie de dérogation ──
const ROUTE_ORDER: Record<DecisionRoute, number> = {
record: 0, solo: 0, mandate: 1, transmit: 1, advice: 2, collective: 3,
}
const effectiveRoute = computed<DecisionRoute>(() => {
if (!chemin.value) return 'solo'
if (!chosenAlt.value) return chemin.value.route
return chosenAlt.value === 'record' ? 'record' : 'collective'
})
const overridden = computed(() => chemin.value !== null && effectiveRoute.value !== chemin.value.route)
const lightening = computed(
() => chemin.value !== null && ROUTE_ORDER[effectiveRoute.value] < ROUTE_ORDER[chemin.value.route],
)
const overrideLabel = computed(() => {
if (chosenAlt.value === 'parametric') return METHOD_LABELS.parametric
if (chosenAlt.value === 'binary') return METHOD_LABELS.binary
if (chosenAlt.value === 'record') return ROUTE_LABELS.record
return ''
})
// ── Frise : durée / seuil (si connus) ──
const protocol = computed(() => {
if (effectiveRoute.value !== 'collective') return undefined
let id = chemin.value?.protocolId
if (chosenAlt.value === 'parametric') id = col.settings?.protocolByRange.parametric ?? id
if (chosenAlt.value === 'binary') id = col.protocols.find(p => p.method === 'binary')?.id ?? id
return col.protocols.find(p => p.id === id)
})
const framingPlanned = computed(
() => chemin.value?.framingDays !== undefined && effectiveRoute.value === 'collective',
)
const durationLabel = computed(() => {
const v = chemin.value
if (!v || effectiveRoute.value === 'solo' || effectiveRoute.value === 'record') return ''
if (framingPlanned.value) return `${v.framingDays} jours de formulation`
if (v.windowHours !== undefined && effectiveRoute.value === v.route) return `fenêtre de ${v.windowHours} h`
return protocol.value ? `${protocol.value.durationDays} jours de vote` : ''
})
const thresholdLabel = computed(() => {
const p = protocol.value
if (!p) return ''
if (p.method === 'consent') return 'zéro objection maintenue'
return METHOD_LABELS[p.method]
})
// ── Bouton principal ──
const mainLabel = computed(() => {
switch (effectiveRoute.value) {
case 'advice': return 'Demande leur avis'
case 'transmit': return 'Transmets'
case 'record': return 'Consigne'
case 'collective': return framingPlanned.value ? 'Ouvre la formulation' : 'Ouvre le vote'
default: return 'Décide'
}
})
const blockReason = computed(() =>
lightening.value && overrideNote.value.trim().length === 0
? 'Alléger se motive — écris pourquoi, ta note s\'affichera.'
: '',
)
const disabled = computed(
() => title.value.trim().length === 0 || blockReason.value !== '' || chemin.value === null,
)
const engagesOpen = computed(
() => (weight.value === 'binding' || weight.value === 'structural') && !scope.value.selfOnly,
)
const baselineSuggested = computed(() => ['advice', 'collective'].includes(effectiveRoute.value))
const suggestion = computed(() => chemin.value?.suggestion)
const reviewLocked = computed(
() => chemin.value?.reviewRequired === true && effectiveRoute.value !== 'solo',
)
// ── Valider : capture → applyPath → fiche (ou Fil si adopté sur-le-champ) ──
async function validate() {
const v = chemin.value
if (!v || disabled.value) return
const created = decisions.capture(title.value)
if ('ok' in created) { error.value = created.reason; return }
if (parentId.value) {
created.parentDecisionId = parentId.value
created.chainKind = chainKind.value ?? 'revision'
}
const edits: ApplyPathEdits = {
scope: {
selfOnly: scope.value.selfOnly,
circleIds: [...scope.value.circleIds],
personIds: [...scope.value.personIds],
},
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
visibility: scope.value.selfOnly ? 'private' : 'scope',
}
if (effectiveRoute.value !== v.route) edits.route = effectiveRoute.value
if (amendsClauseId.value) edits.amendsClauseId = amendsClauseId.value
if (baselineSuggested.value && baselineNote.value.trim()) edits.baselineNote = baselineNote.value.trim()
if (resourceNote.value.trim().length > 0 || typeof resourceAmount.value === 'number') {
edits.resources = {
note: resourceNote.value.trim(),
...(typeof resourceAmount.value === 'number'
? { amount: resourceAmount.value, unit: resourceUnit.value }
: {}),
}
}
if (chosenAlt.value === 'record' && decidedHow.value.trim()) edits.decidedHow = decidedHow.value.trim()
if (chosenAlt.value === 'parametric') {
if (spec.value) edits.paramSpec = spec.value
const pid = col.settings?.protocolByRange.parametric
if (pid) edits.protocolId = pid
}
if (chosenAlt.value === 'binary') {
const pid = col.protocols.find(p => p.method === 'binary')?.id
if (pid) edits.protocolId = pid
}
if (lightening.value) edits.overrideNote = overrideNote.value.trim()
if (mandateAttached.value && suggestion.value?.kind === 'claim-mandate') {
edits.createsMandate = {
title: `Mandat — ${title.value.trim()}`,
domainCircleIds: [...scope.value.circleIds],
domainTags: [...tags.value],
durationDays: 90,
reportEveryDays: 30,
}
}
const applied = decisions.applyPath(created, { ...v, reviewRequired: reviewChecked.value }, edits)
if ('ok' in applied) { error.value = applied.reason; return }
if (engraveChecked.value && applied.status === 'adopted') await decisions.engrave(applied.id)
if (applied.status === 'adopted'
&& (effectiveRoute.value === 'solo' || effectiveRoute.value === 'record' || v.conservatoryChain)) {
stamping.value = true
setTimeout(() => navigateTo('/'), 1100)
return
}
await navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="decider">
<div v-if="!col.current" class="ld-card decider__none">
<p>Aucun collectif actif ouvre ou crée un collectif d'abord.</p>
<NuxtLink to="/creer" class="ld-btn">Créer un collectif</NuxtLink>
</div>
<template v-else>
<input
v-model="title"
type="text"
class="decider__title"
:placeholder="CAPTURE_PLACEHOLDER"
:aria-label="CAPTURE_PLACEHOLDER"
>
<CheminQ0
:title="title"
:linked-clause-id="amendsClauseId"
@contest="amendsClauseId = $event"
/>
<CheminChips
v-model:scope="scope"
v-model:reversibility="reversibility"
v-model:weight="weight"
v-model:urgent="urgent"
v-model:resource-note="resourceNote"
v-model:resource-amount="resourceAmount"
v-model:resource-unit="resourceUnit"
v-model:baseline-note="baselineNote"
:stack="stack"
:engages-open="engagesOpen"
:baseline-suggested="baselineSuggested"
/>
<template v-if="chemin">
<CheminPathCard
:path="chemin"
:route-shown="effectiveRoute"
:overridden="overridden"
:override-label="overrideLabel"
:stack="stack"
:self-only="scope.selfOnly"
:duration-label="durationLabel"
:threshold-label="thresholdLabel"
:main-label="mainLabel"
v-model:review-checked="reviewChecked"
v-model:engrave-checked="engraveChecked"
:review-locked="reviewLocked"
:disabled="disabled"
:block-reason="blockReason"
@validate="validate"
/>
<p v-if="error" class="decider__error">{{ error }}</p>
<CheminAlternatives
v-model:chosen="chosenAlt"
v-model:decided-how="decidedHow"
v-model:override-note="overrideNote"
v-model:spec="spec"
:alternatives="chemin.alternatives"
:parametric-hint="chemin.parametricHint === true"
:lightening="lightening"
/>
<details class="decider__why">
<summary>{{ WHY_DISCLOSURE }}</summary>
<p>Règle {{ chemin.rule }} — {{ chemin.explanation }}</p>
<p>
Le chemin est recommandé par le Pacte de ton collectif, jamais imposé :
alourdir est libre en un tap, alléger se motive et s'affiche à tous.
</p>
</details>
<CheminSuggestion
v-if="suggestion"
v-model:mandate-attached="mandateAttached"
:suggestion="suggestion"
/>
</template>
</template>
<!-- Micro-célébration tampon bref puis retour au Fil -->
<Transition name="stamp">
<div v-if="stamping" class="decider__overlay">
<span class="ld-stamp decider__stamp"> {{ ADOPTED_STAMP }}</span>
<p class="decider__toast">{{ ADOPTED_TOAST }}</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.decider {
width: 100%;
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.decider__none {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
}
.decider__title {
width: 100%;
padding: 0.875rem 1rem;
font-size: clamp(1.125rem, 3.5vw, 1.375rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.decider__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.decider__why {
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.decider__why summary {
cursor: pointer;
font-weight: 700;
padding: 0.25rem;
border-radius: var(--r-input);
width: fit-content;
}
.decider__why summary:hover { color: var(--mood-text); }
.decider__why p { margin: 0.5rem 0 0 0.25rem; line-height: 1.5; }
.decider__overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--mood-bg) 88%, transparent);
}
.decider__stamp { font-size: 1.5rem; padding: 0.75rem 1.5rem; }
.decider__toast { margin: 0; font-size: 1rem; font-weight: 600; }
.stamp-enter-active { transition: opacity 0.12s ease; }
.stamp-enter-from { opacity: 0; }
.stamp-enter-active .decider__stamp {
animation: stamp-in 0.12s ease;
}
@keyframes stamp-in {
from { transform: rotate(-10deg) scale(1.4); opacity: 0; }
to { transform: rotate(-10deg) scale(1); opacity: 1; }
}
</style>
-464
View File
@@ -1,464 +0,0 @@
<script setup lang="ts">
import type { DecisionStep, DecisionStepCreate } from '~/stores/decisions'
const route = useRoute()
const decisions = useDecisionsStore()
const decisionId = computed(() => route.params.id as string)
onMounted(async () => {
await decisions.fetchById(decisionId.value)
})
onUnmounted(() => {
decisions.clearCurrent()
})
watch(decisionId, async (newId) => {
if (newId) {
await decisions.fetchById(newId)
}
})
// --- Status helpers ---
const statusColor = (status: string) => {
switch (status) {
case 'draft': return 'warning'
case 'qualification': return 'info'
case 'review': return 'info'
case 'voting': return 'primary'
case 'executed': return 'success'
case 'closed': return 'neutral'
case 'pending': return 'warning'
case 'active': return 'success'
case 'in_progress': return 'success'
case 'completed': return 'info'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Brouillon'
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'voting': return 'En vote'
case 'executed': return 'Execute'
case 'closed': return 'Clos'
case 'pending': return 'En attente'
case 'active': return 'Actif'
case 'in_progress': return 'En cours'
case 'completed': return 'Termine'
default: return status
}
}
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modification de document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Changement de parametre'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// --- Terminal state check ---
const terminalStatuses = ['executed', 'closed']
const isTerminal = computed(() => {
if (!decisions.current) return true
return terminalStatuses.includes(decisions.current.status)
})
const isDraft = computed(() => decisions.current?.status === 'draft')
// --- Advance action ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try {
await decisions.advance(decisionId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
}
}
// --- Create vote session ---
async function handleCreateVoteSession(step: DecisionStep) {
try {
await decisions.createVoteSession(decisionId.value, step.id)
} catch {
// Error handled by store
}
}
// --- Edit modal ---
const showEditModal = ref(false)
const editData = ref({
title: '',
description: '' as string | null,
context: '' as string | null,
})
const saving = ref(false)
function openEdit() {
if (!decisions.current) return
editData.value = {
title: decisions.current.title,
description: decisions.current.description,
context: decisions.current.context,
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await decisions.update(decisionId.value, editData.value)
showEditModal.value = false
} catch {
// Error handled by store
} finally {
saving.value = false
}
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
async function handleDelete() {
deleting.value = true
try {
await decisions.delete(decisionId.value)
navigateTo('/decisions')
} catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
}
// --- Add step ---
const showAddStep = ref(false)
const newStep = ref<DecisionStepCreate>({
step_type: 'qualification',
title: '',
description: '',
})
const addingStep = ref(false)
const stepTypeOptions = [
{ label: 'Qualification', value: 'qualification' },
{ label: 'Revue', value: 'review' },
{ label: 'Vote', value: 'vote' },
{ label: 'Execution', value: 'execution' },
{ label: 'Compte rendu', value: 'reporting' },
]
async function handleAddStep() {
addingStep.value = true
try {
await decisions.addStep(decisionId.value, newStep.value)
showAddStep.value = false
newStep.value = { step_type: 'qualification', title: '', description: '' }
} catch {
// Error handled by store
} finally {
addingStep.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/decisions"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux decisions"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="decisions.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
</div>
</div>
</template>
<!-- Error state -->
<template v-else-if="decisions.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ decisions.error }}</p>
</div>
</UCard>
</template>
<!-- Decision detail -->
<template v-else-if="decisions.current">
<!-- Header with actions -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ decisions.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">
{{ typeLabel(decisions.current.decision_type) }}
</UBadge>
<UBadge :color="statusColor(decisions.current.status)" variant="subtle">
{{ statusLabel(decisions.current.status) }}
</UBadge>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<UButton
v-if="!isTerminal"
icon="i-lucide-fast-forward"
label="Avancer la decision"
color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div>
</div>
<!-- Description & Context -->
<UCard v-if="decisions.current.description || decisions.current.context">
<div class="space-y-4">
<div v-if="decisions.current.description">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ decisions.current.description }}
</p>
</div>
<div v-if="decisions.current.context">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Contexte</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ decisions.current.context }}
</p>
</div>
</div>
</UCard>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(decisions.current.created_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(decisions.current.updated_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ decisions.current.steps.length }}
</p>
</div>
</div>
</UCard>
<!-- Steps timeline -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Etapes du processus
</h2>
<UButton
v-if="!isTerminal"
icon="i-lucide-plus"
label="Ajouter une etape"
variant="soft"
color="primary"
size="sm"
@click="showAddStep = true"
/>
</div>
<DecisionWorkflow
:steps="decisions.current.steps"
:current-status="decisions.current.status"
@create-vote-session="handleCreateVoteSession"
/>
</div>
</template>
<!-- Edit modal -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier la decision
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="editData.title" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="editData.description" :rows="4" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contexte</label>
<UTextarea v-model="editData.context" :rows="3" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit"
/>
</div>
</div>
</template>
</UModal>
<!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer cette decision ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete"
/>
</div>
</div>
</template>
</UModal>
<!-- Add step modal -->
<UModal v-model:open="showAddStep">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Ajouter une etape
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type d'etape <span class="text-red-500">*</span>
</label>
<USelect
v-model="newStep.step_type"
:items="stepTypeOptions"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="newStep.title" placeholder="Titre de l'etape..." />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="newStep.description" :rows="3" placeholder="Description de l'etape..." />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showAddStep = false"
/>
<UButton
label="Ajouter"
icon="i-lucide-plus"
color="primary"
:loading="addingStep"
@click="handleAddStep"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* /decisions/[id] — la fiche décision, URL permanente de l'objet pivot.
* Progressive disclosure par état : timeline + périmètre toujours ;
* s'instruire (formulation/vote), fenêtre, formulation, éléments de dossier,
* session, vigueur, chaînage. Imprimable en PV A4 ; gravure locale.
*/
import {
ADOPTED_STAMP, BASELINE_ARROW, ENGAGES_LABEL, INERTIA_LABELS,
REOPEN_HANDLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE, WEIGHT_LABELS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, displayStatusLabel, dateFr } from '~/components/decisions/decisionUi'
const route = useRoute()
const col = useCollectiveStore()
const decisionId = computed(() => route.params.id as string)
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const shown = computed(() =>
decision.value ? displayStatus(decision.value, session.value) : 'draft')
// ── Origine : protocole et inertie (poignée « Remettre en question ») ──
const originProtocol = computed(() =>
decision.value?.protocolId
? col.protocols.find(p => p.id === decision.value!.protocolId)?.name
: undefined)
const originInertia = computed(() => {
const clauseId = decision.value?.amendsClauseId
if (!clauseId) return undefined
const clause = col.clauses.find(c => c.id === clauseId)
return clause ? INERTIA_LABELS[clause.inertia] : undefined
})
const originLine = computed(() => {
const parts: string[] = []
if (originProtocol.value) parts.push(originProtocol.value)
if (originInertia.value) parts.push(originInertia.value)
return parts.length > 0
? `Le protocole et l'inertie d'origine s'appliquent : ${parts.join(' — ')}.`
: 'Le protocole et l\'inertie d\'origine s\'appliquent.'
})
// ── Blocs selon l'état ──
const showInstruct = computed(() =>
decision.value?.status === 'framing' || decision.value?.status === 'voting')
const showWindow = computed(() =>
decision.value?.status === 'objection' || decision.value?.status === 'advice')
const hasElements = computed(() =>
col.decisions.some(d =>
d.parentDecisionId === decisionId.value && d.chainKind === 'element'))
const framingDays = computed(() => col.settings?.triage.framingDays ?? 14)
// ── Gestes ──
async function splitDossier() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=element`)
}
async function reopen() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=revision`)
}
</script>
<template>
<!-- ld-v2 -->
<article v-if="decision" class="fiche">
<!-- En-tête -->
<header class="fiche__head">
<div class="fiche__status-row">
<span class="status-pill" :class="`status-${shown}`">
{{ displayStatusLabel(shown) }}
</span>
<span v-if="decision.status === 'adopted'" class="ld-stamp">
{{ ADOPTED_STAMP }}
</span>
<span v-if="decision.engraving" class="fiche__well" title="gravée"></span>
</div>
<h1 class="fiche__title">{{ decision.title }}</h1>
<p v-if="decision.baselineNote" class="fiche__baseline">
<span class="fiche__baseline-arrow">{{ BASELINE_ARROW }}</span>
<span>{{ decision.baselineNote }}</span>
</p>
<div class="fiche__meta">
<span class="fiche__route" :style="{ color: `var(--route-${decision.route})` }">
<UIcon :name="ROUTE_ICONS[decision.route]" />
<span>{{ ROUTE_LABELS[decision.route] }}</span>
</span>
<span class="fiche__chip">{{ WEIGHT_LABELS[decision.weight] }}</span>
<span v-if="decision.urgent" class="fiche__urgent">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_BADGE }}</span>
</span>
</div>
<p v-if="decision.decidedHow" class="fiche__how">
Comment ça s'est décidé : « {{ decision.decidedHow }} »
</p>
<div v-if="decision.routeOverridden && decision.overrideNote" class="fiche__banner">
<UIcon name="i-lucide-feather" />
<span><strong>Chemin allégé</strong> « {{ decision.overrideNote }} »</span>
</div>
<div v-if="decision.scopeKeptNote" class="fiche__banner">
<UIcon name="i-lucide-circle-dot" />
<span><strong>Périmètre maintenu</strong> « {{ decision.scopeKeptNote }} »</span>
</div>
</header>
<!-- Actions + fiche de preuve -->
<DecisionProof :decision="decision" />
<!-- Cycle de vie + périmètre -->
<div class="fiche__grid">
<div class="ld-card fiche__card">
<h2 class="fiche__card-title">Cycle de vie</h2>
<DecisionTimeline :decision="decision" :session="session" />
</div>
<div class="ld-card fiche__card">
<DecisionPerimeter :decision="decision" />
</div>
</div>
<!-- S'instruire ── -->
<div v-if="showInstruct" class="ld-card fiche__card">
<DecisionInstruct :decision="decision" />
</div>
<!-- ── Fenêtre ── -->
<div v-if="showWindow" class="ld-card fiche__card">
<DecisionWindow :decision="decision" />
</div>
<!-- ── Formulation ── -->
<div v-if="decision.status === 'framing'" class="ld-card fiche__card">
<h2 class="fiche__card-title">Formulation</h2>
<p class="fiche__framing-phrase">
{{ framingDays }} jours pour s'instruire et formuler des contre-propositions.
</p>
<LdCountdown v-if="decision.windowEndsAt" :ends-at="decision.windowEndsAt" />
<div v-if="decision.weight === 'structural'" class="no-print">
<button type="button" class="ld-btn ld-btn--ghost" @click="splitDossier()">
<UIcon name="i-lucide-scissors" />
<span>Découper en micro-décisions</span>
</button>
</div>
</div>
<!-- Éléments du dossier -->
<div v-if="hasElements" class="ld-card fiche__card">
<DecisionElements :decision="decision" />
</div>
<!-- Session -->
<div v-if="session" class="ld-card fiche__card">
<DecisionSession :decision="decision" />
</div>
<!-- Vigueur -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card">
<DecisionVigor :decision="decision" />
</div>
<!-- Remettre en question -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card fiche__reopen no-print">
<div>
<p class="fiche__reopen-title">{{ REOPEN_HANDLE }}</p>
<p class="fiche__reopen-line">{{ originLine }}</p>
</div>
<button type="button" class="ld-btn ld-btn--ghost" @click="reopen()">
<UIcon name="i-lucide-rotate-ccw" />
<span>{{ REOPEN_HANDLE }}</span>
</button>
</div>
<!-- Chaînage -->
<div class="fiche__chain-wrap">
<DecisionChain :decision="decision" />
</div>
<!-- Pied de PV (impression) -->
<p class="print-only fiche__pv-foot">
{{ ENGAGES_LABEL }} : {{ decision.resources?.note ?? '—' }}
fiche imprimée le {{ dateFr(new Date().toISOString()) }} · libreDecision
</p>
</article>
<div v-else class="ld-card fiche__missing">
<p>Cette décision est introuvable dans ce collectif.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Retour au registre</NuxtLink>
</div>
</template>
<style scoped>
.fiche {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.fiche__head { display: flex; flex-direction: column; gap: 0.625rem; }
.fiche__status-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.fiche__well {
font-size: 1.25rem;
font-weight: 800;
color: var(--mood-status-vigueur);
transform: rotate(-10deg);
}
.fiche__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.875rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.fiche__baseline {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
font-size: 0.9375rem;
}
.fiche__baseline-arrow {
font-weight: 800;
font-size: 0.8125rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.fiche__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.fiche__route {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 800;
font-size: 0.9375rem;
}
.fiche__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);
}
.fiche__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);
}
.fiche__how {
margin: 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.fiche__banner {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--r-input);
font-size: 0.9375rem;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
box-shadow: inset 0 0 0 1.5px var(--mood-status-fenetre);
}
.fiche__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.fiche__grid { grid-template-columns: 1fr 1fr; }
}
.fiche__card {
padding: 1.25rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fiche__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.fiche__framing-phrase { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.fiche__reopen {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.fiche__reopen-title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
.fiche__reopen-line {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.fiche__pv-foot { font-size: 0.75rem; color: #333333; }
.fiche__missing {
max-width: 52rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
@media print {
.fiche__grid { grid-template-columns: 1fr 1fr; }
.fiche__card { padding: 0.5rem 0; }
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<script setup lang="ts">
// <!-- ld-v2 --> La salle de vote — le geste démocratique central : instruit,
// puis prononcé. Cinq modalités, un seul lieu ; la clôture due est constatée
// au chargement, l'adoption s'applique d'elle-même et se fête sobrement.
import { wotThreshold } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
ADOPTED_STAMP, ADOPTED_TOAST, ELECTION_RULE, SECRET_DISPLAY, STATUS_LABELS,
VOTE_PRIVACY, WHO_VOTES, WORKSHOP_MODE,
} from '~/lexicon'
import type { Id, VoteSession } from '~/types/domain'
const route = useRoute()
const col = useCollectiveStore()
const store = useDecisionsStore()
const decisionId = computed(() => String(route.params.id))
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed<VoteSession | undefined>(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const protocol = computed(() => col.protocols.find(p => p.id === session.value?.protocolId))
const method = computed(() => protocol.value?.method)
const secret = computed(() => protocol.value?.ballot === 'secret')
// ── Qui vote — la liste arrêtée ──
const eligibleIds = computed<Id[]>(() => session.value?.corpusPersonIds ?? [])
const voterEntries = computed(() =>
col.people.filter(p => eligibleIds.value.includes(p.id)).map(person => ({ person })))
const arrestedDate = computed(() => session.value
? new Date(session.value.opensAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
: '')
// ── Mode atelier — saisir pour quelqu'un, en présence ──
const workshop = ref(false)
const asId = ref<Id | ''>('')
onMounted(() => { workshop.value = !!localStorage.getItem('ld2-workshop') })
const votingAs = computed<Id | undefined>(() =>
workshop.value && asId.value && asId.value !== col.me?.id ? asId.value : undefined)
const voterId = computed(() => votingAs.value ?? col.me?.id)
const inList = computed(() => voterId.value !== undefined && eligibleIds.value.includes(voterId.value))
const canAct = computed(() => session.value?.status === 'open' && inList.value)
const isSteward = computed(() => {
const d = decision.value
const me = col.me
if (!d || !me) return false
return d.stewardIds.length > 0 ? d.stewardIds.includes(me.id) : d.authorId === me.id
})
// ── Clôture due, constatée au chargement ──
const toast = ref('')
function celebrate() {
toast.value = ADOPTED_TOAST
setTimeout(() => { toast.value = '' }, 5000)
}
onMounted(() => {
const s = session.value
if (s && s.status === 'open' && s.closesAt <= col.now()) {
const closed = store.closeSession(s)
if (!('ok' in closed) && closed.outcome === 'adopted') celebrate()
}
})
const displayStatus = computed(() => {
if (session.value?.status === 'frozen') return { css: 'status-frozen', label: 'figé' }
const s = decision.value?.status ?? 'voting'
return { css: `status-${s}`, label: STATUS_LABELS[s] }
})
// ── Affiche de session (impression A4) ──
const closesAtLong = computed(() => session.value
? new Date(session.value.closesAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
: '')
const posterRule = computed(() => {
const f = protocol.value?.formula
if (!f) return ''
switch (method.value) {
case 'consent': return 'Zéro objection maintenue à l\'échéance — le collectif consent.'
case 'nuanced': return `${f.nuancedThresholdPct ?? 80} % de nuances positives (3 à 5) requis.`
case 'binary': {
const active = session.value ? store.activeVotes(session.value.id) : []
const total = active.filter(v => v.value === 'for' || v.value === 'against').length
const threshold = wotThreshold(Math.max(session.value?.corpusSize ?? 1, 1), total,
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase)
return `Seuil actuel : ${threshold.toLocaleString('fr-FR')} pour — il descend quand la participation monte.`
}
case 'parametric': return `Le collectif retient la médiane de chaque curseur, cristallisée par le garant${f.parametricMinParticipants !== undefined ? ` (quorum ${f.parametricMinParticipants})` : ''}.`
case 'election': return ELECTION_RULE
default: return ''
}
})
function printPoster() { window.print() }
</script>
<template>
<!-- ld-v2 -->
<div class="vote-room">
<template v-if="decision && session && protocol">
<div class="no-print vote-room__inner">
<!-- En-tête -->
<header class="vr__head">
<NuxtLink :to="`/decisions/${decision.id}`" class="vr__back">
<UIcon name="i-lucide-arrow-left" />
<span>Fiche décision</span>
</NuxtLink>
<div class="vr__title-row">
<h1 class="vr__title">{{ decision.title }}</h1>
<span v-if="decision.status === 'adopted'" class="ld-stamp"> {{ ADOPTED_STAMP }}</span>
</div>
<div class="vr__pills">
<span class="status-pill" :class="displayStatus.css">{{ displayStatus.label }}</span>
<span class="vr__protocol">{{ protocol.name }}</span>
<span v-if="secret" class="vr__secret">
<UIcon name="i-lucide-eye-off" />
<span>{{ SECRET_DISPLAY }}</span>
</span>
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
<button class="ld-btn ld-btn--quiet vr__print" type="button" @click="printPoster()">
<UIcon name="i-lucide-printer" />
<span>Imprimer</span>
</button>
</div>
<div class="vr__who">
<span>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</span>
<LdAvatarStack :people="voterEntries" :size="28" :max="8" />
</div>
</header>
<!-- Mode atelier -->
<div v-if="workshop" class="vr__workshop">
<UIcon name="i-lucide-users-round" />
<span>{{ WORKSHOP_MODE }} saisir pour</span>
<select v-model="asId" class="vr__workshop-select">
<option value="">moi-même</option>
<option v-for="entry in voterEntries" :key="entry.person.id" :value="entry.person.id">
{{ entry.person.displayName }}
</option>
</select>
</div>
<p v-if="session.status === 'open' && !inList" class="vr__consultative">
Tu n'es pas dans la liste arrêtée ta voix est consultative.
</p>
<!-- S'instruire, puis se prononcer -->
<VoteInstruct :decision="decision" :secret="secret" />
<VoteConsent
v-if="method === 'consent'"
:decision="decision" :session="session"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteNuanced
v-else-if="method === 'nuanced'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteBinary
v-else-if="method === 'binary'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteParametric
v-else-if="method === 'parametric' && decision.paramSpec"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
:is-steward="isSteward"
@adopted="celebrate()"
/>
<VoteElection
v-else-if="method === 'election'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<!-- Transversal : re-vote et discrétion -->
<p class="vr__privacy">
<UIcon name="i-lucide-shield" />
<span>{{ VOTE_PRIVACY }}</span>
</p>
<VoteMyHistory v-if="col.me && voterId === col.me.id" :session="session" :voter-id="col.me.id" />
</div>
<!-- Affiche de session A4 -->
<div class="print-only vr__poster">
<h1>{{ decision.title }}</h1>
<p v-if="decision.body" class="vr__poster-body">{{ decision.body }}</p>
<hr>
<p><strong>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</strong></p>
<p v-if="!secret">{{ voterEntries.map(e => e.person.displayName).join(' · ') }}</p>
<p>{{ posterRule }}</p>
<p><strong>Échéance :</strong> {{ closesAtLong }}</p>
<p><strong>Comment participer :</strong> ouvre la salle de vote « {{ decision.title }} » dans libreDecision, ou confie ton geste en présence (mode atelier).</p>
<p class="vr__poster-seal"></p>
</div>
</template>
<!-- Garde-fous -->
<div v-else-if="!decision" class="ld-card vr__empty">
<p>Cette décision est introuvable.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Toutes les décisions</NuxtLink>
</div>
<div v-else class="ld-card vr__empty">
<p>Cette décision n'a pas encore de session de vote.</p>
<NuxtLink :to="`/decisions/${decisionId}`" class="ld-btn ld-btn--ghost">Fiche décision</NuxtLink>
</div>
<!-- Micro-célébration aucune fanfare -->
<Transition name="vr-toast">
<div v-if="toast" class="vr__toast">
<span class="ld-stamp"> {{ ADOPTED_STAMP }}</span>
<span>{{ toast }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.vote-room { max-width: 46rem; width: 100%; margin: 0 auto; }
.vote-room__inner { display: flex; flex-direction: column; gap: 1rem; }
.vr__head { display: flex; flex-direction: column; gap: 0.6rem; }
.vr__back {
display: inline-flex; align-items: center; gap: 0.35rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-decoration: none;
}
.vr__back:hover { color: var(--mood-accent); }
.vr__title-row { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
.vr__title {
margin: 0; font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 800;
line-height: 1.25; letter-spacing: -0.01em; flex: 1; min-width: 0;
}
.vr__pills { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.vr__protocol {
font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent);
background: var(--mood-accent-soft); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__secret {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-status-fige);
background: var(--mood-status-fige-bg); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__print { margin-left: auto; padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
.vr__who {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem 0.9rem;
font-size: 0.9375rem; font-weight: 600; color: var(--mood-text-muted);
}
.vr__workshop {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;
padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-secondary) 10%, var(--mood-surface));
color: var(--mood-secondary); font-size: 0.875rem; font-weight: 700;
}
.vr__workshop-select {
min-height: 2.25rem; padding: 0.25rem 0.6rem; font-size: 0.875rem; font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.vr__consultative {
margin: 0; padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre);
font-size: 0.875rem; font-weight: 600;
}
.vr__privacy {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; color: var(--mood-text-muted);
}
.vr__empty {
padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;
text-align: center; font-weight: 600;
}
.vr__empty p { margin: 0; }
.vr__toast {
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
z-index: 50; display: flex; align-items: center; gap: 0.75rem;
background: var(--mood-surface); color: var(--mood-text);
padding: 0.75rem 1.25rem; border-radius: var(--r-card);
box-shadow: var(--shadow-raised); font-weight: 600; font-size: 0.9375rem;
max-width: min(92vw, 30rem);
}
.vr-toast-enter-active, .vr-toast-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; }
.vr-toast-enter-from, .vr-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* Affiche A4 — noir sur blanc, sans ambiance */
.vr__poster { color: #111; font-size: 12pt; line-height: 1.5; }
.vr__poster h1 { font-size: 22pt; margin: 0 0 8pt; }
.vr__poster hr { border: none; border-top: 1pt solid #111; margin: 8pt 0; }
.vr__poster-body { white-space: pre-wrap; }
.vr__poster-seal { font-size: 28pt; text-align: right; margin-top: 16pt; }
</style>
+269 -561
View File
@@ -1,612 +1,320 @@
<script setup lang="ts">
/**
* Decisions — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
* /decisions — le registre v2. Pills d'état cliquables (mapping unique),
* pills courtes de route, filtres (cercle/tag/me concerne/poids/gravées/
* consignées), recherche locale. Zéro bouton de création : on crée par la
* capture — l'état vide le rappelle.
*/
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
import type { Decision, DecisionRoute, Weight } from '~/types/domain'
import { ROUTE_SHORT, STATUS_LABELS, FROZEN_LABEL, CAPTURE_PLACEHOLDER } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, fold, type DisplayStatus } from '~/components/decisions/decisionUi'
// Toolbox state
const showConsentModal = ref(false)
const selectedMethod = ref<string | null>(null)
const col = useCollectiveStore()
const consentSteps = [
'Présenter la proposition clairement (2 min)',
'Tour de clarification — questions de compréhension uniquement',
'Tour de réaction — chacun réagit brièvement',
'Porteur amende si nécessaire',
'Tour d\'objections — silence = consentement',
'Lever les objections valides par amendement',
'Adopter ou reporter',
// ── Filtres ──
const query = ref('')
const statusFilter = ref<DisplayStatus | null>(null)
const routeFilter = ref<DecisionRoute | null>(null)
const filterCircle = ref<string | null>(null)
const filterTag = ref<string | null>(null)
const filterMine = ref(false)
const filterWeight = ref<Weight | null>(null)
const filterEngraved = ref(false)
const filterRecorded = ref(false)
const latestSession = (decisionId: string) =>
col.sessions
.filter(s => s.decisionId === decisionId)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
const shownStatus = (d: Decision): DisplayStatus => displayStatus(d, latestSession(d.id))
// ── Pills d'état — l'ordre du cycle, comptées, masquées à zéro ──
const STATUS_ORDER: DisplayStatus[] = [
'draft', 'advice', 'objection', 'framing', 'voting', 'frozen',
'adopted', 'closed', 'rejected', 'revoked', 'transmitted',
]
const statusPills = computed(() =>
STATUS_ORDER
.map(status => ({
status,
label: status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status],
count: col.decisions.filter(d => shownStatus(d) === status).length,
}))
.filter(pill => pill.count > 0),
)
function handleMethodSelect(method: string) {
selectedMethod.value = method
if (method.toLowerCase().includes('consentement')) {
showConsentModal.value = true
}
else if (method.toLowerCase().includes('avis')) {
// Navigate to advice process guide in mandates toolbox
navigateTo('/mandates')
}
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routePills = computed(() =>
ROUTES
.map(route => ({ route, count: col.decisions.filter(d => d.route === route).length }))
.filter(pill => pill.count > 0),
)
// ── Liste filtrée puis triée : en cours par échéance, terminées ensuite ──
const meId = computed(() => col.me?.id ?? null)
const ACTIVE: DisplayStatus[] = ['draft', 'advice', 'objection', 'framing', 'voting', 'frozen']
function concernsMe(d: Decision): boolean {
if (!meId.value) return false
if (d.authorId === meId.value) return true
return col.concerns.some(c => c.decisionId === d.id && c.personId === meId.value)
}
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
decisions.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
])
/** Map for the voting pill — include qualification/review under "En vote". */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft'],
voting: ['qualification', 'review', 'voting'],
executed: ['executed'],
closed: ['closed'],
function deadlineOf(d: Decision): string {
return d.windowEndsAt ?? latestSession(d.id)?.closesAt ?? d.createdAt
}
/** Filtered and sorted decisions. */
const filteredDecisions = computed(() => {
let list = [...decisions.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const statuses = statusGroupMap[activeStatus.value]
list = list.filter(d => statuses.includes(d.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
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',
const filtered = computed(() => {
const needle = fold(query.value.trim())
return col.decisions.filter((d) => {
if (statusFilter.value && shownStatus(d) !== statusFilter.value) return false
if (routeFilter.value && d.route !== routeFilter.value) return false
if (filterCircle.value && !d.scope.circleIds.includes(filterCircle.value)) return false
if (filterTag.value && !d.tags.includes(filterTag.value)) return false
if (filterMine.value && !concernsMe(d)) return false
if (filterWeight.value && d.weight !== filterWeight.value) return false
if (filterEngraved.value && !d.engraving) return false
if (filterRecorded.value && d.route !== 'record') return false
if (needle.length > 0 && !fold(d.title).includes(needle)
&& !d.tags.some(t => fold(t).includes(needle))) return false
return true
})
})
const sorted = computed(() => {
const active = filtered.value
.filter(d => ACTIVE.includes(shownStatus(d)))
.sort((a, b) => deadlineOf(a).localeCompare(deadlineOf(b)))
const settled = filtered.value
.filter(d => !ACTIVE.includes(shownStatus(d)))
.sort((a, b) => (b.decidedAt ?? b.updatedAt).localeCompare(a.decidedAt ?? a.updatedAt))
return [...active, ...settled]
})
const hasAny = computed(() => col.decisions.length > 0)
function toggleStatus(status: DisplayStatus) {
statusFilter.value = statusFilter.value === status ? null : status
}
function toggleRoute(route: DecisionRoute) {
routeFilter.value = routeFilter.value === route ? null : route
}
</script>
<template>
<SectionLayout
title="Décisions"
subtitle="Processus de décision collectifs"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher une décision..."
/>
<!-- ld-v2 -->
<div class="reg">
<header class="reg__header">
<div>
<h1 class="reg__title">Décisions</h1>
<p class="reg__subtitle">le registre chaque décision a son URL, pour toujours</p>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<NuxtLink
v-if="auth.isAuthenticated"
to="/decisions/new"
class="action-btn"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouvelle</span>
<NuxtLink to="/decisions/observatoire" class="reg__observatory">
<UIcon name="i-lucide-telescope" />
<span>L'Observatoire</span>
</NuxtLink>
</template>
</header>
<!-- Main content: decision list -->
<template #default>
<!-- Error state -->
<div v-if="decisions.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ decisions.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="decisions.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDecisions.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par état">
<button
v-for="pill in statusPills"
:key="pill.status"
type="button"
class="status-pill is-clickable"
:class="[`status-${pill.status}`, { active: statusFilter === pill.status }]"
@click="toggleStatus(pill.status)"
>
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
<p>Aucune décision trouvée</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<span>{{ pill.label }}</span>
<span class="reg__count">{{ pill.count }}</span>
</button>
</div>
<!-- Decision cards -->
<div v-else class="space-y-3">
<div
v-for="decision in filteredDecisions"
:key="decision.id"
class="decision-card"
@click="navigateTo(`/decisions/${decision.id}`)"
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par chemin">
<button
v-for="pill in routePills"
:key="pill.route"
type="button"
class="reg__route-pill"
:class="{ 'reg__route-pill--on': routeFilter === pill.route }"
@click="toggleRoute(pill.route)"
>
{{ ROUTE_SHORT[pill.route] }}
</button>
</div>
<div v-if="hasAny" class="reg__tools">
<div class="reg__search">
<UIcon name="i-lucide-search" class="reg__search-icon" />
<input
v-model="query"
type="search"
placeholder="Chercher une décision…"
aria-label="Chercher une décision"
>
<div class="decision-card__header">
<div class="decision-card__title-block">
<h3 class="decision-card__title">
{{ decision.title }}
</h3>
<p v-if="decision.description" class="decision-card__description">
{{ decision.description }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<div class="decision-card__meta">
<span class="decision-card__type-badge">
{{ typeLabel(decision.decision_type) }}
</span>
<span
v-if="decision.decision_type === 'runtime_upgrade'"
class="decision-card__onchain-badge"
>
<UIcon name="i-lucide-link" class="text-xs" />
on-chain
</span>
<span class="decision-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ decision.steps.length }} étape{{ decision.steps.length !== 1 ? 's' : '' }}
</span>
<span class="decision-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(decision.created_at) }}
</span>
</div>
<!-- Protocol link for runtime_upgrade -->
<NuxtLink
v-if="decision.decision_type === 'runtime_upgrade'"
to="/protocols"
class="decision-card__protocol-link"
@click.stop
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
<span>Protocole : Soumission Runtime Upgrade</span>
<UIcon name="i-lucide-arrow-right" class="text-xs" />
</NuxtLink>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Context mapper -->
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
<ContextMapper @use="handleMethodSelect" />
</ToolboxSection>
<!-- Vote inertiel WoT -->
<ToolboxVignette
title="Vote inertiel WoT"
:bullets="[
'Seuil adaptatif à la participation',
'Faible participation → quasi-unanimité',
'Formule g1vote — tracé on-chain',
]"
:actions="[
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
]"
<DecisionRegistryFilters
v-model:circle-id="filterCircle"
v-model:tag="filterTag"
v-model:mine="filterMine"
v-model:weight="filterWeight"
v-model:engraved="filterEngraved"
v-model:recorded="filterRecorded"
/>
</div>
<!-- Consentement sociocratique -->
<ToolboxVignette
title="Consentement sociocratique"
:bullets="[
'Aucune objection grave = adopté',
'Rapide pour petits groupes',
'Distingue préférence et objection',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
]"
/>
<div v-if="sorted.length > 0" class="reg__list">
<DecisionRegistryCard v-for="d in sorted" :key="d.id" :decision="d" />
</div>
<!-- Advice process -->
<ToolboxVignette
title="Processus d'avis (Laloux)"
:bullets="[
'Décisions urgentes : < 2h',
'Consultant experts + impactés',
'Responsabilise le porteur',
]"
:actions="[
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- Modal consent guide -->
<UModal v-model:open="showConsentModal">
<template #content>
<div class="decision-modal">
<h3 class="decision-modal__title">Consentement sociocratique</h3>
<p class="decision-modal__text">
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune
pas une simple préférence.
<div v-else class="ld-card reg__empty">
<template v-if="!hasAny">
<span class="reg__empty-well">井</span>
<p class="reg__empty-title">Aucune décision pour l'instant.</p>
<p class="reg__empty-line">
Rien ne se crée ici : tout part de la capture
« {{ CAPTURE_PLACEHOLDER }} » sur Aujourd'hui.
</p>
<div class="decision-modal__steps">
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
<div class="decision-modal__step-num">{{ i + 1 }}</div>
<div class="decision-modal__step-text">{{ step }}</div>
</div>
</div>
<p class="decision-modal__ref">Référence : "La Sociocracie" Gerard Endenburg, Brian Robertson (Holacracy)</p>
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
</div>
</template>
</UModal>
<NuxtLink to="/" class="ld-btn ld-btn--ghost">
<UIcon name="i-lucide-sun-medium" />
<span>Aller à Aujourd'hui</span>
</NuxtLink>
</template>
<template v-else>
<p class="reg__empty-title">Rien ne correspond à ces filtres.</p>
<p class="reg__empty-line">Élargis la recherche ou décide par la capture, sur Aujourd'hui.</p>
</template>
</div>
</div>
</template>
<style scoped>
.decision-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.decision-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.decision-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.decision-card:active {
transform: translateY(0);
}
.decision-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.decision-card__title-block {
flex: 1;
min-width: 0;
}
.decision-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.decision-card__title {
font-size: 1.0625rem;
}
}
.decision-card__description {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.decision-card__description {
font-size: 0.875rem;
}
}
.decision-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.decision-card__meta {
gap: 0.5rem;
}
}
.decision-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.decision-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.decision-card__date {
font-size: 0.8125rem;
}
}
.decision-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.decision-card__onchain-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.decision-card__protocol-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 10%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
transition: transform 0.12s ease, box-shadow 0.12s ease;
width: fit-content;
}
.decision-card__protocol-link:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
/* Decision modal */
.decision-modal {
padding: 1.25rem;
.reg {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
@media (min-width: 640px) {
.decision-modal { padding: 2rem; gap: 1.25rem; }
.reg__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.decision-modal__title {
font-size: 1.125rem;
.reg__title {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
color: var(--mood-text);
margin: 0;
letter-spacing: -0.02em;
}
.decision-modal__text {
font-size: 0.875rem;
.reg__subtitle {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.reg__observatory {
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;
text-decoration: none;
transition: transform 0.1s ease;
}
.reg__observatory:hover { transform: translateY(-1px); }
.reg__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reg__count {
font-size: 0.75rem;
opacity: 0.75;
}
.reg__route-pill {
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__route-pill:hover { transform: translateY(-1px); color: var(--mood-text); }
.reg__route-pill--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.decision-modal__steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.decision-modal__step {
.reg__tools {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.decision-modal__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
.reg__search {
position: relative;
flex: 1;
min-width: 14rem;
}
.decision-modal__step-text {
font-size: 0.875rem;
color: var(--mood-text);
padding-top: 0.125rem;
line-height: 1.5;
.reg__search input {
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem 0.5rem 2.5rem;
font-size: 0.9375rem;
box-shadow: var(--shadow-card);
}
.decision-modal__ref {
font-size: 0.75rem;
.reg__search-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--mood-text-muted);
font-style: italic;
margin: 0;
pointer-events: none;
}
.decision-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
align-self: flex-end;
transition: transform 0.1s ease;
.reg__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reg__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
text-align: center;
}
.reg__empty-well {
font-size: 2rem;
font-weight: 800;
color: var(--mood-accent);
opacity: 0.5;
transform: rotate(-10deg);
}
.reg__empty-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.reg__empty-line {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
max-width: 28rem;
}
.decision-modal__close:hover { transform: translateY(-1px); }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
<script setup lang="ts">
// <!-- ld-v2 --> /decisions/observatoire L'Observatoire (Δ12) : comment nous
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
// faits comptés, des barres sobres aux couleurs du mood jamais un score.
import type { DecisionRoute, Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
} from '~/lexicon'
import { formatDay } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const decisionsStore = useDecisionsStore()
const reviewLabels = REVIEW_VERDICTS
// Filtres : personne / cercle / tag / période
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
const since = computed(() =>
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
)
const filtered = computed(() => col.decisions.filter(d =>
(fPerson.value === '' || d.authorId === fPerson.value)
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
&& (fTag.value === '' || d.tags.includes(fTag.value))
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
))
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
// Autonomie : ratio par route, maturité, temps médians, participation
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'advice', 'collective', 'record']
const routeBars = computed(() => {
const total = Math.max(1, engaged.value.length)
return ROUTES.map((r) => {
const n = engaged.value.filter(d => d.route === r).length
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
})
})
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
function median(xs: number[]): number {
if (xs.length === 0) return 0
const s = [...xs].sort((a, b) => a - b)
const mid = Math.floor(s.length / 2)
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
}
const medianBars = computed(() => ROUTES.map((r) => {
const days = filtered.value
.filter(d => d.route === r && d.decidedAt !== undefined)
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
const m = median(days)
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
}))
const participation = computed(() => {
const ids = new Set(filtered.value.map(d => d.id))
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
})
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
// Ressources PAR UNITÉ l'huile et l'eau, jamais additionnées
const resourceLines = computed(() => {
const byUnit = new Map<string, { total: number; count: number }>()
for (const d of engaged.value) {
if (!d.resources?.amount) continue
const unit = d.resources.unit ?? 'sans unité'
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
line.total += d.resources.amount
line.count += 1
byUnit.set(unit, line)
}
return [...byUnit.entries()].map(([unit, l]) => ({
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
}))
})
// Épreuves du réel
const now = new Date().toISOString()
const reviewsDue = computed(() => filtered.value
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
filtered.value.filter(d => d.review?.verdict === v).length
// Révoquées, consignations, maturation
const revoked = computed(() => filtered.value
.filter(d => d.status === 'revoked')
.map(d => ({
decision: d,
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
})))
const records = computed(() => filtered.value
.filter(d => d.route === 'record')
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
const maturations = computed(() => {
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
for (const r of records.value) {
const tags = [...new Set(r.tags)].sort()
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
const key = `${tags[i]}|${tags[j]}`
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
entry.ids.add(r.id)
pairs.set(key, entry)
}
}
}
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
.sort((a, b) => b.ids.size - a.ids.size)
const seen = new Set<string>()
return ripe.filter((p) => {
const sig = [...p.ids].sort().join(',')
if (seen.has(sig)) return false
seen.add(sig)
return true
}).slice(0, 4)
})
// Élagage & décisions jamais revues
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
const unusedProtocols = computed(() => col.protocols.filter(p =>
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
const idleMandates = computed(() => col.mandates.filter(m =>
m.status === 'active' && m.startsAt <= sixMonthsAgo
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
const neverReviewed = computed(() => {
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
return filtered.value.filter(d =>
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
&& d.decidedAt !== undefined && d.decidedAt <= limit)
})
</script>
<template>
<!-- ld-v2 -->
<div class="obs">
<header class="obs__header">
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
</header>
<!-- Filtres -->
<div class="obs__filters">
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
<option value="">Toutes les personnes</option>
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
</select>
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
<option value="">Tous les cercles</option>
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
<option value="">Tous les tags</option>
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
</select>
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
<option value="">Depuis toujours</option>
<option value="30">30 derniers jours</option>
<option value="90">90 derniers jours</option>
<option value="365">Cette année</option>
</select>
</div>
<!-- Autonomie -->
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
<ObservatoryBars :items="routeBars" />
<div class="obs__tiles">
<ObservatoryStat
label="Maturité"
:value="`${recordedCount} / ${tooledCount}`"
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
/>
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
</div>
<h3 class="obs__h3">Temps médians par route</h3>
<ObservatoryBars :items="medianBars" />
<h3 class="obs__h3">Dérogations au chemin</h3>
<div class="obs__tiles">
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
</div>
</ObservatorySection>
<!-- Ressources par unité -->
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
<ul v-if="resourceLines.length" class="obs__list">
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
</li>
</ul>
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
</ObservatorySection>
<!-- Épreuves du réel -->
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
<div class="obs__tiles">
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
</div>
<ul v-if="reviewsDue.length" class="obs__list">
<li v-for="r in reviewsDue" :key="r.id">
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
<span class="obs__row-title">{{ r.title }}</span>
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
</NuxtLink>
</li>
</ul>
</ObservatorySection>
<!-- Révoquées ce qu'on en a appris -->
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
<ul v-if="revoked.length" class="obs__list">
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
<span class="obs__row-title">{{ entry.decision.title }}</span>
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
</NuxtLink>
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
<UIcon name="i-lucide-link" />
la décision qui l'a révoquée
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant aucune révoquée sur la période.</p>
</ObservatorySection>
<!-- Consignations -->
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs — consigné ici, tel quel">
<ul v-if="records.length" class="obs__list">
<li v-for="d in records" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
<span class="obs__row-title">{{ d.title }}</span>
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
<p class="obs__card-title">
<UIcon name="i-lucide-sprout" />
{{ MATURATION_CARD }}
</p>
<p class="obs__muted">
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
</p>
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
Protocoliser
</NuxtLink>
</div>
</ObservatorySection>
<!-- Élagage & relances -->
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue — pas un enterrement silencieux">
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
</div>
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
<UIcon name="i-lucide-key-round" />
voir la fiche une revue s'impose peut-être
</NuxtLink>
</div>
<template v-if="neverReviewed.length">
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
<ul class="obs__list">
<li v-for="d in neverReviewed" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
<span class="obs__row-title">{{ d.title }}</span>
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
</NuxtLink>
</li>
</ul>
</template>
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
Rien à élaguer tout ce qui existe sert encore.
</p>
</ObservatorySection>
</div>
</template>
<style scoped>
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.obs__select {
flex: 1 1 10rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-input-bg);
border: none;
border-radius: var(--r-input);
box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.obs__h3 {
margin: 0.375rem 0 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.obs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
flex-wrap: wrap;
}
.obs__row:hover { transform: translateY(-1px); }
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
.obs__learned {
margin: 0;
padding-left: 0.75rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.obs__chain {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
padding-left: 0.75rem;
}
.obs__chain:hover { text-decoration: underline; }
.obs__card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
}
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
.obs__card-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
</style>
-814
View File
@@ -1,814 +0,0 @@
<script setup lang="ts">
/**
* Document detail page full structured view with:
* - Genesis block (source files, repos, forum synthesis, formula trigger)
* - Sectioned items grouped by section_tag
* - Mini vote boards per item
* - Inertia sliders per section
* - Permanent vote signage
* - Tuto overlay
*/
import type { DocumentItem, ItemVersion } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const archiving = ref(false)
onMounted(async () => {
await documents.fetchBySlug(slug.value)
})
onUnmounted(() => {
documents.clearCurrent()
})
watch(slug, async (newSlug) => {
if (newSlug) {
await documents.fetchBySlug(newSlug)
}
})
// Section grouping
interface Section {
tag: string
label: string
icon: string
inertiaPreset: string
items: DocumentItem[]
}
const SECTION_META: Record<string, { label: string; icon: string }> = {
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
}
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
const sections = computed((): Section[] => {
const grouped: Record<string, DocumentItem[]> = {}
const ungrouped: DocumentItem[] = []
for (const item of documents.items) {
const tag = item.section_tag
if (tag) {
if (!grouped[tag]) grouped[tag] = []
grouped[tag].push(item)
} else {
ungrouped.push(item)
}
}
const result: Section[] = []
for (const tag of SECTION_ORDER) {
if (grouped[tag]) {
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
const firstItem = grouped[tag][0]
result.push({
tag,
label: meta.label,
icon: meta.icon,
inertiaPreset: firstItem?.inertia_preset || 'standard',
items: grouped[tag],
})
}
}
// Ungrouped items
if (ungrouped.length > 0) {
result.push({
tag: '_other',
label: 'Autres',
icon: 'i-lucide-file-text',
inertiaPreset: 'standard',
items: ungrouped,
})
}
return result
})
const totalItems = computed(() => documents.items.length)
// Helpers
const typeLabel = (docType: string) => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
function handlePropose(item: DocumentItem) {
navigateTo(`/documents/${slug.value}/items/${item.id}`)
}
async function archiveToSanctuary() {
archiving.value = true
try {
await documents.archiveDocument(slug.value)
} catch {
// Error handled in store
} finally {
archiving.value = false
}
}
// View mode (editorial vs preview)
type ViewMode = 'editorial' | 'preview'
type PreviewMode = 'current' | 'projected'
const viewMode = ref<ViewMode>('editorial')
const previewMode = ref<PreviewMode>('current')
const versionsLoaded = ref(false)
async function activatePreview() {
viewMode.value = 'preview'
if (!versionsLoaded.value && documents.items.length > 0) {
const itemIds = documents.items.map(i => i.id)
await documents.fetchAllItemVersions(slug.value, itemIds)
versionsLoaded.value = true
}
}
/** Map item_id → the active version under vote (or null). */
const activeVersionByItem = computed((): Record<string, ItemVersion | null> => {
const map: Record<string, ItemVersion | null> = {}
for (const item of documents.items) {
const versions = documents.allItemVersions[item.id] || []
map[item.id] = versions.find(v => v.status === 'vote')
|| versions.find(v => v.status === 'proposed')
|| null
}
return map
})
const hasProjectedChanges = computed(() =>
Object.values(activeVersionByItem.value).some(v => v !== null),
)
// Active section (scroll spy)
const activeSection = ref<string | null>(null)
function scrollToSection(tag: string) {
// Expand the section if collapsed
if (collapsedSections.value[tag]) {
collapsedSections.value[tag] = false
}
nextTick(() => {
const el = document.getElementById(`section-${tag}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
}
})
}
// Collapsible sections
// First 2 sections open by default, rest collapsed
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (newSections) => {
if (newSections.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
newSections.forEach((s, i) => {
map[s.tag] = i >= 2 // collapsed if index >= 2
})
collapsedSections.value = map
}
}, { immediate: true })
function toggleSection(tag: string) {
collapsedSections.value[tag] = !collapsedSections.value[tag]
}
</script>
<template>
<div class="doc-page">
<!-- Back link -->
<div class="doc-page__nav">
<UButton
to="/documents"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux documents"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="documents.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
</div>
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
</template>
<!-- Document detail -->
<template v-else-if="documents.current">
<!-- HEADER -->
<div class="doc-page__header">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="doc-page__title">
{{ documents.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2 flex-wrap">
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
v{{ documents.current.version }}
</span>
<span class="text-sm" style="color: var(--mood-text-muted)">
{{ totalItems }} items
</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<DocumentTuto />
<UButton
v-if="auth.isAuthenticated && documents.current.status === 'active'"
label="Archiver"
icon="i-lucide-archive"
color="primary"
variant="soft"
size="sm"
:loading="archiving"
@click="archiveToSanctuary"
/>
</div>
</div>
<!-- Description -->
<p v-if="documents.current.description" class="doc-page__desc">
{{ documents.current.description }}
</p>
</div>
<!-- METADATA -->
<div class="doc-page__meta">
<div class="doc-page__meta-grid">
<div>
<p class="doc-page__meta-label">Créé le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Mis à jour le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Ancrage IPFS</p>
<div class="mt-1">
<IPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
<div v-if="documents.current.chain_anchor">
<p class="doc-page__meta-label">Ancrage on-chain</p>
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</div>
<!-- GENESIS BLOCK -->
<GenesisBlock
v-if="documents.current.genesis_json"
:genesis-json="documents.current.genesis_json"
/>
<!-- VIEW MODE TOGGLE -->
<div class="doc-page__view-toggle">
<div class="doc-page__view-tabs">
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'editorial' }"
@click="viewMode = 'editorial'"
>
<UIcon name="i-lucide-layout-list" class="text-sm" />
Vue structurée
</button>
<button
class="doc-page__view-tab"
:class="{ 'doc-page__view-tab--active': viewMode === 'preview' }"
@click="activatePreview"
>
<UIcon name="i-lucide-file-text" class="text-sm" />
Aperçu document
<span v-if="documents.loadingVersions" class="doc-page__view-loading">
<UIcon name="i-lucide-loader-circle" class="text-xs animate-spin" />
</span>
</button>
</div>
<!-- Preview sub-mode (shown only in preview mode) -->
<Transition name="fade">
<div v-if="viewMode === 'preview'" class="doc-page__preview-modes">
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'current' }"
@click="previewMode = 'current'"
>
<UIcon name="i-lucide-circle-check" class="text-xs" />
En vigueur
</button>
<button
class="doc-page__preview-mode"
:class="{ 'doc-page__preview-mode--active': previewMode === 'projected' }"
:disabled="!hasProjectedChanges"
:title="!hasProjectedChanges ? 'Aucun vote en cours sur ce document' : 'Simuler les votes en cours'"
@click="previewMode = 'projected'"
>
<UIcon name="i-lucide-flask-conical" class="text-xs" />
Selon les votes
<span v-if="hasProjectedChanges" class="doc-page__preview-dot" />
</button>
</div>
</Transition>
</div>
<!-- DOCUMENT PREVIEW -->
<Transition name="fade">
<DocumentPreview
v-if="viewMode === 'preview'"
:document="documents.current"
:items="documents.items"
:mode="previewMode"
:version-map="activeVersionByItem"
/>
</Transition>
<!-- SECTION NAVIGATOR -->
<div v-if="sections.length > 1 && viewMode === 'editorial'" class="doc-page__section-nav">
<button
v-for="section in sections"
:key="section.tag"
class="doc-page__section-pill"
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" class="text-xs" />
{{ section.label }}
<span class="doc-page__section-count">{{ section.items.length }}</span>
</button>
</div>
<!-- SECTIONS WITH ITEMS -->
<div v-if="viewMode === 'editorial'" class="doc-page__sections">
<div
v-for="section in sections"
:key="section.tag"
:id="`section-${section.tag}`"
class="doc-page__section"
>
<!-- Section header (clickable toggle) -->
<button
class="doc-page__section-header"
@click="toggleSection(section.tag)"
>
<div class="flex items-center gap-2">
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
<h2 class="doc-page__section-title">
{{ section.label }}
</h2>
<UBadge variant="subtle" color="neutral" size="xs">
{{ section.items.length }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<InertiaSlider :preset="section.inertiaPreset" compact mini />
<UIcon
name="i-lucide-chevron-down"
class="doc-page__section-chevron"
:class="{ 'doc-page__section-chevron--open': !collapsedSections[section.tag] }"
/>
</div>
</button>
<!-- Protocol link for qualification section -->
<NuxtLink
v-if="section.tag === 'qualification' && !collapsedSections[section.tag]"
to="/protocols"
class="doc-page__protocol-link"
>
<UIcon name="i-lucide-git-branch" class="text-sm" />
<div>
<span class="doc-page__protocol-link-label">Protocole lié</span>
<span class="doc-page__protocol-link-name">Embarquement Forgeron</span>
</div>
<UIcon name="i-lucide-arrow-right" class="text-sm doc-page__protocol-link-arrow" />
</NuxtLink>
<!-- Items (collapsible) -->
<Transition name="section-collapse">
<div v-show="!collapsedSections[section.tag]" class="doc-page__section-items">
<EngagementCard
v-for="item in section.items"
:key="item.id"
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
</div>
</Transition>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__nav {
margin-bottom: -0.5rem;
}
/* Header */
.doc-page__header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.doc-page__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
line-height: 1.2;
}
@media (min-width: 640px) {
.doc-page__title {
font-size: 1.875rem;
}
}
.doc-page__desc {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin-top: 0.25rem;
}
/* Metadata */
.doc-page__meta {
padding: 1rem 1.25rem;
background: var(--mood-surface);
border-radius: 14px;
}
.doc-page__meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.doc-page__meta-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.doc-page__meta-label {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.doc-page__meta-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
/* Section navigator */
.doc-page__section-nav {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.doc-page__section-nav::-webkit-scrollbar {
display: none;
}
.doc-page__section-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
background: var(--mood-surface);
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.15s;
border: none;
}
.doc-page__section-pill:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
color: var(--mood-text);
}
.doc-page__section-pill--active {
background: var(--mood-accent);
color: white;
}
.doc-page__section-count {
font-size: 0.625rem;
font-weight: 800;
opacity: 0.7;
}
/* Sections */
.doc-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.doc-page__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
scroll-margin-top: 4rem;
}
.doc-page__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
width: 100%;
background: none;
cursor: pointer;
user-select: none;
transition: opacity 0.15s;
}
.doc-page__section-header:hover {
opacity: 0.85;
}
.doc-page__section-chevron {
font-size: 1rem;
color: var(--mood-text-muted);
transform: rotate(-90deg);
transition: transform 0.25s ease;
flex-shrink: 0;
}
.doc-page__section-chevron--open {
transform: rotate(0deg);
}
.doc-page__section-title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.doc-page__section-title {
font-size: 1.125rem;
}
}
.doc-page__section-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Protocol link */
.doc-page__protocol-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
border-radius: 14px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
color: var(--mood-tertiary, var(--mood-accent));
}
.doc-page__protocol-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.doc-page__protocol-link-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.doc-page__protocol-link-name {
display: block;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.doc-page__protocol-link-arrow {
margin-left: auto;
opacity: 0.3;
transition: opacity 0.12s;
}
.doc-page__protocol-link:hover .doc-page__protocol-link-arrow {
opacity: 1;
}
/* View mode toggle */
.doc-page__view-toggle {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.doc-page__view-tabs {
display: flex;
gap: 4px;
background: var(--mood-surface);
padding: 4px;
border-radius: 14px;
align-self: flex-start;
}
.doc-page__view-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.doc-page__view-tab:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.doc-page__view-tab--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.doc-page__view-loading {
display: inline-flex;
align-items: center;
margin-left: 2px;
}
.doc-page__preview-modes {
display: flex;
gap: 4px;
align-self: flex-start;
}
.doc-page__preview-mode {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-surface);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.doc-page__preview-mode:hover:not(:disabled) {
color: var(--mood-text);
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.doc-page__preview-mode:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.doc-page__preview-mode--active {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-mode--active:hover:not(:disabled) {
background: var(--mood-accent);
color: white;
}
.doc-page__preview-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mood-warning, #f59e0b);
position: absolute;
top: 4px;
right: 4px;
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Section collapse transition */
.section-collapse-enter-active,
.section-collapse-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.section-collapse-enter-from,
.section-collapse-leave-to {
opacity: 0;
max-height: 0;
}
.section-collapse-enter-to,
.section-collapse-leave-from {
opacity: 1;
}
</style>
@@ -1,328 +0,0 @@
<script setup lang="ts">
import type { DocumentItem, VersionProposal } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const itemId = computed(() => route.params.itemId as string)
const currentItem = computed((): DocumentItem | undefined => {
return documents.items.find(i => i.id === itemId.value)
})
// Modal state for proposing a modification
const showProposeModal = ref(false)
const proposedText = ref('')
const rationale = ref('')
const proposing = ref(false)
// Loading versions
const versionsLoading = ref(false)
onMounted(async () => {
// Fetch document + items if not already loaded
if (!documents.current || documents.current.slug !== slug.value) {
await documents.fetchBySlug(slug.value)
}
// Fetch versions for this item
versionsLoading.value = true
await documents.fetchItemVersions(slug.value, itemId.value)
versionsLoading.value = false
})
onUnmounted(() => {
documents.versions = []
})
watch([slug, itemId], async ([newSlug, newItemId]) => {
if (newSlug && newItemId) {
if (!documents.current || documents.current.slug !== newSlug) {
await documents.fetchBySlug(newSlug)
}
versionsLoading.value = true
await documents.fetchItemVersions(newSlug, newItemId)
versionsLoading.value = false
}
})
function openProposeModal() {
if (currentItem.value) {
proposedText.value = currentItem.value.current_text
}
rationale.value = ''
showProposeModal.value = true
}
async function submitProposal() {
proposing.value = true
try {
const data: VersionProposal = {
proposed_text: proposedText.value,
rationale: rationale.value || null,
}
await documents.proposeVersion(slug.value, itemId.value, data)
showProposeModal.value = false
proposedText.value = ''
rationale.value = ''
} catch {
// Error handled in store
} finally {
proposing.value = false
}
}
async function handleAcceptVersion(versionId: string) {
try {
await documents.acceptVersion(slug.value, itemId.value, versionId)
// Refresh item data
await documents.fetchBySlug(slug.value)
} catch {
// Error handled in store
}
}
async function handleRejectVersion(versionId: string) {
try {
await documents.rejectVersion(slug.value, itemId.value, versionId)
} catch {
// Error handled in store
}
}
const itemTypeLabel = (itemType: string): string => {
switch (itemType) {
case 'clause': return 'Clause'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Section'
default: return itemType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div class="space-y-6">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-gray-500">
<NuxtLink to="/documents" class="hover:text-primary transition-colors">
Documents
</NuxtLink>
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<NuxtLink
v-if="documents.current"
:to="`/documents/${slug}`"
class="hover:text-primary transition-colors"
>
{{ documents.current.title }}
</NuxtLink>
<USkeleton v-else class="h-4 w-32" />
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<span v-if="currentItem" class="text-gray-900 dark:text-white font-medium">
Item {{ currentItem.position }}
</span>
<USkeleton v-else class="h-4 w-20" />
</nav>
<!-- Loading state -->
<template v-if="documents.loading && !currentItem">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error && !currentItem">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
</template>
<!-- Item not found -->
<template v-else-if="!currentItem && !documents.loading">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-file-x" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Item introuvable</p>
<UButton
:to="`/documents/${slug}`"
label="Retour au document"
variant="soft"
color="primary"
class="mt-4"
/>
</div>
</UCard>
</template>
<!-- Item detail -->
<template v-else-if="currentItem">
<!-- Item header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UBadge variant="solid" color="primary" size="sm">
{{ currentItem.position }}
</UBadge>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(currentItem.item_type) }}
</UBadge>
<UBadge
v-if="currentItem.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
</div>
<h1
v-if="currentItem.title"
class="text-2xl font-bold text-gray-900 dark:text-white"
>
{{ currentItem.title }}
</h1>
</div>
<!-- Action buttons -->
<div v-if="auth.isAuthenticated" class="flex items-center gap-2">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
color="primary"
variant="soft"
@click="openProposeModal"
/>
</div>
</div>
<!-- Current text -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-file-text" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
</div>
<MarkdownRenderer :content="currentItem.current_text" />
<div class="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400">
<span>Cree le {{ formatDate(currentItem.created_at) }}</span>
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
</div>
</div>
</UCard>
<!-- Error banner -->
<UCard v-if="documents.error">
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
<!-- Version history -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Historique des versions ({{ documents.versions.length }})
</h2>
<template v-if="versionsLoading">
<div class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-32 w-full" />
</div>
</template>
<template v-else-if="documents.versions.length === 0">
<UCard>
<div class="text-center py-6">
<UIcon name="i-lucide-git-branch" class="text-3xl text-gray-400 mb-2" />
<p class="text-gray-500 text-sm">Aucune version proposee pour cet item</p>
<p class="text-xs text-gray-400 mt-1">
Connectez-vous pour proposer une modification
</p>
</div>
</UCard>
</template>
<div v-else class="space-y-4">
<ItemVersionDiff
v-for="version in documents.versions"
:key="version.id"
:version="version"
@accept="handleAcceptVersion"
@reject="handleRejectVersion"
/>
</div>
</div>
</template>
<!-- Propose modification modal -->
<UModal v-model:open="showProposeModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Proposer une modification
</h3>
<p class="text-sm text-gray-500">
Modifiez le texte ci-dessous et fournissez une justification pour votre proposition.
</p>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Texte propose
</label>
<UTextarea
v-model="proposedText"
:rows="8"
placeholder="Saisissez le nouveau texte..."
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Justification
</label>
<UTextarea
v-model="rationale"
:rows="3"
placeholder="Expliquez les raisons de cette modification..."
class="w-full"
/>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showProposeModal = false"
/>
<UButton
label="Soumettre la proposition"
icon="i-lucide-send"
color="primary"
:loading="proposing"
:disabled="!proposedText.trim()"
@click="submitProposal"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
-690
View File
@@ -1,690 +0,0 @@
<script setup lang="ts">
/**
* Documents de reference page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
*/
import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const inertiaLevels = [
{
id: 'light',
name: 'Léger',
color: 'teal',
params: 'B=0.05, G=0.1',
desc: 'Modification facile. Majorité simple suffit avec bonne participation.',
example: 'Clarifications rédactionnelles, notes de bas de page.',
},
{
id: 'standard',
name: 'Standard',
color: 'accent',
params: 'B=0.1, G=0.2',
desc: 'Seuil adaptatif standard. La formule g1vote dans son paramétrage habituel.',
example: 'Articles de fond, engagements opérationnels.',
},
{
id: 'strong',
name: 'Fort',
color: 'secondary',
params: 'B=0.15, G=0.3',
desc: 'Forte résistance. Faible participation → quasi-unanimité requise.',
example: 'Principes fondateurs, formules de vote, critères WoT.',
},
{
id: 'very-strong',
name: 'Très fort',
color: 'error',
params: 'B=0.2, G=0.4',
desc: 'Protection maximale. Seule une forte mobilisation peut modifier.',
example: 'Clause de licence, identité du projet, droits des membres.',
},
]
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
// New document modal state
const showNewDocModal = ref(false)
const newDoc = ref<DocumentCreate>({
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
})
const creating = ref(false)
const newDocTypeOptions = [
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Règlement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
documents.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
])
/** Filtered and sorted documents. */
const filteredDocuments = computed(() => {
let list = [...documents.list]
// Filter by status
if (activeStatus.value) {
list = list.filter(d => d.status === activeStatus.value)
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
/** Toolbox vignettes from protocols. */
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Règlement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
watch(() => newDoc.value.title, (title) => {
if (title) {
newDoc.value.slug = generateSlug(title)
}
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
}
async function createDocument() {
creating.value = true
try {
const doc = await documents.createDocument(newDoc.value)
showNewDocModal.value = false
if (doc) {
navigateTo(`/documents/${doc.slug}`)
}
}
catch {
// Error handled in store
}
finally {
creating.value = false
}
}
</script>
<template>
<SectionLayout
title="Documents de référence"
subtitle="Textes fondateurs sous vote permanent de la communauté"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher un document..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button
v-if="auth.isAuthenticated"
class="action-btn"
@click="openNewDocModal"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</button>
</template>
<!-- Main content: document list -->
<template #default>
<!-- Error state -->
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ documents.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="documents.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDocuments.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
<p>Aucun document trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Document cards -->
<div v-else class="space-y-3">
<div
v-for="doc in filteredDocuments"
:key="doc.id"
class="doc-card"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="doc-card__header">
<h3 class="doc-card__title">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<div class="doc-card__meta">
<span class="doc-card__type-badge">
{{ typeLabel(doc.doc_type) }}
</span>
<span class="doc-card__version">v{{ doc.version }}</span>
<span class="doc-card__items">
<UIcon name="i-lucide-list" class="text-xs" />
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
</span>
<span class="doc-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(doc.updated_at) }}
</span>
</div>
<p v-if="doc.description" class="doc-card__description">
{{ doc.description }}
</p>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Inertia guide -->
<ToolboxSection title="Niveaux d'inertie" icon="i-lucide-sliders-horizontal">
<div class="inertia-guide">
<div v-for="level in inertiaLevels" :key="level.id" class="inertia-level">
<div class="inertia-level__header">
<span class="inertia-level__name" :class="`inertia-level__name--${level.color}`">
{{ level.name }}
</span>
<span class="inertia-level__params">{{ level.params }}</span>
</div>
<p class="inertia-level__desc">{{ level.desc }}</p>
<p class="inertia-level__example">{{ level.example }}</p>
</div>
</div>
<NuxtLink to="/protocols/formulas" class="toolbox-link-btn">
<UIcon name="i-lucide-calculator" />
Simuler les formules
</NuxtLink>
</ToolboxSection>
<!-- Structure document -->
<ToolboxVignette
title="Structure d'un document"
:bullets="[
'Items = clauses individuelles',
'Sections = groupes thématiques',
'Chaque clause : vote indépendant',
'Genesis block : traçabilité d\'origine',
]"
:actions="[
{ label: 'Nouveau doc', icon: 'i-lucide-file-plus', emit: 'new', primary: true },
]"
@action="e => e === 'new' && openNewDocModal()"
/>
<!-- Sanctuaire -->
<ToolboxVignette
title="Sanctuaire IPFS"
:bullets="[
'Document adopté → archivé on-chain',
'Hash IPFS + system.remark Duniter',
'Immuable, vérifiable, décentralisé',
]"
:actions="[
{ label: 'Sanctuaire', icon: 'i-lucide-archive', to: '/sanctuary', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- New document modal -->
<UModal v-model:open="showNewDocModal">
<template #content>
<div class="p-4 sm:p-6 space-y-4">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau document de référence
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Titre
</label>
<UInput
v-model="newDoc.title"
placeholder="Ex: Licence G1"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Slug (identifiant URL)
</label>
<UInput
v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Type de document
</label>
<USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Décrivez brièvement ce document..."
class="w-full"
/>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showNewDocModal = false"
/>
<UButton
label="Créer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
@click="createDocument"
/>
</div>
</div>
</template>
</UModal>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.doc-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.doc-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.doc-card:active {
transform: translateY(0);
}
.doc-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.doc-card__title {
font-size: 1.0625rem;
}
}
.doc-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.doc-card__meta {
gap: 0.5rem;
}
}
.doc-card__version {
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
}
.doc-card__items {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.doc-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.doc-card__date {
font-size: 0.8125rem;
}
}
.doc-card__description {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.doc-card__description {
font-size: 0.875rem;
}
}
/* Inertia guide */
.inertia-guide {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inertia-level {
background: var(--mood-surface);
border-radius: 10px;
padding: 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.inertia-level__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.inertia-level__name {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia-level__name--teal { color: var(--mood-success); }
.inertia-level__name--accent { color: var(--mood-accent); }
.inertia-level__name--secondary { color: var(--mood-secondary, var(--mood-accent)); }
.inertia-level__name--error { color: var(--mood-error); }
.inertia-level__params {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 1px 6px;
border-radius: 8px;
}
.inertia-level__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.inertia-level__example {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin: 0;
font-style: italic;
opacity: 0.8;
}
.toolbox-link-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
text-decoration: none;
cursor: pointer;
align-self: flex-start;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.toolbox-link-btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
.doc-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
/**
* /donnees local-first assumé : tes données, ton fichier, ta machine.
* Sections : collectifs locaux · import · jeux de démonstration · identité ·
* mode atelier · niveaux de preuve. Note synchro en pied.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import { PROOF_LOCAL, SYNC_NOTE, WORKSHOP_MODE } from '~/lexicon'
const store = useCollectiveStore()
// Jeux de démonstration désactivés si déjà présents
const seedBusy = ref<SeedName | null>(null)
const seedIssues = ref<string[]>([])
const hasSeed = (slug: string) => store.index.some(entry => entry.slug === slug)
async function loadSeed(name: SeedName) {
if (seedBusy.value) return
seedBusy.value = name
seedIssues.value = []
const result = await store.loadSeed(name)
seedIssues.value = result.issues.filter(i => i.level === 'error').map(i => i.message)
seedBusy.value = null
}
// Mode atelier flag local lu par les écrans de vote
const workshop = ref(false)
onMounted(() => {
workshop.value = localStorage.getItem('ld2-workshop') === '1'
})
function toggleWorkshop() {
workshop.value = !workshop.value
localStorage.setItem('ld2-workshop', workshop.value ? '1' : '0')
}
</script>
<template>
<!-- ld-v2 -->
<div class="donnees">
<header class="donnees__head">
<h1 class="donnees__title">Données locales</h1>
<p class="donnees__sub">Tes données, ton fichier, ta machine.</p>
</header>
<DataCollectivesCard />
<DataImportCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-sprout" /> Jeux de démonstration
</h2>
<p class="data-card__text">
Deux collectifs complets, importés par le même chemin qu'un fichier à toi.
</p>
<div class="seed-row">
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('duniter-g1') || seedBusy !== null"
@click="loadSeed('duniter-g1')"
>
<UIcon name="i-lucide-coins" />
{{ hasSeed('duniter-g1') ? 'Duniter Ğ1 — déjà là' : 'Explorer Duniter Ğ1' }}
</button>
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('atelier-du-canal') || seedBusy !== null"
@click="loadSeed('atelier-du-canal')"
>
<UIcon name="i-lucide-hammer" />
{{ hasSeed('atelier-du-canal') ? "L'Atelier du Canal — déjà là" : "Explorer l'Atelier du Canal" }}
</button>
</div>
<ul v-if="seedIssues.length" class="seed-issues">
<li v-for="msg in seedIssues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
</section>
<DataIdentityCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-users-round" />
<span class="cap-word">{{ WORKSHOP_MODE }}</span>
</h2>
<div class="workshop">
<p class="data-card__text">
En présence, une seule machine circule : chaque saisie garde qui a saisi,
pour qui, et la date de l'atelier.
</p>
<button
type="button"
class="switch"
:class="{ 'switch--on': workshop }"
role="switch"
:aria-checked="workshop"
:aria-label="WORKSHOP_MODE"
@click="toggleWorkshop"
>
<span class="switch__thumb" />
</button>
</div>
</section>
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-shield-check" /> Niveaux de preuve
</h2>
<ul class="proof-list">
<li class="proof-row">
<UIcon name="i-lucide-fingerprint" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name cap-word">{{ PROOF_LOCAL }}</span>
<span class="proof-row__desc">
Chaque gravure calcule l'empreinte du texte décidé, vérifiable sur ta machine.
</span>
</span>
<span class="status-pill status-adopted">disponible</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-globe" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">IPFS</span>
<span class="proof-row__desc">Une copie de l'empreinte répliquée entre pairs.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-link" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">Chaîne Duniter</span>
<span class="proof-row__desc">L'empreinte ancrée dans la chaîne, pour de bon.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
</ul>
</section>
<p class="donnees__sync">{{ SYNC_NOTE }}</p>
</div>
</template>
<style scoped>
.donnees {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
width: min(100%, 46rem);
margin-inline: auto;
padding: clamp(1rem, 3vw, 2rem) clamp(0.875rem, 3vw, 1.5rem) 4.5rem;
}
.donnees__head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.donnees__title {
margin: 0;
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--mood-text);
}
.donnees__sub {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--mood-text-muted);
}
.cap-word {
display: inline-block;
}
.cap-word::first-letter {
text-transform: uppercase;
}
.seed-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.seed-issues {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.875rem;
font-weight: 600;
}
.seed-issues li {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.workshop {
display: flex;
align-items: center;
gap: 1rem;
}
.switch {
position: relative;
width: 3.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
border-radius: var(--r-pill);
cursor: pointer;
background: var(--mood-input-border);
transition: background 0.15s ease;
}
.switch--on {
background: var(--mood-accent);
}
.switch__thumb {
position: absolute;
top: 50%;
left: 0.3rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
background: var(--mood-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transform: translateY(-50%);
transition: left 0.15s ease;
}
.switch--on .switch__thumb {
left: calc(100% - 1.45rem);
}
.proof-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.proof-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
border-radius: var(--r-input);
background: var(--mood-bg);
}
.proof-row--off {
opacity: 0.55;
}
.proof-row__icon {
font-size: 1.25rem;
flex-shrink: 0;
color: var(--mood-accent);
}
.proof-row--off .proof-row__icon {
color: var(--mood-text-muted);
}
.proof-row__main {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.proof-row__name {
font-weight: 700;
color: var(--mood-text);
}
.proof-row__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.donnees__sync {
margin: 0;
text-align: center;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
@media (max-width: 480px) {
.proof-row {
flex-wrap: wrap;
}
}
</style>
File diff suppressed because it is too large Load Diff
-565
View File
@@ -1,565 +0,0 @@
<script setup lang="ts">
const auth = useAuthStore()
const router = useRouter()
const { $api } = useApi()
const address = ref('')
const step = ref<'input' | 'challenge' | 'signing' | 'success'>('input')
const errorMessage = ref('')
// Dev profiles
interface DevProfile {
address: string
display_name: string
wot_status: string
is_smith: boolean
is_techcomm: boolean
}
const devProfiles = ref<DevProfile[]>([])
const devLoading = ref(false)
async function loadDevProfiles() {
try {
devProfiles.value = await $api<DevProfile[]>('/auth/dev/profiles')
} catch {
// Not in dev mode or endpoint unavailable
}
}
function statusLabel(p: DevProfile): string {
const parts: string[] = []
parts.push(p.wot_status === 'member' ? 'Membre WoT' : 'Observateur')
if (p.is_smith) parts.push('Forgeron')
if (p.is_techcomm) parts.push('ComTech')
return parts.join(' · ')
}
function statusColor(p: DevProfile): string {
if (p.is_techcomm) return 'var(--mood-info, #3b82f6)'
if (p.is_smith) return 'var(--mood-warning, #f59e0b)'
if (p.wot_status === 'member') return 'var(--mood-success, #22c55e)'
return 'var(--mood-text-muted, #888)'
}
async function loginAsProfile(p: DevProfile) {
devLoading.value = true
address.value = p.address
errorMessage.value = ''
step.value = 'challenge'
try {
step.value = 'signing'
// Dev mode: bypass extension backend accepte toute signature pour les profils dev
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
step.value = 'success'
setTimeout(() => router.push('/'), 800)
} catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur connexion dev'
step.value = 'input'
} finally {
devLoading.value = false
}
}
async function handleLogin() {
if (!address.value.trim()) {
errorMessage.value = 'Veuillez entrer votre adresse Duniter'
return
}
errorMessage.value = ''
step.value = 'challenge'
try {
step.value = 'signing'
await auth.login(address.value.trim())
step.value = 'success'
setTimeout(() => {
router.push('/')
}, 800)
} catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur lors de la connexion'
step.value = 'input'
}
}
const steps = computed(() => [
{ label: 'Adresse', done: step.value !== 'input' },
{ label: 'Challenge', done: step.value === 'signing' || step.value === 'success' },
{ label: 'Signature', done: step.value === 'success' },
{ label: 'OK', done: false },
])
const activeStepIndex = computed(() => {
switch (step.value) {
case 'input': return 0
case 'challenge': return 1
case 'signing': return 2
case 'success': return 3
default: return 0
}
})
const isProtoMode = computed(() => devProfiles.value.length > 0)
onMounted(() => {
if (auth.isAuthenticated) {
router.push('/')
}
loadDevProfiles()
})
</script>
<template>
<div class="login-page">
<div class="login-card">
<!-- Logo -->
<div class="login-card__header">
<div class="login-card__logo">
<UIcon name="i-lucide-gavel" class="login-card__logo-icon" />
</div>
<h1 class="login-card__title">Connexion</h1>
<p class="login-card__subtitle">
Duniter V2 · Ed25519
</p>
</div>
<!-- Steps indicator -->
<div class="login-steps">
<div
v-for="(s, i) in steps"
:key="i"
class="login-step"
:class="{
'login-step--done': s.done,
'login-step--active': i === activeStepIndex,
}"
>
<div class="login-step__dot">
<UIcon v-if="s.done" name="i-lucide-check" />
<span v-else class="login-step__num">{{ i + 1 }}</span>
</div>
<span class="login-step__label">{{ s.label }}</span>
</div>
</div>
<!-- Input -->
<div class="login-card__field">
<label class="login-card__label">Adresse Duniter (SS58)</label>
<input
v-model="address"
type="text"
class="login-card__input"
placeholder="5GrwvaEF5zXb26Fz9rcQpDWS57Ct..."
:disabled="auth.loading || step !== 'input'"
@keydown.enter="handleLogin"
/>
</div>
<!-- Error -->
<div v-if="errorMessage || auth.error" class="login-card__error">
<UIcon name="i-lucide-alert-circle" />
<span>{{ errorMessage || auth.error }}</span>
</div>
<!-- Success -->
<div v-if="step === 'success'" class="login-card__success">
<UIcon name="i-lucide-check-circle" />
<span>Connecte. Redirection...</span>
</div>
<!-- Mode prototype : profils démo -->
<template v-if="isProtoMode">
<div class="proto-panel">
<div class="proto-panel__header">
<UIcon name="i-lucide-flask-conical" />
<span>Mode prototype sélectionnez un profil</span>
</div>
<div class="proto-panel__profiles">
<button
v-for="p in devProfiles"
:key="p.address"
class="dev-profile"
:disabled="devLoading || step === 'success'"
@click="loginAsProfile(p)"
>
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
<div class="dev-profile__info">
<span class="dev-profile__name">{{ p.display_name }}</span>
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
</div>
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button>
</div>
<p class="proto-panel__note">
Authentification trustWallet à venir intégration librodrome
</p>
</div>
</template>
<!-- Mode production : formulaire + extension -->
<template v-else>
<button
class="login-card__btn"
:disabled="!address.trim() || step === 'success' || auth.loading"
@click="handleLogin"
>
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-log-in" />
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique.
</p>
</template>
</div>
</div>
</template>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
.login-page {
min-height: 70vh;
padding: 2rem 1rem;
}
}
.login-card {
width: 100%;
max-width: 26rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
@media (min-width: 640px) {
.login-card {
gap: 1.5rem;
}
}
.login-card__header {
text-align: center;
}
.login-card__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
font-size: 1.75rem;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 14px;
margin-bottom: 1rem;
transform: rotate(-10deg);
}
.login-card__logo-icon {
transform: scaleX(-1);
}
.login-card__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin-top: 0.375rem;
}
@media (min-width: 640px) {
.login-card__subtitle {
font-size: 0.9375rem;
}
}
.login-card__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
@media (min-width: 640px) {
.login-card__title {
font-size: 1.75rem;
}
}
/* Steps */
.login-steps {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding: 0.75rem 0;
}
.login-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
flex: 1;
}
.login-step__dot {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
transition: all 0.2s ease;
}
.login-step--active .login-step__dot {
color: var(--mood-accent);
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.login-step--done .login-step__dot {
background: var(--mood-success);
color: white;
}
.login-step__num {
font-size: 0.75rem;
}
.login-step__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-align: center;
}
.login-step--active .login-step__label {
color: var(--mood-accent);
font-weight: 700;
}
/* Field */
.login-card__field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.login-card__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.login-card__input {
width: 100%;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, 'Cascadia Code', monospace;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
/* Prevent iOS zoom on focus */
-webkit-text-size-adjust: 100%;
}
@media (min-width: 640px) {
.login-card__input {
padding: 0.75rem 1rem;
font-size: 0.9375rem;
}
}
.login-card__input:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.login-card__input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-card__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
/* Messages */
.login-card__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-error);
background: rgba(196, 43, 43, 0.08);
border-radius: 12px;
}
.login-card__success {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-success);
background: rgba(24, 132, 59, 0.08);
border-radius: 12px;
}
/* Button */
.login-card__btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
padding: 1rem 1.25rem;
font-size: 1rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
min-height: 3rem;
}
@media (min-width: 640px) {
.login-card__btn {
padding: 0.875rem 1.25rem;
font-size: 1.0625rem;
}
}
.login-card__btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--mood-shadow);
}
.login-card__btn:active:not(:disabled) {
transform: translateY(0);
}
.login-card__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Proto panel */
.proto-panel {
border-radius: 16px;
padding: 1rem;
background: var(--mood-accent-soft);
box-shadow: 0 2px 12px var(--mood-shadow, rgba(0,0,0,0.06));
}
.proto-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.proto-panel__profiles {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-panel__note {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
opacity: 0.7;
text-align: center;
}
.dev-profile {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
text-align: left;
}
.dev-profile:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 12px var(--mood-shadow, rgba(0,0,0,0.08));
}
.dev-profile:active:not(:disabled) {
transform: translateY(0);
}
.dev-profile:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dev-profile__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
.dev-profile__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.dev-profile__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.dev-profile__status {
font-size: 0.6875rem;
color: var(--mood-text-muted);
font-weight: 600;
}
.dev-profile__addr {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
opacity: 0.6;
flex-shrink: 0;
}
/* Note */
.login-card__note {
text-align: center;
font-size: 0.8125rem;
color: var(--mood-text-muted);
opacity: 0.7;
}
</style>
-436
View File
@@ -1,436 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const mandates = useMandatesStore()
const { $api } = useApi()
const mandateId = computed(() => route.params.id as string)
onMounted(async () => {
await mandates.fetchById(mandateId.value)
})
onUnmounted(() => {
mandates.clearCurrent()
})
watch(mandateId, async (newId) => {
if (newId) await mandates.fetchById(newId)
})
// --- Helpers ---
const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t)
function formatDate(d: string | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status))
const canRevoke = computed(() => mandates.current?.status === 'active')
const isDraft = computed(() => mandates.current?.status === 'draft')
// --- Advance ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
}
// --- Identity search (shared for assign + edit) ---
interface IdentityResult { id: string; address: string; display_name: string | null }
function useIdentitySearch() {
const query = ref('')
const results = ref<IdentityResult[]>([])
const searching = ref(false)
const selectedId = ref<string | null>(null)
const selectedLabel = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
async function search(q: string) {
if (q.length < 2) { results.value = []; return }
searching.value = true
try {
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { results.value = [] } finally { searching.value = false }
}
function onInput(q: string) {
query.value = q
selectedId.value = null
if (timer) clearTimeout(timer)
timer = setTimeout(() => search(q), 300)
}
function select(i: IdentityResult) {
selectedId.value = i.id
selectedLabel.value = i.display_name || i.address
query.value = i.display_name || i.address
results.value = []
}
function reset() {
query.value = ''
results.value = []
selectedId.value = null
selectedLabel.value = ''
}
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
}
// --- Assign mandatee ---
const showAssignModal = ref(false)
const assigning = ref(false)
const assignSearch = useIdentitySearch()
async function handleAssign() {
if (!assignSearch.selectedId.value) return
assigning.value = true
try {
await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value)
showAssignModal.value = false
assignSearch.reset()
} catch { /* store holds error */ } finally { assigning.value = false }
}
function openAssign() {
assignSearch.reset()
showAssignModal.value = true
}
// --- Revoke ---
const showRevokeConfirm = ref(false)
const revoking = ref(false)
async function handleRevoke() {
revoking.value = true
try {
await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false
} catch { /* store holds error */ } finally { revoking.value = false }
}
// --- Edit ---
const showEditModal = ref(false)
const editData = ref({ title: '', origin_id: null as string | null, description: '' })
const editOriginSearch = useIdentitySearch()
const saving = ref(false)
function openEdit() {
if (!mandates.current) return
editData.value = {
title: mandates.current.title,
origin_id: mandates.current.origin_id,
description: mandates.current.description ?? '',
}
if (mandates.current.origin_display_name) {
editOriginSearch.query.value = mandates.current.origin_display_name
editOriginSearch.selectedId.value = mandates.current.origin_id
} else {
editOriginSearch.reset()
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await mandates.update(mandateId.value, {
title: editData.value.title,
origin_id: editOriginSearch.selectedId.value ?? editData.value.origin_id,
description: editData.value.description || null,
})
showEditModal.value = false
} catch { /* store holds error */ } finally { saving.value = false }
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
async function handleDelete() {
deleting.value = true
try {
await mandates.delete(mandateId.value)
navigateTo('/mandates')
} catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false }
}
</script>
<template>
<div class="space-y-6">
<div>
<UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" />
</div>
<template v-if="mandates.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
</div>
</div>
</template>
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ mandates.error }}</p>
</div>
</UCard>
</template>
<template v-else-if="mandates.current">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge>
<StatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
<div class="flex items-center gap-2">
<UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" />
<UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" />
<UButton icon="i-lucide-pen-line" label="Modifier" variant="soft" color="neutral" size="sm" @click="openEdit" />
<UButton v-if="canRevoke" icon="i-lucide-shield-off" label="Revoquer" variant="soft" color="error" size="sm" @click="showRevokeConfirm = true" />
<UButton v-if="isDraft" icon="i-lucide-trash-2" label="Supprimer" variant="soft" color="error" size="sm" @click="showDeleteConfirm = true" />
</div>
</div>
<!-- Error feedback -->
<div v-if="mandates.error" class="text-sm text-red-500 bg-red-50 dark:bg-red-950 px-4 py-2 rounded-lg">
{{ mandates.error }}
</div>
<UCard v-if="mandates.current.description">
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p>
</div>
</UCard>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template>
<template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}</span></template>
<template v-else><span class="text-gray-400 italic">Non assigne</span></template>
</p>
</div>
<div>
<p class="text-gray-500">Origine</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.origin_display_name">{{ mandates.current.origin_display_name }}</template>
<template v-else><span class="text-gray-400 italic">Non renseigné</span></template>
</p>
</div>
<div>
<p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p>
</div>
<div>
<p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p>
</div>
</div>
</UCard>
<UCard>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p>
</div>
</div>
</UCard>
<!-- Steps -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2>
<MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" />
</div>
</template>
<!-- Modal : Assigner un mandataire -->
<UModal v-model:open="showAssignModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Rechercher un membre <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
:value="assignSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Nom ou adresse Duniter…"
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="assignSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in assignSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="assignSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
:disabled="!assignSearch.selectedId.value || assigning"
@click="handleAssign"
>
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
Assigner
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Révoquer -->
<UModal v-model:open="showRevokeConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
:disabled="revoking"
@click="handleRevoke"
>
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
Revoquer
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Modifier -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<input v-model="editData.title" type="text" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Origine</label>
<div class="relative">
<input
:value="editOriginSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Rechercher un membre…"
@input="editOriginSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="editOriginSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in editOriginSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="editOriginSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<span>{{ r.display_name || r.address }}</span>
</button>
</div>
</div>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea v-model="editData.description" rows="4" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
:disabled="!editData.title?.trim() || saving"
@click="saveEdit"
>
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
Enregistrer
</button>
</div>
</div>
</template>
</UModal>
<!-- Modal : Supprimer -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button>
<button
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
:disabled="deleting"
@click="handleDelete"
>
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
Supprimer
</button>
</div>
</div>
</template>
</UModal>
</div>
</template>
-637
View File
@@ -1,637 +0,0 @@
<script setup lang="ts">
/**
* Mandats page index.
*
* Utilise SectionLayout avec status filters, recherche,
* et sidebar "Boîte à outils" affichant les protocoles de vote.
* État vide enrichi avec onboarding expliquant le concept de mandat.
*/
import type { MandateCreate } from '~/stores/mandates'
const mandates = useMandatesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
// Create mandate modal state
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comité technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalisé', value: 'custom' },
]
const newMandate = ref<MandateCreate>({
title: '',
description: '',
mandate_type: 'techcomm',
})
const creating = ref(false)
onMounted(async () => {
await Promise.all([
mandates.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prépa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
])
/** Map for status group filtering. */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft', 'candidacy'],
voting: ['voting'],
active: ['active', 'reporting'],
closed: ['completed', 'revoked'],
}
/** Filtered and sorted mandates. */
const filteredMandates = computed(() => {
let list = [...mandates.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const allowedStatuses = statusGroupMap[activeStatus.value]!
list = list.filter(m => allowedStatuses.includes(m.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(m => m.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'statutory': return 'Statutaire'
case 'functional': return 'Fonctionnel'
case 'techcomm': return 'Comité technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalisé'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
async function handleCreate() {
creating.value = true
try {
const mandate = await mandates.create(newMandate.value)
showCreateModal.value = false
newMandate.value = { title: '', description: '', mandate_type: 'techcomm' }
if (mandate) {
navigateTo(`/mandates/${mandate.id}`)
}
}
catch {
// Error handled by store
}
finally {
creating.value = false
}
}
</script>
<template>
<SectionLayout
title="Mandats"
subtitle="Un contexte, un objectif, une durée, une ou plusieurs nominations ; par défaut : nomination d'un binôme."
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher un mandat..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<NuxtLink
v-if="auth.isAuthenticated"
to="/mandates/new"
class="action-btn"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</NuxtLink>
</template>
<!-- Main content: mandates list -->
<template #default>
<!-- Error state -->
<div v-if="mandates.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ mandates.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="mandates.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Onboarding empty state -->
<div
v-else-if="mandates.list.length === 0 && !activeStatus && !searchQuery"
class="mandate-onboarding"
>
<div class="mandate-onboarding__icon">
<UIcon name="i-lucide-user-check" class="text-3xl" />
</div>
<h3 class="mandate-onboarding__title">
Qu'est-ce qu'un mandat ?
</h3>
<p class="mandate-onboarding__text">
Un mandat définit un contexte, un objectif et une durée pour une mission de gouvernance.
Il peut porter sur le comité technique, les forgerons, ou tout rôle spécifique de la communauté.
</p>
<p class="mandate-onboarding__text">
Par défaut, un mandat nomme un binôme pour assurer la continuité.
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
</p>
<div class="mandate-onboarding__actions">
<UButton
v-if="auth.isAuthenticated"
to="/mandates/new"
label="Créer un premier mandat"
icon="i-lucide-plus"
color="primary"
size="sm"
/>
<UButton
to="/protocols"
label="Découvrir les protocoles"
variant="outline"
size="sm"
icon="i-lucide-wrench"
/>
</div>
</div>
<!-- Filtered empty state -->
<div
v-else-if="filteredMandates.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
<p>Aucun mandat trouvé</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Mandate cards -->
<div v-else class="space-y-3">
<div
v-for="mandate in filteredMandates"
:key="mandate.id"
class="mandate-card"
@click="navigateTo(`/mandates/${mandate.id}`)"
>
<div class="mandate-card__header">
<div class="mandate-card__title-block">
<h3 class="mandate-card__title">
{{ mandate.title }}
</h3>
<p v-if="mandate.description" class="mandate-card__description">
{{ mandate.description }}
</p>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<div class="mandate-card__meta">
<span class="mandate-card__type-badge">
{{ typeLabel(mandate.mandate_type) }}
</span>
<span class="mandate-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ mandate.steps.length }} étape{{ mandate.steps.length !== 1 ? 's' : '' }}
</span>
<span v-if="mandate.mandatee_id" class="mandate-card__mandatee">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="mandate-card__dates">
<span>Début : {{ formatDate(mandate.starts_at) }}</span>
<span>Fin : {{ formatDate(mandate.ends_at) }}</span>
</div>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Sociocratic election guide -->
<ToolboxSection title="Nomination & Élection" icon="i-lucide-users">
<SocioElection />
</ToolboxSection>
<!-- Mandat cycle -->
<ToolboxVignette
title="Cycle de mandat"
:bullets="[
'1. Ouverture + définition du rôle',
'2. Candidatures (auto ou par pairs)',
'3. Élection sociocratique',
'4. Période active + rapports',
'5. Renouvellement ou clôture',
]"
:actions="[
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
]"
@action="e => e === 'create' && navigateTo('/mandates/new')"
/>
<!-- Révocation -->
<ToolboxVignette
title="Révocation"
:bullets="[
'Initiée par 3 membres ou plus',
'Vote communautaire ordinaire',
'Bilan de clôture obligatoire',
]"
:actions="[
{ label: 'Voir', icon: 'i-lucide-shield-off', emit: 'revoke' },
]"
/>
</template>
</SectionLayout>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-4 sm:p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Titre <span style="color: var(--mood-error);">*</span>
</label>
<UInput
v-model="newMandate.title"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Type de mandat <span style="color: var(--mood-error);">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4" style="border-top: 1px solid var(--mood-border);">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Créer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template>
</UModal>
</template>
<style scoped>
.mandate-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.mandate-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.mandate-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.mandate-card:active {
transform: translateY(0);
}
.mandate-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.mandate-card__title-block {
flex: 1;
min-width: 0;
}
.mandate-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.mandate-card__title {
font-size: 1.0625rem;
}
}
.mandate-card__description {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.mandate-card__description {
font-size: 0.875rem;
}
}
.mandate-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.mandate-card__meta {
gap: 0.5rem;
}
}
.mandate-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mandate-card__mandatee {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.mandate-card__dates {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
}
@media (min-width: 640px) {
.mandate-card__dates {
gap: 1rem;
font-size: 0.8125rem;
}
}
/* Onboarding empty state */
.mandate-onboarding {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2rem 1.25rem;
background: var(--mood-surface);
border-radius: 20px;
}
@media (min-width: 640px) {
.mandate-onboarding {
gap: 1rem;
padding: 3rem 2rem;
}
}
.mandate-onboarding__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.5rem;
}
@media (min-width: 640px) {
.mandate-onboarding__icon {
width: 4rem;
height: 4rem;
font-size: 1.75rem;
}
}
.mandate-onboarding__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
}
@media (min-width: 640px) {
.mandate-onboarding__title {
font-size: 1.375rem;
}
}
.mandate-onboarding__text {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
max-width: 32rem;
}
@media (min-width: 640px) {
.mandate-onboarding__text {
font-size: 0.9375rem;
}
}
.mandate-onboarding__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.mandate-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
</style>
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/[id] le pouvoir confié, visible et reprenable.
// Origine cliquable (le mandat EST né d'une décision), faits comptés (Δ7),
// feux de la rampe, révocation en un geste au protocole d'origine.
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_REVOKE, NOMINATION_LABELS } from '~/lexicon'
import {
MANDATE_STATUS_LABELS,
MANDATE_STATUS_PILL,
} from '~/components/mandates/mandateUi'
const route = useRoute()
const col = useCollectiveStore()
const mandate = computed(() =>
col.mandates.find(m => m.id === String(route.params.id)),
)
const holder = computed(() =>
col.people.find(p => p.id === mandate.value?.holderId),
)
const originDecision = computed(() =>
col.decisions.find(d => d.id === mandate.value?.originDecisionId),
)
const domainCircles = computed(() =>
(mandate.value?.domain.circleIds ?? [])
.map(id => col.circles.find(c => c.id === id))
.filter(c => c !== undefined),
)
const electorCircle = computed(() =>
col.circles.find(c => c.id === mandate.value?.electorCircleId),
)
const revokeTo = computed(() =>
mandate.value ? `/decider?mandat=${mandate.value.id}&chain=revocation` : '/decider',
)
</script>
<template>
<!-- ld-v2 -->
<div v-if="mandate" class="mfiche">
<NuxtLink to="/mandats" class="mfiche__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<!-- En-tête : le pouvoir, explicite -->
<header class="mfiche__head ld-card">
<div class="mfiche__title-row">
<h1 class="mfiche__title">{{ mandate.title }}</h1>
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
{{ MANDATE_STATUS_LABELS[mandate.status] }}
</span>
</div>
<div v-if="holder" class="mfiche__holder">
<LdAvatarStack :people="[{ person: holder }]" :size="38" />
<div>
<p class="mfiche__holder-name">{{ holder.displayName }}</p>
<p class="mfiche__holder-role">titulaire</p>
</div>
</div>
<dl class="mfiche__meta">
<div class="mfiche__meta-row">
<dt>Origine</dt>
<dd>
<NuxtLink
v-if="originDecision"
:to="`/decisions/${originDecision.id}`"
class="mfiche__origin"
>
<UIcon name="i-lucide-scale" />
{{ originDecision.title }}
</NuxtLink>
<span v-else class="mfiche__muted">décision fondatrice hors de ce fichier</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Domaine</dt>
<dd class="mfiche__chips">
<span v-for="c in domainCircles" :key="c.id" class="mfiche__chip">
<UIcon name="i-lucide-circle-dashed" />
{{ c.name }}
</span>
<span v-for="tag in mandate.domain.tags" :key="tag" class="mfiche__tag">
#{{ tag }}
</span>
<span v-if="!domainCircles.length && !mandate.domain.tags.length" class="mfiche__muted">
domaine non précisé
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Cercle électeur</dt>
<dd>
<span class="mfiche__chip">
<UIcon name="i-lucide-users-round" />
{{ electorCircle?.name ?? '—' }}
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Nomination</dt>
<dd>{{ NOMINATION_LABELS[mandate.nominationMethod] }}</dd>
</div>
<div class="mfiche__meta-row mfiche__meta-row--period">
<dt>Période</dt>
<dd>
<MandatePeriodBar :starts-at="mandate.startsAt" :ends-at="mandate.endsAt" />
</dd>
</div>
</dl>
</header>
<!-- Exercice du mandat faits comptés -->
<MandateExercise :mandate="mandate" />
<!-- Feux de la rampe -->
<MandateSpotlights :mandate="mandate" />
<!-- Révocation en un geste jamais improvisée -->
<section v-if="mandate.status === 'active'" class="mfiche__revoke ld-card">
<NuxtLink :to="revokeTo" class="ld-btn ld-btn--ghost mfiche__revoke-btn">
<UIcon name="i-lucide-key-square" />
{{ MANDATE_REVOKE }}
</NuxtLink>
<p class="mfiche__revoke-note">
La révocation suit le protocole de nomination d'origine
({{ NOMINATION_LABELS[mandate.nominationMethod] }}), avec le cercle électeur
« {{ electorCircle?.name ?? '—' }} » comme périmètre jamais plus coûteuse
que la nomination.
</p>
</section>
</div>
<div v-else class="mfiche mfiche--missing">
<p class="ld-card mfiche__missing-card">
Ce mandat est introuvable dans ce collectif.
<NuxtLink to="/mandats" class="mfiche__origin">Revenir aux mandats</NuxtLink>
</p>
</div>
</template>
<style scoped>
.mfiche {
max-width: 46rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.mfiche__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
align-self: flex-start;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
}
.mfiche__back:hover { color: var(--mood-text); }
.mfiche__head {
padding: 1.375rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mfiche__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mfiche__title {
margin: 0;
font-size: clamp(1.25rem, 3.5vw, 1.625rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.01em;
}
.mfiche__holder { display: flex; align-items: center; gap: 0.75rem; }
.mfiche__holder-name { margin: 0; font-size: 1rem; font-weight: 700; }
.mfiche__holder-role { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__meta { margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.mfiche__meta-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.75rem;
align-items: baseline;
}
.mfiche__meta-row dt {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.mfiche__meta-row dd { margin: 0; font-size: 0.9375rem; min-width: 0; }
.mfiche__meta-row--period dd { padding-top: 0.25rem; }
@media (max-width: 479px) {
.mfiche__meta-row { grid-template-columns: 1fr; gap: 0.125rem; }
}
.mfiche__origin {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.mfiche__origin:hover { text-decoration: underline; }
.mfiche__chips { display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: center; }
.mfiche__chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
}
.mfiche__tag { font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__muted { color: var(--mood-text-muted); font-style: italic; font-size: 0.875rem; }
.mfiche__revoke {
padding: 1.125rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.mfiche__revoke-btn { align-self: flex-start; color: var(--mood-status-revoque); }
.mfiche__revoke-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
}
.mfiche--missing { padding-top: 2rem; }
.mfiche__missing-card {
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats la carte des pouvoirs confiés : qui peut décider
// quoi, jusqu'à quand. Cartes par statut, compteurs bruts (Δ7 : aucune jauge).
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATES_TITLE, MANDATE_CLAIM } from '~/lexicon'
import { MANDATE_STATUS_LABELS } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const ORDER: Mandate['status'][] = ['active', 'proposed', 'expired', 'revoked']
const groups = computed(() =>
ORDER
.map(status => ({
status,
label: MANDATE_STATUS_LABELS[status],
items: col.mandates
.filter(m => m.status === status)
.sort((a, b) => (a.endsAt < b.endsAt ? -1 : 1)),
}))
.filter(g => g.items.length > 0),
)
</script>
<template>
<!-- ld-v2 -->
<div class="mpage">
<header class="mpage__header">
<div>
<h1 class="mpage__title">{{ MANDATES_TITLE }}</h1>
<p class="mpage__sub">qui peut décider quoi, jusqu'à quand</p>
</div>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</header>
<template v-if="groups.length">
<section v-for="group in groups" :key="group.status" class="mpage__group">
<h2 class="mpage__group-title">
{{ group.label }}
<span class="mpage__count">{{ group.items.length }}</span>
</h2>
<div class="mpage__grid">
<MandateCard v-for="m in group.items" :key="m.id" :mandate="m" />
</div>
</section>
</template>
<div v-else class="mpage__empty ld-card">
<UIcon name="i-lucide-key-round" class="mpage__empty-icon" />
<p class="mpage__empty-title">Aucun pouvoir confié pour l'instant.</p>
<p class="mpage__empty-text">
Un mandat naît d'une décision : un rôle, un domaine, une durée bornée
et des comptes à rendre.
</p>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.mpage {
max-width: 64rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.mpage__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.mpage__title {
margin: 0;
font-size: clamp(1.375rem, 3.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.01em;
}
.mpage__sub {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.mpage__group { display: flex; flex-direction: column; gap: 0.875rem; }
.mpage__group-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.mpage__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.mpage__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.mpage__grid { grid-template-columns: 1fr 1fr; }
}
.mpage__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 3rem 1.5rem;
text-align: center;
}
.mpage__empty-icon { font-size: 2rem; color: var(--mood-accent); }
.mpage__empty-title { margin: 0; font-size: 1.0625rem; font-weight: 700; }
.mpage__empty-text {
margin: 0 0 0.625rem;
max-width: 26rem;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.55;
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/nouveau wizard 3 étapes porté du v1 (la structure
// à pépite : pills de progression, cartes cliquables, transitions), branché sur
// le pivot : la nomination EST une décision (createsMandate, route collective).
// Pré-remplissage par query ?titre=&tags= (suggestion R6 du chemin).
import type { Id, MandateDraft, NominationMethod, TriageInput } from '~/types/domain'
import type { ApplyPathEdits } from '~/stores/decisions'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { MANDATE_CLAIM, NOMINATION_LABELS } from '~/lexicon'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
const STEPS = ['Rôle et domaine', 'Nomination', 'Durée et comptes'] as const
const step = ref(0)
// Étape 1 : rôle et domaine
const title = ref(typeof route.query.titre === 'string' ? route.query.titre : '')
const mission = ref('')
const circleIds = ref<Id[]>([])
const tags = ref<string[]>(
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: [],
)
const tagInput = ref('')
function toggleCircle(id: Id): void {
circleIds.value = circleIds.value.includes(id)
? circleIds.value.filter(c => c !== id)
: [...circleIds.value, id]
}
function addTag(): void {
const tag = tagInput.value.trim().replace(/^#/, '').toLowerCase()
if (tag && !tags.value.includes(tag)) tags.value.push(tag)
tagInput.value = ''
}
function removeTag(tag: string): void {
tags.value = tags.value.filter(t => t !== tag)
}
// Étape 2 : modalité de nomination (6)
const method = ref<NominationMethod | null>(null)
// Étape 3 : durée bornée, cadence, cercle électeur
const durationDays = ref(180)
const withReports = ref(true)
const reportEveryDays = ref(90)
const electorCircleId = ref<Id>(col.current?.collective.rootCircleId ?? '')
const canNext = computed(() => {
if (step.value === 0) return title.value.trim().length > 0
if (step.value === 1) return method.value !== null
return durationDays.value >= 1 && electorCircleId.value !== ''
})
const electorCircle = computed(() => col.circles.find(c => c.id === electorCircleId.value))
const domainNames = computed(() =>
circleIds.value
.map(id => col.circles.find(c => c.id === id)?.name)
.filter(n => n !== undefined),
)
/** La modalité choisie résout le protocole via le Pacte (repli : consentement). */
function protocolFor(m: NominationMethod): Id | undefined {
const byRange = col.settings?.protocolByRange
if (!byRange) return undefined
if (m === 'election-no-candidate') return byRange.election ?? byRange.consent
if (m === 'nuanced-vote') return byRange.nuanced ?? byRange.consent
return byRange.consent // ratification par consentement (auto-désignation, tirage, rotation)
}
// Sortie : une Decision createsMandate, route collective
const submitting = ref(false)
const error = ref<string | null>(null)
function submit(): void {
if (!method.value || submitting.value) return
submitting.value = true
error.value = null
const captured = decisions.capture(`Confier le mandat « ${title.value.trim()} »`)
if ('ok' in captured) { error.value = captured.reason; submitting.value = false; return }
const scope = {
selfOnly: false,
// Le cercle électeur EN PREMIER : il devient le périmètre d'élection et de révocation.
circleIds: [electorCircleId.value, ...circleIds.value.filter(id => id !== electorCircleId.value)],
personIds: [] as Id[],
}
const input: TriageInput = {
title: captured.title,
tags: tags.value,
scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}
const path = decisions.runTriage(input)
if ('ok' in path) { error.value = path.reason; submitting.value = false; return }
const draft: MandateDraft = {
title: title.value.trim(),
domainCircleIds: [...circleIds.value],
domainTags: [...tags.value],
durationDays: durationDays.value,
...(withReports.value ? { reportEveryDays: reportEveryDays.value } : {}),
}
const protocolId = protocolFor(method.value)
const edits: ApplyPathEdits = {
route: 'collective',
scope,
reversibility: 'costly',
weight: 'binding',
tags: [...tags.value],
visibility: 'collective',
createsMandate: draft,
...(mission.value.trim() ? { body: mission.value.trim() } : {}),
...(protocolId !== undefined ? { protocolId } : {}),
}
const applied = decisions.applyPath(captured, path, edits)
if ('ok' in applied) { error.value = applied.reason; submitting.value = false; return }
navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="mwiz">
<nav class="mwiz__nav">
<button v-if="step > 0" type="button" class="mwiz__back" @click="step--">
<UIcon name="i-lucide-arrow-left" />
Retour
</button>
<NuxtLink v-else to="/mandats" class="mwiz__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<div class="mwiz__progress">
<template v-for="(label, i) in STEPS" :key="label">
<span v-if="i > 0" class="mwiz__sep"></span>
<span
class="mwiz__pill"
:class="{ 'mwiz__pill--active': step === i, 'mwiz__pill--done': step > i }"
>
{{ i + 1 }} · {{ label }}
</span>
</template>
</div>
</nav>
<Transition name="slide-fade" mode="out-in">
<!-- ÉTAPE 1 : rôle et domaine -->
<div v-if="step === 0" key="role" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">{{ MANDATE_CLAIM }}</h1>
<p class="mwiz__sub">Un rôle nommé, un domaine explicite le pouvoir se voit.</p>
</header>
<label class="mwiz__label" for="mwiz-title">Titre du rôle</label>
<input
id="mwiz-title"
v-model="title"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Trésorerie, Animation du jardin…"
>
<label class="mwiz__label" for="mwiz-mission">La mission, en une phrase</label>
<input
id="mwiz-mission"
v-model="mission"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Tenir les comptes et rendre la caisse lisible par tous."
>
<p class="mwiz__label">Cercles du domaine</p>
<div class="mwiz__chips">
<button
v-for="c in col.circles"
:key="c.id"
type="button"
class="mwiz__chip"
:class="{ 'mwiz__chip--on': circleIds.includes(c.id) }"
@click="toggleCircle(c.id)"
>
{{ c.name }}
</button>
</div>
<label class="mwiz__label" for="mwiz-tag">Tags du domaine</label>
<div class="mwiz__chips">
<span v-for="tag in tags" :key="tag" class="mwiz__chip mwiz__chip--on">
#{{ tag }}
<button type="button" class="mwiz__chip-x" :aria-label="`Retirer ${tag}`" @click="removeTag(tag)">
<UIcon name="i-lucide-x" />
</button>
</span>
<input
id="mwiz-tag"
v-model="tagInput"
type="text"
class="mwiz__input mwiz__input--tag"
placeholder="ajouter un tag ⏎"
@keydown.enter.prevent="addTag"
@blur="addTag"
>
</div>
</div>
<!-- ÉTAPE 2 : modalité de nomination -->
<div v-else-if="step === 1" key="nomination" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Comment nommer ?</h1>
<p class="mwiz__sub">Six modalités, chacune avec sa pédagogie le cercle électeur tranchera.</p>
</header>
<MandateNominationPicker v-model="method" />
</div>
<!-- ÉTAPE 3 : durée bornée, cadence, cercle électeur -->
<div v-else key="terms" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Borné, redevable</h1>
<p class="mwiz__sub">Un mandat a toujours une fin et des comptes à rendre avant.</p>
</header>
<label class="mwiz__label" for="mwiz-duration">Durée du mandat (jours)</label>
<div class="mwiz__row">
<input
id="mwiz-duration"
v-model.number="durationDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
max="730"
>
<input
v-model.number="durationDays"
type="range"
class="mwiz__slider"
min="7"
max="730"
step="1"
aria-label="Durée du mandat en jours"
>
</div>
<label class="mwiz__check">
<input v-model="withReports" type="checkbox" class="mwiz__checkbox">
<span>Avec rapports réguliers</span>
</label>
<div v-if="withReports" class="mwiz__row">
<label class="mwiz__label mwiz__label--inline" for="mwiz-cadence">tous les</label>
<input
id="mwiz-cadence"
v-model.number="reportEveryDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
:max="durationDays"
>
<span class="mwiz__unit">jours</span>
</div>
<label class="mwiz__label" for="mwiz-elector">Cercle électeur il nomme, il révoque</label>
<select id="mwiz-elector" v-model="electorCircleId" class="mwiz__input">
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div class="mwiz__recap">
<p class="mwiz__recap-title">{{ title }}</p>
<p v-if="mission" class="mwiz__recap-line">{{ mission }}</p>
<p class="mwiz__recap-line">
{{ method ? NOMINATION_LABELS[method] : '—' }} ·
{{ durationDays }} jours<template v-if="withReports"> · rapport tous les {{ reportEveryDays }} jours</template>
</p>
<p v-if="domainNames.length || tags.length" class="mwiz__recap-line">
Domaine : {{ [...domainNames, ...tags.map(t => `#${t}`)].join(' · ') }}
</p>
<p class="mwiz__recap-note">
La nomination est une décision elle part au vote du cercle
« {{ electorCircle?.name ?? '—' }} ».
</p>
</div>
<p v-if="error" class="mwiz__error">{{ error }}</p>
</div>
</Transition>
<div class="mwiz__actions">
<button
v-if="step < 2"
type="button"
class="ld-btn"
:disabled="!canNext"
@click="step++"
>
Continuer
<UIcon name="i-lucide-arrow-right" />
</button>
<button
v-else
type="button"
class="ld-btn"
:disabled="!canNext || !method || submitting"
@click="submit"
>
<UIcon name="i-lucide-scale" />
{{ submitting ? 'Ouverture…' : 'Soumettre au cercle électeur' }}
</button>
</div>
</div>
</template>
<style scoped>
.mwiz { max-width: 46rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
.mwiz__nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.mwiz__back {
display: inline-flex; align-items: center; gap: 0.375rem;
font-size: 0.875rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-decoration: none;
}
.mwiz__back:hover { color: var(--mood-text); }
.mwiz__progress { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; flex-wrap: wrap; }
.mwiz__sep { color: var(--mood-text-muted); font-size: 0.75rem; }
.mwiz__pill {
font-size: 0.75rem; font-weight: 600; padding: 0.25rem 0.625rem;
border-radius: var(--r-pill); color: var(--mood-text-muted);
background: var(--mood-surface); white-space: nowrap;
}
.mwiz__pill--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__pill--done { background: var(--mood-accent-soft); color: var(--mood-accent); }
.mwiz__step { display: flex; flex-direction: column; gap: 0.75rem; }
.mwiz__header { margin-bottom: 0.5rem; }
.mwiz__title { margin: 0 0 0.25rem; font-size: clamp(1.25rem, 3vw, 1.625rem); font-weight: 800; }
.mwiz__sub { margin: 0; font-size: 0.9375rem; color: var(--mood-text-muted); }
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
.mwiz__label--inline { margin-top: 0; }
.mwiz__input {
width: 100%; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
color: var(--mood-text); background: var(--mood-input-bg); border: none;
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.mwiz__input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.mwiz__input--num { width: 6rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.mwiz__input--tag { width: 11rem; padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.mwiz__row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.mwiz__slider { flex: 1; min-width: 10rem; accent-color: var(--mood-accent); }
.mwiz__unit { font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.mwiz__chip {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.875rem; font-size: 0.875rem; font-weight: 600;
color: var(--mood-text-muted); background: var(--mood-surface);
border-radius: var(--r-pill); box-shadow: var(--shadow-card);
cursor: pointer; transition: transform 0.1s ease;
}
.mwiz__chip:hover { transform: translateY(-1px); }
.mwiz__chip--on { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__chip-x { display: inline-flex; background: none; color: inherit; cursor: pointer; padding: 0; }
.mwiz__check {
display: flex; align-items: center; gap: 0.625rem; margin-top: 0.375rem;
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
}
.mwiz__checkbox { width: 1.125rem; height: 1.125rem; accent-color: var(--mood-accent); }
.mwiz__recap {
margin-top: 0.75rem; padding: 1.125rem 1.25rem; border-radius: var(--r-card);
background: var(--mood-surface); box-shadow: var(--shadow-card);
display: flex; flex-direction: column; gap: 0.375rem;
}
.mwiz__recap-title { margin: 0; font-size: 1.0625rem; font-weight: 800; }
.mwiz__recap-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__recap-note { margin: 0.375rem 0 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-accent); }
.mwiz__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.mwiz__actions { display: flex; justify-content: flex-end; }
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
.slide-fade-leave-to { opacity: 0; transform: translateX(-20px); }
</style>
-180
View File
@@ -1,180 +0,0 @@
<script setup lang="ts">
/**
* Protocol detail page.
*
* Displays full protocol information including name, type, description,
* mode params, formula config, and links to the formula simulator.
*/
const route = useRoute()
const protocols = useProtocolsStore()
const votes = useVotesStore()
const protocolId = computed(() => route.params.id as string)
onMounted(async () => {
await protocols.fetchProtocolById(protocolId.value)
})
const protocol = computed(() => protocols.currentProtocol)
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuance'
default: return voteType
}
}
const voteTypeColor = (voteType: string) => {
switch (voteType) {
case 'binary': return 'primary'
case 'nuanced': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
/** Build simulator URL with prefilled params. */
const simulatorLink = computed(() => {
if (!protocol.value?.mode_params) return '/protocols/formulas'
return `/protocols/formulas`
})
</script>
<template>
<div class="space-y-8">
<!-- Header with back link -->
<div class="flex items-center gap-3">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Detail du protocole
</h1>
</div>
<!-- Loading -->
<template v-if="protocols.loading">
<div class="space-y-3">
<USkeleton class="h-12 w-3/4" />
<USkeleton class="h-6 w-1/2" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error -->
<template v-else-if="protocols.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ protocols.error }}</p>
</div>
</UCard>
</template>
<!-- Protocol detail -->
<template v-else-if="protocol">
<!-- Protocol header card -->
<UCard>
<div class="space-y-4">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ protocol.name }}
</h2>
<p v-if="protocol.description" class="text-gray-600 dark:text-gray-400 mt-1">
{{ protocol.description }}
</p>
<p class="text-xs text-gray-500 mt-2">
Cree le {{ formatDate(protocol.created_at) }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle">
Meta-gouverne
</UBadge>
</div>
</div>
</div>
</UCard>
<!-- Mode params -->
<UCard v-if="protocol.mode_params">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres du mode
</h3>
</template>
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</UCard>
<!-- Formula config -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Configuration de la formule : {{ protocol.formula_config.name }}
</h3>
<NuxtLink :to="simulatorLink">
<UButton variant="outline" size="sm" icon="i-lucide-calculator">
Simuler
</UButton>
</NuxtLink>
</div>
</template>
<FormulaDisplay :formula-config="protocol.formula_config" />
</UCard>
<!-- Meta-governance info -->
<UCard v-if="protocol.is_meta_governed">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield" class="text-2xl text-amber-500" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
Protocole meta-gouverne
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les modifications de ce protocole sont soumises au vote selon ses propres regles.
</p>
</div>
</div>
</UCard>
<!-- Related vote sessions -->
<UCard v-if="votes.sessions && votes.sessions.length > 0">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Sessions de vote utilisant ce protocole
</h3>
</template>
<div class="space-y-2">
<div
v-for="session in votes.sessions"
:key="session.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">
{{ session.id.slice(0, 8) }}...
</span>
<StatusBadge :status="session.status" type="vote" class="ml-2" />
</div>
<div class="text-sm text-gray-500">
{{ session.votes_total }} votes
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>
-304
View File
@@ -1,304 +0,0 @@
<script setup lang="ts">
/**
* Formula simulator page.
*
* Allows interactive adjustment of formula parameters with live threshold
* computation, a visual gauge, and a table of thresholds at various
* participation levels.
*/
import type { FormulaConfig } from '~/stores/protocols'
import { encodeModeParams } from '~/utils/mode-params'
const { computeThreshold, computeRequiredRatio, computeInertiaFactor } = useVoteFormula()
/** Default formula config for the simulator. */
const formulaConfig = ref<FormulaConfig>({
id: 'simulator',
name: 'Simulateur',
description: null,
duration_days: 30,
majority_pct: 50,
base_exponent: 0.1,
gradient_exponent: 0.2,
constant_base: 0,
smith_exponent: null,
techcomm_exponent: null,
nuanced_min_participants: null,
nuanced_threshold_pct: null,
created_at: new Date().toISOString(),
})
/** Simulation inputs. */
const wotSize = ref(7224)
const simulatedVotes = ref(120)
const simulatedFor = ref(97)
/** Computed threshold for current params. */
const threshold = computed(() => {
try {
return computeThreshold(wotSize.value, simulatedVotes.value, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
} catch {
return 0
}
})
/** Computed required ratio. */
const requiredRatio = computed(() => {
return computeRequiredRatio(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
})
/** Computed inertia factor. */
const inertiaFactor = computed(() => {
return computeInertiaFactor(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.gradient_exponent,
)
})
/** Simulated votes against. */
const simulatedAgainst = computed(() => {
return Math.max(0, simulatedVotes.value - simulatedFor.value)
})
/** Mode params string for current config. */
const modeParamsString = computed(() => {
try {
return encodeModeParams({
duration_days: formulaConfig.value.duration_days,
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
smith_exponent: formulaConfig.value.smith_exponent,
techcomm_exponent: formulaConfig.value.techcomm_exponent,
})
} catch {
return ''
}
})
/** Table of thresholds at various participation levels. */
const participationLevels = [10, 50, 100, 200, 500, 1000, 3000, 5000, 7000]
const thresholdTable = computed(() => {
return participationLevels
.filter(n => n <= wotSize.value)
.map(totalVotes => {
let t: number
let ratio: number
try {
t = computeThreshold(wotSize.value, totalVotes, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
ratio = computeRequiredRatio(
totalVotes,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
} catch {
t = 0
ratio = 0
}
const participation = ((totalVotes / wotSize.value) * 100).toFixed(2)
return {
totalVotes,
threshold: t,
ratio: (ratio * 100).toFixed(1),
participation,
}
})
})
/** Keep simulatedFor within bounds when simulatedVotes changes. */
watch(simulatedVotes, (newTotal) => {
if (simulatedFor.value > newTotal) {
simulatedFor.value = newTotal
}
})
</script>
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<div class="flex items-center gap-3 mb-2">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Simulateur de formule de seuil
</h1>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Ajustez les parametres de la formule et observez le seuil calcule en temps reel.
</p>
</div>
<!-- Current mode params -->
<UCard v-if="modeParamsString">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mode params :</span>
<ModeParamsDisplay :mode-params="modeParamsString" />
</div>
</UCard>
<!-- Formula editor -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres de la formule
</h2>
</template>
<FormulaEditor v-model="formulaConfig" />
</UCard>
<!-- Simulation inputs -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Simulation
</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- WoT size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Taille du corpus WoT (W)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ wotSize }}</span>
</div>
<UInput v-model.number="wotSize" type="number" :min="1" :max="100000" />
</div>
<!-- Total votes -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes totaux (T)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ simulatedVotes }}</span>
</div>
<URange v-model="simulatedVotes" :min="0" :max="wotSize" :step="1" />
</div>
<!-- Votes for -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes pour
</label>
<span class="text-sm font-mono font-bold text-green-600">{{ simulatedFor }}</span>
</div>
<URange v-model="simulatedFor" :min="0" :max="simulatedVotes" :step="1" />
</div>
</div>
</UCard>
<!-- Results -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Resultat
</h2>
</template>
<div class="space-y-6">
<!-- Threshold gauge -->
<ThresholdGauge
:votes-for="simulatedFor"
:votes-against="simulatedAgainst"
:threshold="threshold"
:wot-size="wotSize"
/>
<!-- Key metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Seuil requis</p>
<p class="text-2xl font-bold text-primary">{{ threshold }}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Ratio requis</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ (requiredRatio * 100).toFixed(1) }}%
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Facteur d'inertie</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ inertiaFactor.toFixed(4) }}
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Participation</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ ((simulatedVotes / wotSize) * 100).toFixed(2) }}%
</p>
</div>
</div>
</div>
</UCard>
<!-- Formula display -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Formule
</h2>
</template>
<FormulaDisplay :formula-config="formulaConfig" show-explanation />
</UCard>
<!-- Threshold table -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Seuils par niveau de participation
</h2>
</template>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votes totaux (T)</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Participation</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Ratio requis</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Seuil (votes pour)</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in thresholdTable"
:key="row.totalVotes"
class="border-b border-gray-100 dark:border-gray-800"
:class="row.totalVotes === simulatedVotes ? 'bg-primary-50 dark:bg-primary-900/20' : ''"
>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{{ row.totalVotes }}</td>
<td class="px-4 py-3 text-gray-600">{{ row.participation }}%</td>
<td class="px-4 py-3 text-gray-600">{{ row.ratio }}%</td>
<td class="px-4 py-3 font-mono font-bold text-primary">{{ row.threshold }}</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</template>
File diff suppressed because it is too large Load Diff
-445
View File
@@ -1,445 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const { $api } = useApi()
interface SanctuaryEntryDetail {
id: string
entry_type: string
reference_id: string
title: string | null
content_hash: string
ipfs_cid: string | null
chain_tx_hash: string | null
chain_block: number | null
metadata_json: string | null
created_at: string
}
interface VerifyResult {
match: boolean
message: string
}
const entryId = computed(() => route.params.id as string)
const entry = ref<SanctuaryEntryDetail | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const verifying = ref(false)
const verifyResult = ref<VerifyResult | null>(null)
const copied = ref<string | null>(null)
async function loadEntry() {
loading.value = true
error.value = null
try {
entry.value = await $api<SanctuaryEntryDetail>(`/sanctuary/${entryId.value}`)
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Entree introuvable'
} finally {
loading.value = false
}
}
async function verifyIntegrity() {
verifying.value = true
verifyResult.value = null
try {
verifyResult.value = await $api<VerifyResult>(
`/sanctuary/${entryId.value}/verify`,
)
} catch (err: any) {
verifyResult.value = {
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = false
}
}
async function copyToClipboard(text: string, field: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = field
setTimeout(() => { copied.value = null }, 2000)
} catch {
// Clipboard API not available
}
}
onMounted(() => {
loadEntry()
})
watch(entryId, () => {
loadEntry()
})
const typeLabel = (entryType: string): string => {
switch (entryType) {
case 'document': return 'Document'
case 'decision': return 'Decision'
case 'vote_result': return 'Resultat de vote'
default: return entryType
}
}
const typeColor = (entryType: string): string => {
switch (entryType) {
case 'document': return 'primary'
case 'decision': return 'success'
case 'vote_result': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const parsedMetadata = computed(() => {
if (!entry.value?.metadata_json) return null
try {
return JSON.parse(entry.value.metadata_json)
} catch {
return null
}
})
const formattedMetadata = computed(() => {
if (!parsedMetadata.value) return null
return JSON.stringify(parsedMetadata.value, null, 2)
})
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/sanctuary"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour au sanctuaire"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Entry detail -->
<template v-else-if="entry">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UIcon name="i-lucide-shield-check" class="text-2xl text-primary" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h1>
</div>
<div class="flex items-center gap-3">
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle">
{{ typeLabel(entry.entry_type) }}
</UBadge>
<span class="text-sm text-gray-500">
{{ formatDate(entry.created_at) }}
</span>
</div>
</div>
<!-- Verify button -->
<UButton
label="Verifier l'integrite"
icon="i-lucide-shield-check"
color="primary"
:loading="verifying"
@click="verifyIntegrity"
/>
</div>
<!-- Verification result -->
<UCard v-if="verifyResult">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-2xl' : 'text-red-500 text-2xl'"
/>
<div>
<p
:class="verifyResult.match
? 'text-green-700 dark:text-green-400 font-semibold'
: 'text-red-700 dark:text-red-400 font-semibold'"
>
{{ verifyResult.match ? 'Integrite verifiee avec succes' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500 mt-1">{{ verifyResult.message }}</p>
</div>
</div>
</UCard>
<!-- SHA-256 Hash -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Hash SHA-256</h2>
</div>
<UButton
:icon="copied === 'hash' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'hash' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.content_hash, 'hash')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.content_hash }}
</p>
</div>
</div>
</UCard>
<!-- IPFS CID -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hard-drive" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">IPFS CID</h2>
</div>
<UButton
v-if="entry.ipfs_cid"
:icon="copied === 'ipfs' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'ipfs' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.ipfs_cid!, 'ipfs')"
/>
</div>
<template v-if="entry.ipfs_cid">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.ipfs_cid }}
</p>
</div>
<a
:href="`${IPFS_GATEWAY}${entry.ipfs_cid}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<UIcon name="i-lucide-external-link" />
<span>Ouvrir sur la passerelle IPFS</span>
</a>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'epinglage IPFS
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Chain Anchor -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-link" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Ancrage On-chain</h2>
</div>
<UButton
v-if="entry.chain_tx_hash"
:icon="copied === 'chain' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'chain' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.chain_tx_hash!, 'chain')"
/>
</div>
<template v-if="entry.chain_tx_hash">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 space-y-2">
<div>
<p class="text-xs text-gray-500 mb-1">TX Hash</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.chain_tx_hash }}
</p>
</div>
<div v-if="entry.chain_block">
<p class="text-xs text-gray-500 mb-1">Numero de bloc</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300">
#{{ entry.chain_block.toLocaleString('fr-FR') }}
</p>
</div>
</div>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'ancrage on-chain via system.remark
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Verification status -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-check-circle" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Statut de verification</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.content_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-gray-50 dark:bg-gray-800/50'"
>
<UIcon
:name="entry.content_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.content_hash ? 'text-green-500' : 'text-gray-400'"
/>
<div>
<p class="text-sm font-medium" :class="entry.content_hash ? 'text-green-700 dark:text-green-400' : 'text-gray-500'">
Hash SHA-256
</p>
<p class="text-xs text-gray-500">
{{ entry.content_hash ? 'Calcule' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.ipfs_cid
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.ipfs_cid ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
IPFS
</p>
<p class="text-xs text-gray-500">
{{ entry.ipfs_cid ? 'Epingle' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.chain_tx_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.chain_tx_hash ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
On-chain
</p>
<p class="text-xs text-gray-500">
{{ entry.chain_tx_hash ? 'Ancre' : 'En attente' }}
</p>
</div>
</div>
</div>
</div>
</UCard>
<!-- Metadata JSON -->
<UCard v-if="entry.metadata_json">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-braces" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Metadonnees</h2>
</div>
<UButton
v-if="entry.metadata_json"
:icon="copied === 'metadata' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'metadata' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.metadata_json!, 'metadata')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 overflow-x-auto">
<pre class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ formattedMetadata || entry.metadata_json }}</pre>
</div>
</div>
</UCard>
<!-- Reference info -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Reference</h2>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Identifiant</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.id }}</p>
</div>
<div>
<p class="text-gray-500">Reference (document/decision)</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.reference_id }}</p>
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>
-193
View File
@@ -1,193 +0,0 @@
<script setup lang="ts">
import type { SanctuaryEntryOut } from '~/components/sanctuary/SanctuaryEntry.vue'
const { $api } = useApi()
const entries = ref<SanctuaryEntryOut[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filterType = ref<string | undefined>(undefined)
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Document', value: 'document' },
{ label: 'Decision', value: 'decision' },
{ label: 'Resultat de vote', value: 'vote_result' },
]
// Verification state
const verifying = ref<string | null>(null)
const verifyResult = ref<{ id: string; match: boolean; message: string } | null>(null)
async function loadEntries() {
loading.value = true
error.value = null
try {
const query: Record<string, string> = {}
if (filterType.value) query.entry_type = filterType.value
entries.value = await $api<SanctuaryEntryOut[]>('/sanctuary/', { query })
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
} finally {
loading.value = false
}
}
async function handleVerify(id: string) {
verifying.value = id
verifyResult.value = null
try {
const result = await $api<{ match: boolean; message: string }>(
`/sanctuary/${id}/verify`,
)
verifyResult.value = { id, ...result }
} catch (err: any) {
verifyResult.value = {
id,
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = null
}
}
onMounted(() => {
loadEntries()
})
watch(filterType, () => {
loadEntries()
})
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Sanctuaire
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Archive immuable : documents et decisions ancres sur IPFS avec preuve on-chain via system.remark
</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="typeOptions"
placeholder="Type d'entree"
class="w-56"
/>
</div>
<!-- Verification result banner -->
<UCard v-if="verifyResult">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-xl' : 'text-red-500 text-xl'"
/>
<div>
<p :class="verifyResult.match ? 'text-green-700 dark:text-green-400 font-medium' : 'text-red-700 dark:text-red-400 font-medium'">
{{ verifyResult.match ? 'Integrite verifiee' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500">{{ verifyResult.message }}</p>
</div>
</div>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="verifyResult = null"
/>
</div>
</UCard>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-48 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="entries.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-archive" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune entree dans le sanctuaire pour le moment</p>
<p class="text-xs text-gray-400 mt-1">
Les documents et decisions adoptes seront automatiquement archives ici
</p>
</div>
</UCard>
</template>
<!-- Entries list using SanctuaryEntry component -->
<template v-else>
<div class="space-y-4">
<div v-for="entry in entries" :key="entry.id" class="relative">
<SanctuaryEntry
:entry="entry"
@verify="handleVerify"
/>
<!-- Loading overlay for verification -->
<div
v-if="verifying === entry.id"
class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center rounded-lg"
>
<div class="flex items-center gap-2 text-sm text-gray-500">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
<span>Verification en cours...</span>
</div>
</div>
</div>
</div>
</template>
<!-- Info card -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="text-primary" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Processus d'archivage
</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-gray-500">
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">1</span>
<span>Le contenu est hache en SHA-256 pour garantir son integrite</span>
</div>
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">2</span>
<span>Le document est epingle sur IPFS (Kubo) pour le stockage distribue</span>
</div>
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">3</span>
<span>Le hash est ancre on-chain via system.remark sur Duniter V2</span>
</div>
</div>
</div>
</UCard>
</div>
</template>
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
/**
* /textes/[slug] le document vivant : sections repliables + scroll-spy
* (sommaire latéral desktop, dropdown mobile), clauses (pastille d'inertie
* câblée, mini-jauge de session réelle, statut), détail dépliable, vue
* projetée, provenance. Spécifique Pacte : valeurs en clair + effet immédiat,
* clause d'inertie protégée, A1 en tête, préambule boussoles.
*/
import type { Clause } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
buildClauseView,
formatInt,
liveClauseGauges,
sectionMeta,
type ClauseSessionGauge,
type ClauseView,
type ClauseViewCtx,
} from '~/components/texts/textsModel'
const route = useRoute()
const store = useCollectiveStore()
const doc = computed(() =>
store.docs.find(d => d.slug === String(route.params.slug)) ?? null)
const isPact = computed(() => doc.value?.role === 'pact')
// Clauses & sections (A1 en tête : l'ordre des positions fait foi)
const docClauses = computed(() =>
store.clauses
.filter(c => c.docId === doc.value?.id)
.sort((a, b) => a.position - b.position))
interface SectionGroup { tag: string; label: string; icon: string; clauses: Clause[] }
const sections = computed((): SectionGroup[] => {
const groups = new Map<string, Clause[]>()
for (const clause of docClauses.value) {
const list = groups.get(clause.section) ?? []
list.push(clause)
groups.set(clause.section, list)
}
return [...groups.entries()].map(([tag, clauses]) => ({ tag, ...sectionMeta(tag), clauses }))
})
// Assemblage des vues de clauses (pur, dans textsModel)
const gauges = computed(() => {
const map = new Map<string, ClauseSessionGauge>()
for (const gauge of liveClauseGauges(docClauses.value, store.decisions, store.sessions, store.votes)) {
map.set(gauge.clauseId, gauge)
}
return map
})
const viewCtx = computed((): ClauseViewCtx => ({
versions: store.versions,
decisions: store.decisions,
people: store.people,
protocols: store.protocols,
settings: store.settings,
gauges: gauges.value,
isPact: isPact.value,
memberCount: store.people.length,
}))
const viewByClause = computed(() => new Map<string, ClauseView>(
docClauses.value.map(c => [c.id, buildClauseView(c, viewCtx.value)]),
))
function viewOf(clause: Clause): ClauseView {
return viewByClause.value.get(clause.id) ?? buildClauseView(clause, viewCtx.value)
}
// Dépliage clause + repli sections
const expandedClauseId = ref<string | null>(null)
function toggleClause(id: string) {
expandedClauseId.value = expandedClauseId.value === id ? null : id
}
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (list) => {
if (list.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
list.forEach((s, i) => { map[s.tag] = i >= 2 })
collapsedSections.value = map
}
}, { immediate: true })
// Vue projetée : le document si les votes en cours passaient
const projected = ref(false)
function projectedContent(clause: Clause): { content: string; changed: boolean } {
const view = viewOf(clause)
const proposed = view.proposed[0]
return proposed
? { content: proposed.version.content, changed: true }
: { content: view.current?.content ?? '', changed: false }
}
const projectedChanges = computed(() =>
docClauses.value.filter(c => viewOf(c).proposed.length > 0).length)
// Sommaire + scroll-spy
const activeSection = ref<string | null>(null)
let observer: IntersectionObserver | null = null
function observeSections() {
observer?.disconnect()
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) activeSection.value = entry.target.id.replace('sec-', '')
}
}, { rootMargin: '-15% 0px -70% 0px' })
for (const section of sections.value) {
const el = document.getElementById(`sec-${section.tag}`)
if (el) observer.observe(el)
}
}
onMounted(() => nextTick(observeSections))
watch([sections, projected], () => nextTick(observeSections))
onUnmounted(() => observer?.disconnect())
function scrollToSection(tag: string) {
if (collapsedSections.value[tag]) collapsedSections.value[tag] = false
nextTick(() => {
document.getElementById(`sec-${tag}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
})
}
</script>
<template>
<!-- ld-v2 -->
<div class="doc-page">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet doc-page__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<p v-if="!doc" class="doc-page__missing">
Ce texte est introuvable il a peut-être été remplacé ou retiré.
</p>
<template v-else>
<!-- En-tête -->
<header class="doc-page__header">
<div class="doc-page__badges">
<span v-if="isPact" class="doc-page__pact-badge">{{ PACT_BADGE }}</span>
<span v-else class="doc-page__ref-badge">document de référence</span>
</div>
<h1 class="doc-page__title">{{ doc.title }}</h1>
<p class="doc-page__desc">{{ isPact ? PACT_SUBTITLE : doc.description }}</p>
<div v-if="isPact" class="doc-page__compass">
<UIcon name="i-lucide-compass" />
<span>Autonomie</span><span class="doc-page__compass-dot">·</span>
<span>Équilibre</span><span class="doc-page__compass-dot">·</span>
<span>Liaison</span>
</div>
<div class="doc-page__meta">
<span class="doc-page__meta-chip">{{ formatInt(docClauses.length) }} clauses</span>
<span v-if="gauges.size" class="doc-page__meta-chip doc-page__meta-chip--vote">
{{ gauges.size }} session{{ gauges.size > 1 ? 's' : '' }} en cours
</span>
<button
type="button"
class="doc-page__projected"
:class="{ 'doc-page__projected--on': projected }"
:disabled="projectedChanges === 0"
:title="projectedChanges === 0 ? 'Aucune version proposée sur ce texte' : ''"
@click="projected = !projected"
>
<UIcon name="i-lucide-telescope" />
{{ projected ? 'Revenir au texte en vigueur' : 'Le document si les votes en cours passaient' }}
</button>
</div>
</header>
<div class="doc-page__body">
<!-- Sommaire : latéral en desktop, dropdown en mobile -->
<nav class="doc-page__toc" aria-label="Sommaire">
<select
class="doc-page__toc-select"
:value="activeSection ?? ''"
@change="scrollToSection(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Aller à une section</option>
<option v-for="section in sections" :key="section.tag" :value="section.tag">
{{ section.label }} ({{ section.clauses.length }})
</option>
</select>
<ul class="doc-page__toc-list">
<li v-for="section in sections" :key="section.tag">
<button
type="button"
class="doc-page__toc-item"
:class="{ 'doc-page__toc-item--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" />
<span class="doc-page__toc-label">{{ section.label }}</span>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
</button>
</li>
</ul>
</nav>
<!-- Les sections -->
<div class="doc-page__sections">
<section
v-for="section in sections"
:id="`sec-${section.tag}`"
:key="section.tag"
class="doc-page__section"
>
<button
type="button"
class="doc-page__section-head"
@click="collapsedSections[section.tag] = !collapsedSections[section.tag]"
>
<UIcon :name="section.icon" class="doc-page__section-icon" />
<h2 class="doc-page__section-title">{{ section.label }}</h2>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
<UIcon
name="i-lucide-chevron-down"
class="doc-page__chevron"
:class="{ 'doc-page__chevron--open': !collapsedSections[section.tag] }"
/>
</button>
<div v-show="!collapsedSections[section.tag]" class="doc-page__clauses">
<!-- Vue projetée : lecture, votes appliqués -->
<template v-if="projected">
<article
v-for="clause in section.clauses"
:key="clause.id"
class="doc-page__projected-clause"
:class="{ 'doc-page__projected-clause--changed': projectedContent(clause).changed }"
>
<p class="doc-page__clause-head">
<span class="doc-page__clause-code">{{ clause.code }}</span>
<span class="doc-page__clause-name">{{ clause.title }}</span>
<span v-if="projectedContent(clause).changed" class="doc-page__changed-chip">
si le vote passe
</span>
</p>
<MarkdownRenderer
:content="projectedContent(clause).content"
class="doc-page__projected-text"
/>
</article>
</template>
<!-- Vue structurée : la boucle vote texte -->
<template v-else>
<ClauseCard
v-for="clause in section.clauses"
:key="clause.id"
:view="viewOf(clause)"
:expanded="expandedClauseId === clause.id"
@toggle="toggleClause(clause.id)"
/>
</template>
</div>
</section>
<TextProvenance v-if="doc.provenance" :provenance="doc.provenance" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.1rem;
max-width: 64rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.doc-page__missing { font-size: 0.9375rem; color: var(--mood-text-muted); font-style: italic; }
.doc-page__header { display: flex; flex-direction: column; gap: 0.45rem; }
.doc-page__badges { display: flex; gap: 0.4rem; }
.doc-page__pact-badge,
.doc-page__ref-badge {
font-size: 0.6875rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 3px 10px; border-radius: var(--r-pill);
}
.doc-page__pact-badge { background: var(--mood-accent); color: var(--mood-accent-text); }
.doc-page__ref-badge {
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-page__title {
font-size: clamp(1.35rem, 4.5vw, 1.8rem); font-weight: 800;
letter-spacing: -0.02em; color: var(--mood-text); line-height: 1.2;
}
.doc-page__desc { font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.55; }
.doc-page__compass {
display: inline-flex; align-items: center; gap: 0.45rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 10%, transparent);
padding: 0.3rem 0.9rem; border-radius: var(--r-pill);
}
.doc-page__compass-dot { opacity: 0.5; }
.doc-page__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.2rem; }
.doc-page__meta-chip {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-page__meta-chip--vote { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.doc-page__projected {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.75rem; font-weight: 700; padding: 4px 12px; border-radius: var(--r-pill);
background: var(--mood-surface); color: var(--mood-text-muted);
box-shadow: var(--shadow-card); cursor: pointer;
}
.doc-page__projected:disabled { opacity: 0.45; cursor: not-allowed; }
.doc-page__projected--on {
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
box-shadow: 0 0 0 2px var(--mood-status-fige);
}
.doc-page__body { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }
@media (min-width: 1024px) { .doc-page__body { grid-template-columns: 15rem 1fr; } }
.doc-page__toc { display: flex; flex-direction: column; gap: 0.5rem; }
.doc-page__toc-select {
padding: 0.55rem 0.8rem;
font-size: 0.875rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.doc-page__toc-list { display: none; }
@media (min-width: 1024px) {
.doc-page__toc-select { display: none; }
.doc-page__toc { position: sticky; top: 4.5rem; }
.doc-page__toc-list { display: flex; flex-direction: column; gap: 2px; }
}
.doc-page__toc-item {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.45rem 0.7rem; border-radius: var(--r-input);
font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-align: left;
}
.doc-page__toc-item:hover { background: var(--mood-accent-soft); color: var(--mood-text); }
.doc-page__toc-item--active { background: var(--mood-accent-soft); color: var(--mood-accent); font-weight: 700; }
.doc-page__toc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-page__toc-count { font-size: 0.6875rem; font-weight: 800; opacity: 0.6; }
.doc-page__sections { display: flex; flex-direction: column; gap: 1.4rem; min-width: 0; }
.doc-page__section { scroll-margin-top: 4.5rem; }
.doc-page__section-head {
display: flex; align-items: center; gap: 0.55rem; width: 100%;
padding: 0.5rem 0; background: none; cursor: pointer;
box-shadow: 0 2px 0 color-mix(in srgb, var(--mood-accent) 14%, transparent);
}
.doc-page__section-icon { color: var(--mood-accent); }
.doc-page__section-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.doc-page__chevron { margin-left: auto; color: var(--mood-text-muted); transform: rotate(-90deg); transition: transform 0.2s ease; }
.doc-page__chevron--open { transform: rotate(0); }
.doc-page__clauses { display: flex; flex-direction: column; gap: 0.6rem; padding-top: 0.7rem; }
.doc-page__projected-clause { padding: 0.6rem 0.9rem; border-radius: var(--r-input); }
.doc-page__projected-clause--changed { background: color-mix(in srgb, var(--mood-status-fige) 8%, transparent); }
.doc-page__clause-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.doc-page__clause-code {
font-family: monospace; font-size: 0.75rem; font-weight: 800;
color: var(--mood-accent); background: var(--mood-accent-soft);
padding: 2px 8px; border-radius: 7px;
}
.doc-page__clause-name { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.doc-page__changed-chip {
font-size: 0.6875rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
}
.doc-page__projected-text { font-size: 0.875rem; }
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* /textes/formules l'Atelier des formules : la pédagogie du seuil,
* seule maison des lettres W/T/M/B/G/C. Query params ?W=&T=&M=&B=&G=&C=(&S=)
* pré-règlent l'établi les liens « comprendre ce seuil » arrivent ici.
*/
import { BINARY_DESCRIPTION, BINARY_COST, METHOD_LABELS } from '~/lexicon'
import type { AtelierInit } from '~/components/texts/textsModel'
const route = useRoute()
function numParam(name: string): number | undefined {
const raw = route.query[name]
const value = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(value) ? value : undefined
}
const initial = computed((): AtelierInit => {
const init: AtelierInit = {}
const W = numParam('W')
const T = numParam('T')
const M = numParam('M')
const B = numParam('B')
const G = numParam('G')
const C = numParam('C')
const S = numParam('S')
if (W !== undefined) init.W = W
if (T !== undefined) init.T = T
if (M !== undefined) init.M = M
if (B !== undefined) init.B = B
if (G !== undefined) init.G = G
if (C !== undefined) init.C = C
if (S !== undefined) init.S = S
return init
})
</script>
<template>
<!-- ld-v2 -->
<div class="atelier">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet atelier__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<header class="atelier__header">
<h1 class="atelier__title">L'Atelier des formules</h1>
<p class="atelier__subtitle">
La pédagogie du seuil c'est ici, et seulement ici, que vivent les lettres.
Partout ailleurs, l'outil parle français.
</p>
</header>
<!-- L'établi -->
<AtelierFormulaLab :key="JSON.stringify(initial)" :initial="initial" />
<!-- Le pour/contre, présenté pour ce qu'il est -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--binary">
<UIcon name="i-lucide-scale" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.binary }}</h2>
<p class="atelier__card-sub">{{ BINARY_DESCRIPTION }}</p>
</div>
</div>
<p class="atelier__prose">
Cette formule inertielle est un héritage de la Toile de Confiance Ğ1 : quand
des milliers de membres sont éligibles et que peu s'expriment, elle exige
presque l'unanimité ; quand la participation monte, le seuil descend vers la
majorité cible. C'est une protection remarquable pour les très grands corps
et un outil d'exception : son coût reste « {{ BINARY_COST }} ».
Pour la vie courante d'un collectif, le consentement, le vote nuancé et le
réglage collectif font mieux vivre le désaccord.
</p>
</section>
<!-- Le Réglage collectif : la médiane basse expliquée -->
<section class="atelier__card ld-card">
<div class="atelier__card-head">
<span class="atelier__card-icon atelier__card-icon--parametric">
<UIcon name="i-lucide-sliders-horizontal" />
</span>
<div>
<h2 class="atelier__card-title">{{ METHOD_LABELS.parametric }}</h2>
<p class="atelier__card-sub">décider au curseur la médiane basse, expliquée en jouant</p>
</div>
</div>
<p class="atelier__prose">
Quand la question est un nombre un montant, un taux, une répartition
voter pour ou contre appauvrit la question. Chacun pose son curseur, et le
collectif retient la médiane basse. Bouge les cinq curseurs-votes ci-dessous :
tire un vote vers l'extrême, la médiane bouge à peine.
</p>
<AtelierMedianDemo />
</section>
</div>
</template>
<style scoped>
.atelier {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.atelier__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.atelier__header { display: flex; flex-direction: column; gap: 0.35rem; }
.atelier__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.atelier__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.55;
max-width: 38rem;
}
.atelier__card {
padding: clamp(1rem, 3vw, 1.4rem);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.atelier__card-head { display: flex; align-items: center; gap: 0.8rem; }
.atelier__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
font-size: 1.3rem;
flex-shrink: 0;
}
.atelier__card-icon--binary {
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
color: var(--mood-secondary);
}
.atelier__card-icon--parametric {
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
color: var(--mood-tertiary);
}
.atelier__card-title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
.atelier__card-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.atelier__prose {
font-size: 0.875rem;
line-height: 1.65;
color: var(--mood-text);
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
/**
* /textes la bibliothèque des documents sous vote permanent.
* Pacte épinglé en tête, cartes documents (clauses, sessions en cours,
* provenance résumée), lien vers l'Atelier des formules, filtre gravées .
*/
import type { TextDoc } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
engravedCount,
liveClauseGauges,
type ClauseSessionGauge,
} from '~/components/texts/textsModel'
const store = useCollectiveStore()
interface DocEntry {
doc: TextDoc
clauseCount: number
gauges: ClauseSessionGauge[]
engraved: number
}
function entryFor(doc: TextDoc): DocEntry {
const clauses = store.clauses.filter(c => c.docId === doc.id)
return {
doc,
clauseCount: clauses.length,
gauges: liveClauseGauges(clauses, store.decisions, store.sessions, store.votes),
engraved: engravedCount(clauses, store.decisions),
}
}
const pactEntry = computed((): DocEntry | null =>
store.pactDoc ? entryFor(store.pactDoc) : null)
const referenceEntries = computed((): DocEntry[] =>
store.docs
.filter(d => d.id !== store.pactDoc?.id)
.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
.map(entryFor))
// Filtre gravées
const onlyEngraved = ref(false)
const visibleEntries = computed(() =>
onlyEngraved.value
? referenceEntries.value.filter(entry => entry.engraved > 0)
: referenceEntries.value)
const pactVisible = computed(() =>
pactEntry.value !== null && (!onlyEngraved.value || pactEntry.value.engraved > 0))
const liveSessionTotal = computed(() =>
(pactEntry.value?.gauges.length ?? 0)
+ referenceEntries.value.reduce((n, entry) => n + entry.gauges.length, 0))
</script>
<template>
<!-- ld-v2 -->
<div class="texts-page">
<header class="texts-page__header">
<div>
<h1 class="texts-page__title">Textes</h1>
<p class="texts-page__subtitle">la bibliothèque des documents sous vote permanent</p>
</div>
<button
type="button"
class="texts-page__filter"
:class="{ 'texts-page__filter--on': onlyEngraved }"
@click="onlyEngraved = !onlyEngraved"
>
<span class="texts-page__filter-seal"></span>
gravées
</button>
</header>
<p v-if="liveSessionTotal > 0" class="texts-page__pulse">
<UIcon name="i-lucide-activity" />
{{ liveSessionTotal }} session{{ liveSessionTotal > 1 ? 's' : '' }} de vote en cours
sur ces textes le document vit.
</p>
<!-- Le Pacte, épinglé -->
<section v-if="pactVisible && pactEntry" class="texts-page__pact">
<div class="texts-page__pact-banner">
<UIcon name="i-lucide-pin" class="texts-page__pin" />
<span class="texts-page__pact-badge">{{ PACT_BADGE }}</span>
<span class="texts-page__pact-subtitle">{{ PACT_SUBTITLE }}</span>
</div>
<TextDocCard
:doc="pactEntry.doc"
:clause-count="pactEntry.clauseCount"
:gauges="pactEntry.gauges"
:engraved="pactEntry.engraved"
pinned
/>
</section>
<!-- Les documents de référence -->
<section v-if="visibleEntries.length" class="texts-page__grid">
<TextDocCard
v-for="entry in visibleEntries"
:key="entry.doc.id"
:doc="entry.doc"
:clause-count="entry.clauseCount"
:gauges="entry.gauges"
:engraved="entry.engraved"
/>
</section>
<p v-else-if="onlyEngraved" class="texts-page__empty">
Aucun texte gravé pour l'instant la gravure vient avec les décisions qui comptent.
</p>
<p v-else-if="!pactEntry" class="texts-page__empty">
Aucun texte ici pour l'instant les documents naissent des décisions.
</p>
<!-- L'Atelier des formules -->
<NuxtLink to="/textes/formules" class="texts-page__atelier ld-card ld-card--hover">
<span class="texts-page__atelier-icon">
<UIcon name="i-lucide-flask-conical" />
</span>
<span class="texts-page__atelier-text">
<span class="texts-page__atelier-title">L'Atelier des formules</span>
<span class="texts-page__atelier-sub">
la pédagogie du seuil pourquoi 94 pour quand 120 votent sur 7 224
</span>
</span>
<UIcon name="i-lucide-arrow-right" class="texts-page__atelier-arrow" />
</NuxtLink>
</div>
</template>
<style scoped>
.texts-page {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.texts-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.texts-page__title {
font-size: clamp(1.5rem, 5vw, 1.9rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.texts-page__subtitle { font-size: 0.875rem; color: var(--mood-text-muted); margin-top: 2px; }
.texts-page__filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.35rem 1rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
background: var(--mood-surface);
color: var(--mood-text-muted);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.texts-page__filter:hover { transform: translateY(-1px); }
.texts-page__filter--on {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
box-shadow: 0 0 0 2px var(--mood-status-vigueur);
}
.texts-page__filter-seal { font-size: 1rem; font-weight: 800; }
.texts-page__pulse {
display: inline-flex;
align-items: center;
gap: 0.45rem;
align-self: flex-start;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-vote);
background: var(--mood-status-vote-bg);
padding: 0.35rem 0.9rem;
border-radius: var(--r-pill);
}
.texts-page__pact { display: flex; flex-direction: column; gap: 0.5rem; }
.texts-page__pact-banner {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.texts-page__pin { color: var(--mood-accent); font-size: 0.95rem; }
.texts-page__pact-badge {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.texts-page__pact-subtitle {
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.texts-page__grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
}
@media (min-width: 768px) { .texts-page__grid { grid-template-columns: 1fr 1fr; } }
.texts-page__empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-style: italic;
padding: 1rem 0;
}
.texts-page__atelier {
display: flex;
align-items: center;
gap: 0.9rem;
padding: clamp(0.9rem, 3vw, 1.2rem);
text-decoration: none;
color: inherit;
}
.texts-page__atelier-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.3rem;
flex-shrink: 0;
}
.texts-page__atelier-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.texts-page__atelier-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.texts-page__atelier-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.texts-page__atelier-arrow {
margin-left: auto;
color: var(--mood-text-muted);
flex-shrink: 0;
}
</style>
-452
View File
@@ -1,452 +0,0 @@
<script setup lang="ts">
/**
* Tools page lists tools grouped by main section.
* Each section shows relevant tools for Documents, Decisions, Mandates, Protocols.
*/
interface Tool {
label: string
icon: string
description: string
to?: string
status: 'ready' | 'soon'
}
interface ToolSection {
key: string
title: string
icon: string
color: string
tools: Tool[]
}
const expandSocio = ref(false)
const sections: ToolSection[] = [
{
key: 'documents',
title: 'Documents de référence',
icon: 'i-lucide-book-open',
color: 'var(--mood-accent)',
tools: [
{ label: 'Modules', icon: 'i-lucide-puzzle', description: 'Structurer un document en sections et clauses modulaires', to: '/documents', status: 'ready' },
{ label: 'Votes permanents', icon: 'i-lucide-infinity', description: 'Chaque clause est sous vote permanent, modifiable à tout moment', status: 'ready' },
{ label: 'Inertie de remplacement', icon: 'i-lucide-sliders-horizontal', description: 'Régler la difficulté de modification par section (standard, haute, très haute)', to: '/protocols/formulas', status: 'ready' },
{ label: 'Contre-propositions', icon: 'i-lucide-pen-line', description: 'Soumettre un texte alternatif soumis au vote de la communauté', status: 'ready' },
{ label: 'Ancrage IPFS', icon: 'i-lucide-hard-drive', description: 'Archiver les documents validés sur IPFS avec preuve on-chain', status: 'soon' },
],
},
{
key: 'decisions',
title: 'Décisions et consultation d\'avis',
icon: 'i-lucide-scale',
color: 'var(--mood-secondary, var(--mood-accent))',
tools: [
{ label: 'Vote majoritaire WoT', icon: 'i-lucide-check-circle', description: 'Seuil adaptatif par la toile de confiance, formule g1vote', to: '/protocols/formulas', status: 'ready' },
{ label: 'Vote quadratique', icon: 'i-lucide-square-stack', description: 'Pondération dégressive pour éviter la concentration de pouvoir', status: 'soon' },
{ label: 'Vote nuancé 6 niveaux', icon: 'i-lucide-bar-chart-3', description: 'De Tout à fait contre à Tout à fait pour, avec seuil de satisfaction', status: 'ready' },
{ label: 'Mandature', icon: 'i-lucide-user-check', description: 'Élection et nomination en binôme avec transparence', status: 'ready' },
{ label: 'Multi-critères', icon: 'i-lucide-layers', description: 'Combinaison WoT + Smith + TechComm, tous doivent passer', to: '/protocols/formulas', status: 'ready' },
],
},
{
key: 'mandats',
title: 'Mandats et nominations',
icon: 'i-lucide-user-check',
color: 'var(--mood-success)',
tools: [
{ label: 'Ouverture', icon: 'i-lucide-door-open', description: 'Définir une mission, son périmètre, sa durée et ses objectifs', status: 'ready' },
{ label: 'Nomination', icon: 'i-lucide-users', description: 'Élection en binôme : un titulaire + un suppléant', status: 'ready' },
{ label: 'Transparence', icon: 'i-lucide-eye', description: 'Rapports d\'activité périodiques soumis au vote', status: 'ready' },
{ label: 'Clôture', icon: 'i-lucide-lock', description: 'Fin de mandat avec bilan ou révocation anticipée par vote', status: 'ready' },
],
},
{
key: 'protocoles',
title: 'Protocoles et fonctionnement',
icon: 'i-lucide-settings',
color: 'var(--mood-tertiary, var(--mood-accent))',
tools: [
{ label: 'Simulateur de formules', icon: 'i-lucide-calculator', description: 'Tester les paramètres de seuil WoT en temps réel', to: '/protocols/formulas', status: 'ready' },
{ label: 'Méta-gouvernance', icon: 'i-lucide-shield', description: 'Les formules elles-mêmes sont soumises au vote', status: 'ready' },
{ label: 'Workflows n8n', icon: 'i-lucide-workflow', description: 'Automatisations optionnelles (notifications, alertes, relances)', status: 'soon' },
{ label: 'Protocoles opérationnels', icon: 'i-lucide-git-branch', description: 'Processus multi-étapes réutilisables (embarquement, upgrade)', to: '/protocols', status: 'ready' },
],
},
]
</script>
<template>
<div class="tools-page">
<!-- Back link -->
<div class="tools-page__nav">
<UButton
to="/"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour à l'accueil"
size="sm"
/>
</div>
<!-- Header -->
<div class="tools-page__header">
<h1 class="tools-page__title">
<UIcon name="i-lucide-wrench" class="tools-page__title-icon" />
Boîte à outils
</h1>
<p class="tools-page__subtitle">
Tous les outils de décision collective, organisés par section
</p>
</div>
<!-- Tool sections -->
<div class="tools-page__sections">
<div
v-for="section in sections"
:key="section.key"
class="tools-section"
:style="{ '--section-color': section.color }"
>
<div class="tools-section__header">
<UIcon :name="section.icon" class="tools-section__icon" />
<h2 class="tools-section__title">{{ section.title }}</h2>
<span class="tools-section__count">{{ section.tools.length }}</span>
</div>
<div class="tools-section__grid">
<NuxtLink
v-for="tool in section.tools.filter(t => t.to)"
:key="tool.label"
:to="tool.to!"
class="tool-card"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
<UIcon name="i-lucide-chevron-right" class="tool-card__arrow" />
</NuxtLink>
<div
v-for="tool in section.tools.filter(t => !t.to)"
:key="tool.label"
class="tool-card"
:class="{ 'tool-card--soon': tool.status === 'soon' }"
>
<div class="tool-card__icon">
<UIcon :name="tool.icon" />
</div>
<div class="tool-card__body">
<div class="tool-card__head">
<span class="tool-card__label">{{ tool.label }}</span>
<span v-if="tool.status === 'soon'" class="tool-card__badge">bientôt</span>
</div>
<p class="tool-card__desc">{{ tool.description }}</p>
</div>
</div>
</div>
<!-- Election sociocratique modalité d'élection, accessible depuis mandats -->
<div v-if="section.key === 'mandats'" class="socio-expand">
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
<div class="socio-expand__icon">
<UIcon name="i-lucide-users" class="text-sm" />
</div>
<div class="socio-expand__info">
<span class="socio-expand__title">Élection sociocratique</span>
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
</div>
<span class="socio-expand__tag">Modalité d'élection</span>
<UIcon
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="socio-expand__toggle"
/>
</button>
<Transition name="socio-expand">
<div v-if="expandSocio" class="socio-expand__content">
<SocioElection />
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tools-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.tools-page__nav {
margin-bottom: -0.5rem;
}
.tools-page__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tools-page__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
@media (min-width: 640px) {
.tools-page__title {
font-size: 1.875rem;
}
}
.tools-page__title-icon {
color: var(--mood-accent);
}
.tools-page__subtitle {
font-size: 0.9375rem;
color: var(--mood-text-muted);
font-weight: 500;
}
/* Sections */
.tools-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.tools-section__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tools-section__icon {
font-size: 1.125rem;
color: var(--section-color);
}
.tools-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.tools-section__count {
font-size: 0.6875rem;
font-weight: 700;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
padding: 2px 8px;
border-radius: 20px;
}
/* Tool cards */
.tools-section__grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tool-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border-radius: 14px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px var(--mood-shadow);
}
.tool-card--soon {
opacity: 0.6;
cursor: default;
}
.tool-card--soon:hover {
transform: none;
box-shadow: none;
}
.tool-card__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--section-color) 12%, transparent);
color: var(--section-color);
font-size: 0.875rem;
}
.tool-card__body {
flex: 1;
min-width: 0;
}
.tool-card__head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.tool-card__label {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.tool-card__badge {
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.tool-card__desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0.125rem 0 0;
}
.tool-card__arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
margin-top: 0.375rem;
transition: all 0.12s;
}
.tool-card:hover .tool-card__arrow {
opacity: 1;
color: var(--section-color);
}
/* --- Élection sociocratique expandable --- */
.socio-expand {
margin-top: 0.5rem;
border-radius: 16px;
overflow: hidden;
background: var(--mood-surface);
}
.socio-expand__trigger {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 1.125rem;
cursor: pointer;
background: none;
text-align: left;
transition: background 0.12s;
}
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
.socio-expand__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
}
.socio-expand__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.socio-expand__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.socio-expand__meta {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.socio-expand__tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 480px) {
.socio-expand__tag { display: none; }
}
.socio-expand__toggle {
flex-shrink: 0;
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.socio-expand__content {
padding: 0 1rem 1rem;
}
.socio-expand-enter-active,
.socio-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.socio-expand-enter-from,
.socio-expand-leave-to {
max-height: 0;
opacity: 0;
}
.socio-expand-enter-to,
.socio-expand-leave-from {
max-height: 2000px;
opacity: 1;
}
</style>
+28 -1
View File
@@ -157,7 +157,7 @@ describe('La table des transitions', () => {
expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected'])
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected', 'framing'])
expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
})
@@ -621,3 +621,30 @@ describe('windowOutcome — l’échéance des fenêtres dobjection', () => {
expect(windowOutcome(decision, makeCtx())).toBe('wait')
})
})
describe('Garde j — reformuler (voting→framing) réservé au réglage collectif figé', () => {
it('session figée non cristallisée — reformuler est permis', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(true)
})
it('session ouverte — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'open' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('session cristallisée — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({
session: makeSession({ status: 'closed', crystallizedById: 'p-steward' }),
})
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('sans session — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
expect(canTransition(decision, 'framing', makeCtx()).ok).toBe(false)
})
})