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 errorsuccess, 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>