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