v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
export default defineAppConfig({
ui: {
button: {
variants: {
variant: {
solid: 'border-0',
outline: 'border-0',
soft: 'border-0',
subtle: 'border-0',
ghost: 'border-0',
link: 'border-0',
},
},
},
badge: {
variants: {
variant: {
solid: 'border-0',
outline: 'border-0',
soft: 'border-0',
subtle: 'border-0',
},
},
},
input: {
variants: {
variant: {
outline: 'border-0 ring-1 ring-[var(--mood-input-border)] focus:ring-[var(--mood-input-focus)]',
},
},
},
},
})
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
// Shell v2 — app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
// Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
import { useCollectiveStore } from '~/stores/collective'
const { initMood } = useLibreMood()
onMounted(() => {
initMood()
useCollectiveStore().init?.()
})
</script>
<template>
<!-- ld-v2 -->
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
+351
View File
@@ -0,0 +1,351 @@
/* ==========================================================================
libreDecision v2 — Ambiances (champ lexical du puits 井)
Source (light, eau claire) · Margelle (light, pierre chaude)
Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne)
Borderless : profondeur par background + box-shadow, jamais de bordures.
========================================================================== */
/* --------------------------------------------------------------------------
Source — l'eau vive au grand jour (Light)
-------------------------------------------------------------------------- */
.mood-source {
--mood-bg: #f0f5f6;
--mood-surface: #fcfefe;
--mood-surface-hover: #e6eff2;
--mood-text: #102126;
--mood-text-muted: #52707a;
--mood-accent: #0f7fa8;
--mood-accent-soft: rgba(15, 127, 168, 0.09);
--mood-accent-text: #ffffff;
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
--mood-success: #1d8a4a;
--mood-warning: #b07c14;
--mood-error: #bc3d33;
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
--mood-shadow: rgba(20, 90, 120, 0.08);
--mood-input-bg: #fcfefe;
--mood-input-border: #b8ccd2;
--mood-input-focus: #0f7fa8;
--mood-status-prepa: #8a7040;
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
--mood-status-fenetre: #b07c14;
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
--mood-status-vote: #2661b8;
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
--mood-status-fige: #58708c;
--mood-status-fige-bg: rgba(88, 112, 140, 0.12);
--mood-status-vigueur: #1d8a4a;
--mood-status-vigueur-bg: rgba(29, 138, 74, 0.10);
--mood-status-revoque: #a4553a;
--mood-status-revoque-bg: rgba(164, 85, 58, 0.10);
--mood-status-clos: #5f6f74;
--mood-status-clos-bg: rgba(95, 111, 116, 0.09);
}
/* --------------------------------------------------------------------------
Margelle — la pierre du puits, calcaire et bronze (Light)
-------------------------------------------------------------------------- */
.mood-margelle {
--mood-bg: #f6f3ec;
--mood-surface: #fdfbf6;
--mood-surface-hover: #efe9dc;
--mood-text: #221c10;
--mood-text-muted: #77684c;
--mood-accent: #96682a;
--mood-accent-soft: rgba(150, 104, 42, 0.10);
--mood-accent-text: #ffffff;
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
--mood-success: #3d7e34;
--mood-warning: #a8720e;
--mood-error: #b23f30;
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
--mood-shadow: rgba(120, 90, 40, 0.09);
--mood-input-bg: #fdfbf6;
--mood-input-border: #cfc2a6;
--mood-input-focus: #96682a;
--mood-status-prepa: #7c6a48;
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
--mood-status-fenetre: #a8720e;
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
--mood-status-vote: #285e88;
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
--mood-status-fige: #6e6a58;
--mood-status-fige-bg: rgba(110, 106, 88, 0.12);
--mood-status-vigueur: #3d7e34;
--mood-status-vigueur-bg: rgba(61, 126, 52, 0.10);
--mood-status-revoque: #9c5236;
--mood-status-revoque-bg: rgba(156, 82, 54, 0.10);
--mood-status-clos: #6f6a5c;
--mood-status-clos-bg: rgba(111, 106, 92, 0.09);
}
/* --------------------------------------------------------------------------
Nappe — l'eau profonde sous la ville (Dark)
-------------------------------------------------------------------------- */
.mood-nappe {
--mood-bg: #0a141b;
--mood-surface: #101f2a;
--mood-surface-hover: #16293a;
--mood-text: #d6e3ea;
--mood-text-muted: #7e98a8;
--mood-accent: #3fa9cc;
--mood-accent-soft: rgba(63, 169, 204, 0.13);
--mood-accent-text: #06141c;
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
--mood-tertiary: #52b894;
--mood-success: #46bf7c;
--mood-warning: #d2a13c;
--mood-error: #d86458;
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
--mood-shadow: rgba(30, 120, 160, 0.14);
--mood-input-bg: #101f2a;
--mood-input-border: #24425a;
--mood-input-focus: #3fa9cc;
--mood-status-prepa: #a5987c;
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
--mood-status-fenetre: #d2a13c;
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
--mood-status-vote: #5f97e0;
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
--mood-status-fige: #7d97b5;
--mood-status-fige-bg: rgba(125, 151, 181, 0.14);
--mood-status-vigueur: #46bf7c;
--mood-status-vigueur-bg: rgba(70, 191, 124, 0.13);
--mood-status-revoque: #cd7a56;
--mood-status-revoque-bg: rgba(205, 122, 86, 0.13);
--mood-status-clos: #7d8fa0;
--mood-status-clos-bg: rgba(125, 143, 160, 0.11);
}
/* --------------------------------------------------------------------------
Minuit — l'encre, et la lanterne posée sur la margelle (Dark)
-------------------------------------------------------------------------- */
.mood-minuit {
--mood-bg: #101014;
--mood-surface: #17171e;
--mood-surface-hover: #202029;
--mood-text: #e2e2e8;
--mood-text-muted: #8e8e9e;
--mood-accent: #cf9c3e;
--mood-accent-soft: rgba(207, 156, 62, 0.12);
--mood-accent-text: #131309;
--mood-secondary: #6c94d8;
--mood-tertiary: #58ae8e;
--mood-success: #52bd7e;
--mood-warning: #cfa03a;
--mood-error: #d25f55;
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
--mood-shadow: rgba(190, 140, 50, 0.11);
--mood-input-bg: #17171e;
--mood-input-border: #33333f;
--mood-input-focus: #cf9c3e;
--mood-status-prepa: #9e9584;
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
--mood-status-fenetre: #cfa03a;
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
--mood-status-vote: #6c94d8;
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
--mood-status-fige: #8b8ba0;
--mood-status-fige-bg: rgba(139, 139, 160, 0.14);
--mood-status-vigueur: #52bd7e;
--mood-status-vigueur-bg: rgba(82, 189, 126, 0.13);
--mood-status-revoque: #c97a5c;
--mood-status-revoque-bg: rgba(201, 122, 92, 0.13);
--mood-status-clos: #85859a;
--mood-status-clos-bg: rgba(133, 133, 154, 0.11);
}
/* ==========================================================================
Tokens dérivés — routes, tailles, ombres (identiques dans toutes les ambiances)
========================================================================== */
.mood-source, .mood-margelle, .mood-nappe, .mood-minuit {
/* Routes du chemin — vocabulaire visuel fixe */
--route-solo: var(--mood-accent);
--route-mandate: var(--mood-secondary);
--route-transmit: var(--mood-text-muted);
--route-advice: var(--mood-tertiary);
--route-collective: var(--mood-status-vote);
--route-record: var(--mood-status-prepa);
--route-urgent: var(--mood-error);
/* Rayons (consignes) */
--r-card: 16px;
--r-input: 12px;
--r-pill: 20px;
--r-icon: 14px;
/* Ombres borderless — deux couches, jamais de bordure */
--shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow);
--shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow);
--shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow);
}
/* ==========================================================================
Socle global — Plus Jakarta Sans, borderless, gestes
========================================================================== */
*, *::before, *::after { border-color: transparent; }
html {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
font-size: 16px;
}
body {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
background: var(--mood-bg);
color: var(--mood-text);
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input, select, textarea {
border: none !important;
outline: none;
font-family: inherit;
color: inherit;
}
input, select, textarea {
background: var(--mood-input-bg);
border-radius: var(--r-input);
}
input:focus-visible, select:focus-visible, textarea:focus-visible,
button:focus-visible, a:focus-visible, [tabindex]:focus-visible {
outline: none;
box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* --- Cartes --- */
.ld-card {
background: var(--mood-surface);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.ld-card--hover:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-card-hover);
}
.ld-card--hover:active { transform: translateY(0); }
/* --- Boutons pill --- */
.ld-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.25rem;
padding: 0.5rem 1.25rem;
border-radius: var(--r-pill);
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
background: var(--mood-accent);
color: var(--mood-accent-text);
transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease;
user-select: none;
}
.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
.ld-btn:active { transform: translateY(0); }
.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
.ld-btn--ghost {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.ld-btn--quiet {
background: none;
color: var(--mood-text-muted);
}
.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; }
/* --- Pills d'état (mapping unique état→couleur) --- */
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 4px 13px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.01em;
user-select: none;
transition: all 0.15s ease;
}
.status-pill.is-clickable { cursor: pointer; }
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
.status-advice,
.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); }
.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; }
.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
/* Suspension (frontière contestée) : fond strié */
.status-suspended {
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre-bg),
var(--mood-status-fenetre-bg) 6px,
transparent 6px,
transparent 10px
);
color: var(--mood-status-fenetre);
}
/* --- Le tampon 井 (signature) --- */
.ld-stamp {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.85rem;
border-radius: 10px;
transform: rotate(-10deg);
font-weight: 800;
font-size: 0.8125rem;
letter-spacing: 0.04em;
color: var(--mood-status-vigueur);
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
background: var(--mood-status-vigueur-bg);
user-select: none;
}
/* --- Sceau hexagramme #48 Tsing --- */
.app-seal {
display: block;
width: 44px;
color: var(--mood-accent);
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15));
}
/* --- Impression A4 (PV, affiches de session) --- */
@media print {
body { background: #ffffff !important; color: #111111 !important; }
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
.ld-card { box-shadow: none !important; }
.print-only { display: block !important; }
}
.print-only { display: none; }
@@ -0,0 +1,147 @@
/*
* Overlays Nuxt UI (UModal / USlideover) — géométrie posée à la main.
* Les composants @nuxt/ui portent des classes utilitaires tailwind que ce
* projet ne génère pas (UnoCSS ne scanne pas node_modules) : sans ces règles,
* voile et panneaux se rendent à leur position statique, en fin de <body>,
* donc hors écran. On cible la structure stable posée par reka-ui.
*/
/* Voile */
body > div.fixed.inset-0[data-state] {
position: fixed;
inset: 0;
z-index: 90;
background: rgb(15 20 25 / 0.45);
backdrop-filter: blur(2px);
}
body > div.fixed.inset-0[data-state='open'] {
animation: ldov-fade-in 0.15s ease;
}
/* Panneau latéral (USlideover — drawer mobile) */
body > [role='dialog'][data-side] {
position: fixed;
top: 0;
bottom: 0;
width: min(20rem, 86vw);
z-index: 91;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--mood-surface);
color: var(--mood-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
}
body > [role='dialog'][data-side='left'] {
left: 0;
right: auto;
border-radius: 0 16px 16px 0;
}
body > [role='dialog'][data-side='right'] {
right: 0;
left: auto;
border-radius: 16px 0 0 16px;
}
body > [role='dialog'][data-side='left'][data-state='open'] {
animation: ldov-slide-left 0.18s ease;
}
body > [role='dialog'][data-side='right'][data-state='open'] {
animation: ldov-slide-right 0.18s ease;
}
/* Modale centrée (UModal) */
body > [role='dialog']:not([data-side]) {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(26rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow-y: auto;
z-index: 91;
background: var(--mood-surface);
color: var(--mood-text);
border-radius: 16px;
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
}
body > [role='dialog']:not([data-side])[data-state='open'] {
animation: ldov-pop 0.15s ease;
}
/* Blocs internes générés par Nuxt UI — classes posées via la prop :ui */
.ldov-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 3.5rem 0.5rem 1.25rem;
}
.ldov-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
/* description a11y sans rendu visuel (lecteurs d'écran seulement) */
.ldov-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ldov-desc {
margin: 0;
padding: 0 1.25rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.ldov-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 1.25rem 1.25rem;
}
.ldov-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
min-height: 2.25rem;
border-radius: 14px;
background: var(--mood-input-bg);
color: var(--mood-text);
cursor: pointer;
transition: background 0.1s ease, transform 0.1s ease;
}
.ldov-close:hover {
background: var(--mood-accent-soft);
transform: translateY(-1px);
}
.ldov-close:active {
transform: translateY(0);
}
@keyframes ldov-fade-in {
from { opacity: 0; }
}
@keyframes ldov-slide-left {
from { transform: translateX(-100%); }
}
@keyframes ldov-slide-right {
from { transform: translateX(100%); }
}
@keyframes ldov-pop {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.96);
}
}
@@ -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>
@@ -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>
@@ -0,0 +1,100 @@
<script setup lang="ts">
// <!-- ld-v2 --> Stacked initials avatars — deterministic tint from id, -8px overlap.
// origin glyphs: 'computed' = full dot (concerné·e en premier lieu),
// 'declared' = raised hand (en second lieu). Tap → parent shows inclusion reason.
import type { Person } from '~/types/domain'
const props = withDefaults(defineProps<{
people: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
max?: number
size?: number
}>(), { max: 6, size: 30 })
const emit = defineEmits<{ (e: 'tap', entry: { person: Person; origin?: string; reason?: string }): void }>()
const visible = computed(() => props.people.slice(0, props.max))
const overflow = computed(() => Math.max(0, props.people.length - props.max))
function initials(name: string): string {
return name.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})`
}
</script>
<template>
<!-- ld-v2 -->
<div class="ld-avatars" :style="{ '--av-size': size + 'px' }">
<button
v-for="entry in visible"
:key="entry.person.id"
class="ld-avatars__item"
:style="{ background: tint(entry.person.id) }"
:title="entry.reason ? `${entry.person.displayName} — ${entry.reason}` : entry.person.displayName"
type="button"
@click="emit('tap', entry)"
>
<span>{{ initials(entry.person.displayName) }}</span>
<span v-if="entry.origin === 'declared'" class="ld-avatars__glyph">
<UIcon name="i-lucide-hand" />
</span>
<span v-else-if="entry.origin === 'computed'" class="ld-avatars__dot" />
</button>
<span v-if="overflow > 0" class="ld-avatars__more">+{{ overflow }}</span>
</div>
</template>
<style scoped>
.ld-avatars { display: inline-flex; align-items: center; }
.ld-avatars__item {
position: relative;
width: var(--av-size);
height: var(--av-size);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: calc(var(--av-size) * 0.36);
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
margin-left: -8px;
box-shadow: 0 0 0 2px var(--mood-surface);
cursor: pointer;
transition: transform 0.1s ease;
}
.ld-avatars__item:first-child { margin-left: 0; }
.ld-avatars__item:hover { transform: translateY(-1px); z-index: 1; }
.ld-avatars__glyph {
position: absolute;
right: -3px;
bottom: -3px;
width: calc(var(--av-size) * 0.48);
height: calc(var(--av-size) * 0.48);
border-radius: 50%;
background: var(--mood-surface);
color: var(--mood-accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: calc(var(--av-size) * 0.3);
}
.ld-avatars__dot {
position: absolute;
right: -1px;
bottom: -1px;
width: calc(var(--av-size) * 0.28);
height: calc(var(--av-size) * 0.28);
border-radius: 50%;
background: var(--mood-accent);
box-shadow: 0 0 0 2px var(--mood-surface);
}
.ld-avatars__more {
margin-left: 0.4rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// <!-- ld-v2 --> Window countdown chip. Suspended (boundary objection) shows the
// pause state — the most important state to make visible.
import { BOUNDARY_SUSPENDED } from '~/lexicon'
const props = defineProps<{
endsAt?: string
suspendedAt?: string
}>()
const label = computed(() => {
if (props.suspendedAt) return BOUNDARY_SUSPENDED
if (!props.endsAt) return ''
const ms = new Date(props.endsAt).getTime() - Date.now()
if (ms <= 0) return 'échéance passée'
const h = Math.floor(ms / 3_600_000)
if (h < 1) return `${Math.max(1, Math.floor(ms / 60_000))} min restantes`
if (h < 48) return `${h} h restantes`
return `${Math.floor(h / 24)} jours restants`
})
</script>
<template>
<!-- ld-v2 -->
<span class="ld-countdown" :class="{ 'ld-countdown--paused': suspendedAt }">
<UIcon :name="suspendedAt ? 'i-lucide-pause' : 'i-lucide-timer'" />
<span>{{ label }}</span>
</span>
</template>
<style scoped>
.ld-countdown {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.ld-countdown--paused {
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre-bg),
var(--mood-status-fenetre-bg) 6px,
transparent 6px,
transparent 10px
);
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
/** Quatre pastilles rondes — une par ambiance. Ring accent sur l'actif. */
const { moods, currentMood, setMood } = useLibreMood()
</script>
<template>
<!-- ld-v2 -->
<div class="mood-switcher" role="group" aria-label="Ambiance">
<UTooltip v-for="m in moods" :key="m.id" :text="m.label">
<button
class="mood-dot"
:class="{ 'mood-dot--active': currentMood === m.id }"
:style="{ background: m.color }"
:aria-label="m.label"
:aria-pressed="currentMood === m.id"
@click="setMood(m.id)"
/>
</UTooltip>
</div>
</template>
<style scoped>
.mood-switcher {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
}
.mood-dot {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
cursor: pointer;
padding: 0;
flex-shrink: 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;
}
.mood-dot:hover {
transform: translateY(-1px) scale(1.12);
}
.mood-dot:active {
transform: translateY(0);
}
.mood-dot--active {
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
0 0 0 2px var(--mood-surface),
0 0 0 4px var(--mood-accent);
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
/**
* Sceau hexagramme #48 Tsing 井 — géométrie SVG canonique.
* viewBox 112×105, 6 traits (trait 1 en bas = dernier rect), coins vifs.
* Réutilisable : stamp du logo (miniature), sceau de page (.app-seal), badges.
*/
const props = withDefaults(defineProps<{ size?: number }>(), { size: 44 })
const height = computed(() => Math.round((props.size * 105) / 112))
</script>
<template>
<!-- ld-v2 -->
<svg
:width="size"
:height="height"
viewBox="0 0 112 105"
fill="currentColor"
aria-hidden="true"
>
<!-- Trait 6 (haut) — yin -->
<rect x="6" y="6" width="42" height="8" />
<rect x="64" y="6" width="42" height="8" />
<!-- Trait 5 — yang -->
<rect x="6" y="23" width="100" height="8" />
<!-- Trait 4 — yin -->
<rect x="6" y="40" width="42" height="8" />
<rect x="64" y="40" width="42" height="8" />
<!-- Trait 3 — yang -->
<rect x="6" y="57" width="100" height="8" />
<!-- Trait 2 — yang -->
<rect x="6" y="74" width="100" height="8" />
<!-- Trait 1 (bas) — yin -->
<rect x="6" y="91" width="42" height="8" />
<rect x="64" y="91" width="42" height="8" />
</svg>
</template>
@@ -0,0 +1,242 @@
<script setup lang="ts">
/**
* Sélecteur de collectif — pastille couleur + nom + finalité (clause A1 du Pacte)
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
* Accès défensifs (?.) : le store peut ne pas être hydraté.
*/
import { useCollectiveStore } from '~/stores/collective'
const store = useCollectiveStore()
const route = useRoute()
const open = ref(false)
const rootEl = ref<HTMLElement | null>(null)
onClickOutside(rootEl, () => { open.value = false })
watch(() => route.path, () => { open.value = false })
const current = computed(() => store.current?.collective ?? null)
const index = computed(() => store.index ?? [])
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
const purpose = computed(() => {
const state = store.current
if (!state) return ''
const clause = state.clauses?.find(c => c.code === 'A1')
if (!clause?.currentVersionId) return ''
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
})
async function pick(id: string) {
open.value = false
if (id !== store.activeId) await store.switchTo?.(id)
}
</script>
<template>
<!-- ld-v2 -->
<div ref="rootEl" class="ws">
<!-- Aucun collectif : inviter à créer -->
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
<UIcon name="i-lucide-sparkles" />
<span>Créer</span>
</NuxtLink>
<template v-else>
<button
class="ws__trigger"
:aria-expanded="open"
aria-haspopup="menu"
@click="open = !open"
>
<span class="ws__dot" :style="{ background: current.color }" />
<span class="ws__text">
<span class="ws__name">{{ current.name }}</span>
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
</span>
<UIcon
name="i-lucide-chevron-down"
class="ws__chevron"
:class="{ 'ws__chevron--open': open }"
/>
</button>
<Transition name="ws-pop">
<div v-if="open" class="ws__menu ld-card" role="menu">
<button
v-for="entry in index"
:key="entry.id"
class="ws__item"
:class="{ 'ws__item--active': entry.id === store.activeId }"
role="menuitem"
@click="pick(entry.id)"
>
<span class="ws__dot" :style="{ background: entry.color }" />
<span class="ws__item-name">{{ entry.name }}</span>
<UIcon
v-if="entry.id === store.activeId"
name="i-lucide-check"
class="ws__item-check"
/>
</button>
<div class="ws__sep" />
<NuxtLink to="/creer" class="ws__item" role="menuitem">
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
<span class="ws__item-name">Créer un collectif</span>
</NuxtLink>
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
<span class="ws__item-name">Données locales</span>
</NuxtLink>
</div>
</Transition>
</template>
</div>
</template>
<style scoped>
.ws {
position: relative;
display: inline-flex;
max-width: 100%;
}
.ws__create {
font-size: 0.875rem;
padding: 0.4rem 1rem;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.625rem;
max-width: 20rem;
padding: 0.375rem 0.75rem;
background: none;
border-radius: var(--r-input);
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__trigger:hover {
background: var(--mood-accent-soft);
}
.ws__dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 1px 3px var(--mood-shadow);
}
.ws__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.ws__name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__purpose {
font-size: 0.75rem;
color: var(--mood-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
}
.ws__chevron {
font-size: 0.875rem;
color: var(--mood-text-muted);
flex-shrink: 0;
transition: transform 0.15s ease;
}
.ws__chevron--open {
transform: rotate(180deg);
}
/* --- Menu --- */
.ws__menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 40;
min-width: 15rem;
max-width: 20rem;
padding: 0.375rem;
box-shadow: var(--shadow-raised);
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
background: none;
border-radius: var(--r-input);
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
text-decoration: none;
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__item:hover {
background: var(--mood-accent-soft);
}
.ws__item--active {
color: var(--mood-accent);
font-weight: 700;
}
.ws__item-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__item-check {
font-size: 0.875rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.ws__item-icon {
font-size: 1rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
.ws__sep {
height: 1px;
margin: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
}
/* --- Transition --- */
.ws-pop-enter-active,
.ws-pop-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.ws-pop-enter-from,
.ws-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
const props = defineProps<{
content: string
}>()
/**
* Basic markdown rendering: bold, line breaks, unordered lists.
* Returns sanitized HTML string for v-html usage.
*/
const renderedHtml = computed(() => {
if (!props.content) return ''
let html = props.content
// Escape HTML entities to prevent XSS
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Convert **bold** to <strong>
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Convert *italic* to <em>
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Convert `code` to <code>
html = html.replace(/`([^`]+)`/g, '<code class="md-code">$1</code>')
// Process lines for list items
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('- ')) {
if (!inList) {
result.push('<ul class="md-list">')
inList = true
}
result.push(`<li>${trimmed.slice(2)}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
if (trimmed === '') {
result.push('<br>')
} else {
result.push(`<p class="md-para">${line}</p>`)
}
}
}
if (inList) {
result.push('</ul>')
}
return result.join('\n')
})
</script>
<template>
<div
class="md-rendered"
v-html="renderedHtml"
/>
</template>
<style scoped>
.md-rendered {
color: var(--mood-text);
line-height: 1.5;
}
.md-rendered :deep(.md-list) {
list-style-type: disc;
padding-left: 1.25em;
margin: 0.125em 0;
}
.md-rendered :deep(.md-list li) {
padding: 0;
line-height: 1.4;
}
.md-rendered :deep(.md-para) {
margin: 0.1em 0;
}
.md-rendered :deep(.md-code) {
font-family: monospace;
font-size: 0.875em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-bg));
color: var(--mood-text);
}
.md-rendered :deep(strong) {
font-weight: 700;
color: var(--mood-text);
}
.md-rendered :deep(em) {
font-style: italic;
color: var(--mood-text-muted);
font-size: 0.875em;
}
</style>
@@ -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,273 @@
<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;
}
@media (max-width: 768px) {
/* la table ne sait pas rétrécir sous la largeur de son contenu : elle défile */
.vig__table { display: block; overflow-x: auto; }
}
.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>
@@ -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>
@@ -0,0 +1,132 @@
<script setup lang="ts">
// <!-- 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 col = useCollectiveStore()
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>
<!-- 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>
<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 dû 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 dû -->
<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">dû 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 dû 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 dû — 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>
@@ -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,258 @@
<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"
:title="confirmEntry ? `Supprimer « ${confirmEntry.name} » ?` : 'Supprimer'"
description="Ce geste supprime uniquement la copie locale, sur cette machine."
>
<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,329 @@
<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()
}
// ── « C'est moi » — un collectif importé (seed, fichier) n'a aucun isMe :
// on se désigne parmi les personnes existantes, ou on s'ajoute.
const newMeName = ref('')
function claimMe(personId: string) {
const current = store.current
if (!current || store.me) return
const person = current.people.find(p => p.id === personId && !p.archivedAt)
if (!person) return
person.isMe = true
store.stamp(person)
store.persist()
}
function createMe() {
const current = store.current
const displayName = newMeName.value.trim()
if (!current || store.me || !displayName) return
const now = store.now()
current.people.push({
id: store.newId(),
collectiveId: current.collective.id,
createdAt: now,
updatedAt: now,
displayName,
isMe: true,
})
newMeName.value = ''
store.persist()
}
</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.current" class="data-card__empty">
Aucun collectif actif — crée ou importe un collectif pour poser ton identité.
</p>
<template v-else-if="!store.me">
<p class="data-card__text">
Personne n'est encore désigné·e comme toi dans ce collectif —
retrouve-toi dans la liste, ou ajoute-toi.
</p>
<div class="claim-chips">
<button
v-for="person in store.people"
:key="person.id"
type="button"
class="claim-chip"
@click="claimMe(person.id)"
>
<UIcon name="i-lucide-user-round-check" />
<span>{{ person.displayName }}</span>
<span class="claim-chip__me">c'est moi</span>
</button>
</div>
<form class="claim-new" @submit.prevent="createMe">
<input
v-model="newMeName"
class="field__input claim-new__input"
type="text"
placeholder="Ton prénom ici"
autocomplete="off"
>
<button type="submit" class="ld-btn" :disabled="newMeName.trim().length === 0">
<UIcon name="i-lucide-plus" /> Je m'ajoute
</button>
</form>
</template>
<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; }
/* « C'est moi » — désignation dans un collectif importé */
.claim-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.claim-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.35rem 0.8rem;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 600;
background: var(--mood-input-bg);
color: var(--mood-text);
cursor: pointer;
transition: background 0.1s ease, transform 0.1s ease;
}
.claim-chip:hover {
background: var(--mood-accent-soft);
transform: translateY(-1px);
}
.claim-chip:active { transform: translateY(0); }
.claim-chip__me {
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-accent);
}
.claim-new {
display: flex;
align-items: center;
gap: 0.5rem;
}
.claim-new__input { flex: 1; min-width: 0; }
</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; max-width: 100%; overflow-x: auto; }
.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>
@@ -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,
}
}
@@ -0,0 +1,203 @@
<script setup lang="ts">
// <!-- 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<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
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)
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}`
})
// ── 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))
// ── 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
}
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
</script>
<template>
<!-- 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
</button>
<button class="ld-btn ld-btn--ghost vb__no" type="button" :disabled="!canAct" @click="refusing = !refusing">
Contre
</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>
<!-- 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 n’adopte 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>
@@ -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 ? 'quelqu’un' : 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 ? 'quelqu’un' : 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); overflow-wrap: anywhere;
}
.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>
@@ -0,0 +1,192 @@
<script setup lang="ts">
// <!-- ld-v2 --> Nuancé 6 niveaux — segments en gradient sémantique dérivé des
// custom properties du mood (color-mix error→success, jamais de couleurs crues),
// commentaire obligatoire sous 0-1, histogramme de la distribution en cours.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { NUANCED_LABELS } from '~/lexicon'
import type { Decision, Id, NuancedValue, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
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 comment = ref('')
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),
)
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 = ''
}
// ── 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 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()))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vn">
<!-- La jauge : 6 segments -->
<div class="vn__scale" role="radiogroup" aria-label="Nuance">
<button
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"
>
<span class="vn__segment-value">{{ level }}</span>
<span class="vn__segment-label">{{ NUANCED_LABELS[level] }}</span>
</button>
</div>
<!-- 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>
<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>
<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>
<!-- 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 n’adopte pas.' }}
</template>
</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,408 @@
<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, CRYSTALLIZE_MODAL_DESC, 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"
:description="CRYSTALLIZE_MODAL_DESC"
:ui="{ header: 'ldov-header', title: 'ldov-title', description: 'ldov-desc', close: 'ldov-close', body: 'ldov-body' }"
>
<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>
@@ -0,0 +1,307 @@
/**
* The Fil (« Aujourd'hui ») — PURE Pinia selectors, ZERO notification table.
* Everything derives from the collective state (BLUEPRINT-V2.md « Navigation »
* COUCHES CODE): windows, votes, due reviews, due reports, boundaries,
* influxes, pending crystallizations, complete dossiers, R6 suggestions.
*
* Every section is a computed array sorted by deadline; myCount sums the
* sections that ask ME for a gesture (the bare counter of « À toi de décider »
* — never red, never a debt word).
*
* EXPLICIT imports (no Nuxt auto-imports) — testable under plain vitest.
*/
import { computed, type ComputedRef } from 'vue'
import type {
Concern,
Decision,
Id,
Mandate,
Objection,
Protocol,
VoteSession,
} from '../types/domain'
import { TERMINAL_STATUSES } from '../engine'
import { useCollectiveStore } from '../stores/collective'
export interface SessionFeedItem {
session: VoteSession
decision: Decision | undefined
}
export interface MandateReportDueItem {
mandate: Mandate
dueAt: string
}
export type FeedSuggestion =
| { kind: 'claim-mandate'; tags: string[]; count: number }
| { kind: 'protocolize'; tags: string[]; count: number }
| { kind: 'prune-protocol'; protocol: Protocol }
export interface Feed {
objectionWindows: ComputedRef<Decision[]>
adviceRequests: ComputedRef<Decision[]>
openVotes: ComputedRef<SessionFeedItem[]>
toCrystallize: ComputedRef<SessionFeedItem[]>
dossiersComplete: ComputedRef<Decision[]>
tiesToBreak: ComputedRef<SessionFeedItem[]>
reviewsDue: ComputedRef<Decision[]>
mandateReportsDue: ComputedRef<MandateReportDueItem[]>
boundaryObjections: ComputedRef<Objection[]>
overflowingScopes: ComputedRef<Decision[]>
prioritiesAsked: ComputedRef<Concern[]>
suggestions: ComputedRef<FeedSuggestion[]>
collectiveActivity: ComputedRef<Decision[]>
myCount: ComputedRef<number>
}
const byDeadline
= (deadline: (d: Decision) => string) => (a: Decision, b: Decision) =>
deadline(a) < deadline(b) ? -1 : 1
export function useFeed(): Feed {
const col = useCollectiveStore()
const myId = computed<Id | null>(() => col.me?.id ?? null)
/** Ids of the decisions where a live Concern names me. */
const concernedDecisionIds = computed<Set<Id>>(() => {
const me = myId.value
const set = new Set<Id>()
if (!me) return set
for (const concern of col.concerns) {
if (concern.personId === me) set.add(concern.decisionId)
}
return set
})
const isMySteward = (decision: Decision): boolean => {
const me = myId.value
if (!me) return false
if (decision.stewardIds.length > 0) return decision.stewardIds.includes(me)
return decision.authorId === me
}
const decisionOf = (session: VoteSession): Decision | undefined =>
col.decisions.find(d => d.id === session.decisionId)
// ── Windows where I am concerned ───────────────────────────
const objectionWindows = computed(() =>
col.decisions
.filter(d => d.status === 'objection' && concernedDecisionIds.value.has(d.id))
.sort(byDeadline(d => d.windowEndsAt ?? '')),
)
const adviceRequests = computed(() =>
col.decisions
.filter(d => d.status === 'advice' && concernedDecisionIds.value.has(d.id))
.sort(byDeadline(d => d.windowEndsAt ?? '')),
)
// ── Open votes where I belong to the arrested list ─────────
const openVotes = computed<SessionFeedItem[]>(() => {
const me = myId.value
if (!me) return []
return col.sessions
.filter(s => s.status === 'open' && s.corpusPersonIds.includes(me))
.sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1))
.map(session => ({ session, decision: decisionOf(session) }))
})
// ── Frozen parametric sessions waiting for MY gesture ──────
const toCrystallize = computed<SessionFeedItem[]>(() =>
col.sessions
.filter((s) => {
if (s.status !== 'frozen') return false
const decision = decisionOf(s)
return decision !== undefined && isMySteward(decision)
})
.sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1))
.map(session => ({ session, decision: decisionOf(session) })),
)
// ── Dossiers whose element children are all terminal ───────
const dossiersComplete = computed(() =>
col.decisions
.filter((d) => {
if (d.status !== 'framing' || !isMySteward(d)) return false
const elements = col.decisions.filter(
child => child.parentDecisionId === d.id && child.chainKind === 'element',
)
return (
elements.length > 0
&& elements.every(child => TERMINAL_STATUSES.includes(child.status))
)
})
.sort(byDeadline(d => d.windowEndsAt ?? '')),
)
// ── Tied elections waiting for a HUMAN runoff ──────────────
const tiesToBreak = computed<SessionFeedItem[]>(() =>
col.sessions
.filter((s) => {
if (s.outcome !== 'tie') return false
const decision = decisionOf(s)
return (
decision !== undefined && decision.status === 'voting' && isMySteward(decision)
)
})
.sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1))
.map(session => ({ session, decision: decisionOf(session) })),
)
// ── Reviews due — « Le réel a-t-il suivi ? » ───────────────
const reviewsDue = computed(() => {
const me = myId.value
if (!me) return []
const now = new Date().toISOString()
return col.decisions
.filter((d) => {
if (d.status !== 'adopted' || !d.review || d.review.verdict) return false
if (d.review.dueAt > now) return false
return d.authorId === me || d.stewardIds.includes(me) || d.measurerIds.includes(me)
})
.sort(byDeadline(d => d.review?.dueAt ?? ''))
})
// ── Mandate reports I owe ──────────────────────────────────
const mandateReportsDue = computed<MandateReportDueItem[]>(() => {
const me = myId.value
if (!me) return []
const now = new Date().toISOString()
return col.mandates
.filter(m => m.status === 'active' && m.holderId === me)
.flatMap((mandate) => {
const due = mandate.reports
.filter(r => !r.deliveredAt && r.dueAt <= now)
.sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0]
return due ? [{ mandate, dueAt: due.dueAt }] : []
})
.sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))
})
// ── Boundary objections on MY decisions (highest priority) ─
const boundaryObjections = computed(() => {
const me = myId.value
if (!me) return []
const mine = new Set(col.decisions.filter(d => d.authorId === me).map(d => d.id))
return col.objections
.filter(o => o.kind === 'boundary' && o.status === 'open' && mine.has(o.decisionId))
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
})
// ── My perimeters overflowing (widen or motivate) ──────────
const overflowingScopes = computed(() => {
const me = myId.value
const settings = col.settings
if (!me || !settings) return []
const ratio = settings.triage.concernEscalateRatio
return col.decisions
.filter((d) => {
if (d.authorId !== me || d.scopeKeptNote) return false
if (!['objection', 'advice', 'voting'].includes(d.status)) return false
const concerns = col.concerns.filter(c => c.decisionId === d.id)
const computedCount = concerns.filter(c => c.origin === 'computed').length
const declaredCount = concerns.filter(c => c.origin === 'declared').length
return computedCount > 0 && declaredCount >= ratio * computedCount
})
.sort(byDeadline(d => d.windowEndsAt ?? ''))
})
// ── « pondère tes enjeux » — my unweighted element concerns ─
const prioritiesAsked = computed(() => {
const me = myId.value
if (!me) return []
const openElements = new Set(
col.decisions
.filter(
d => d.chainKind === 'element' && !TERMINAL_STATUSES.includes(d.status),
)
.map(d => d.id),
)
return col.concerns.filter(
c => c.personId === me && c.priority === undefined && openElements.has(c.decisionId),
)
})
// ── R6 / maturation / pruning — simple, never blocking ─────
const suggestions = computed<FeedSuggestion[]>(() => {
const settings = col.settings
if (!settings) return []
const threshold = settings.triage.recurrenceThreshold
const out: FeedSuggestion[] = []
// Group by shared tag (simple heuristic): a tag carried by ≥ threshold
// adopted decisions of the last 90 days suggests a mandate; the same on
// 'record' entries suggests protocolizing the ripe practice.
const since = new Date(Date.now() - 90 * 86_400_000).toISOString()
const countByTag = (rows: Decision[]): Map<string, number> => {
const map = new Map<string, number>()
for (const row of rows) {
for (const tag of row.tags) map.set(tag, (map.get(tag) ?? 0) + 1)
}
return map
}
const recent = col.decisions.filter(d => (d.decidedAt ?? d.createdAt) >= since)
for (const [tag, count] of countByTag(
recent.filter(d => d.status === 'adopted' && d.route !== 'record'),
)) {
if (count >= threshold) out.push({ kind: 'claim-mandate', tags: [tag], count })
}
for (const [tag, count] of countByTag(recent.filter(d => d.route === 'record'))) {
if (count >= threshold) out.push({ kind: 'protocolize', tags: [tag], count })
}
// Pruning: a protocol no session ever invoked.
const invoked = new Set(col.sessions.map(s => s.protocolId))
for (const protocol of col.protocols) {
if (!invoked.has(protocol.id) && protocol.method !== 'consent') {
out.push({ kind: 'prune-protocol', protocol })
}
}
return out
})
// ── The collective's activity (visibility respected) ───────
const collectiveActivity = computed(() => {
const me = myId.value
return col.decisions
.filter(d => d.visibility !== 'private' || d.authorId === me)
.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1))
.slice(0, 20)
})
// ── The bare counter — sections that ask ME for a gesture ──
const myCount = computed(
() =>
objectionWindows.value.length
+ adviceRequests.value.length
+ openVotes.value.length
+ toCrystallize.value.length
+ dossiersComplete.value.length
+ tiesToBreak.value.length
+ reviewsDue.value.length
+ mandateReportsDue.value.length
+ boundaryObjections.value.length
+ overflowingScopes.value.length
+ prioritiesAsked.value.length,
)
return {
objectionWindows,
adviceRequests,
openVotes,
toCrystallize,
dossiersComplete,
tiesToBreak,
reviewsDue,
mandateReportsDue,
boundaryObjections,
overflowingScopes,
prioritiesAsked,
suggestions,
collectiveActivity,
myCount,
}
}
@@ -0,0 +1,24 @@
// Ambiances libreDecision v2 — champ lexical du puits 井.
// Délègue le mécanisme au layer @yvv/nuxt-base (auto-import `useMood(moods, options)`).
// Les couleurs (identité du projet) restent dans assets/css/moods.css (classes .mood-*).
// Enveloppé sous le nom `useLibreMood` pour éviter la collision avec le composable du layer.
export interface Mood {
id: string
label: string
description: string
icon: string
color: string
isDark: boolean
}
const moods: Mood[] = [
{ id: 'source', label: 'Source', description: 'Eau claire', icon: 'i-lucide-droplets', color: '#0f7fa8', isDark: false },
{ id: 'margelle', label: 'Margelle', description: 'Pierre chaude', icon: 'i-lucide-landmark', color: '#96682a', isDark: false },
{ id: 'nappe', label: 'Nappe', description: 'Eau profonde', icon: 'i-lucide-waves', color: '#3fa9cc', isDark: true },
{ id: 'minuit', label: 'Minuit', description: 'Encre et lanterne', icon: 'i-lucide-lamp', color: '#cf9c3e', isDark: true },
]
export function useLibreMood() {
return useMood(moods, { storageKey: 'libredecision_mood_v2', defaultId: 'source' })
}
@@ -0,0 +1,78 @@
/**
* The ONE search index — Cmd+K AND Q0 « déjà décidé ? » (same index, by
* doctrine): clauses (code + title), decisions (title), mandates (title).
* Case- and accent-insensitive. Pure selector over the collective store.
*
* EXPLICIT imports (no Nuxt auto-imports) — testable under plain vitest.
*/
import type { Id } from '../types/domain'
import { STATUS_LABELS } from '../lexicon'
import { useCollectiveStore } from '../stores/collective'
export interface SearchHit {
kind: 'clause' | 'decision' | 'mandate'
id: Id
label: string
sublabel: string
}
const MAX_HITS = 20
/** Case/accent-insensitive folding (same folding as the stores). */
function fold(text: string): string {
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export function useSearch() {
const col = useCollectiveStore()
/** Search the unique index. Empty (or blank) query ⇒ no hit. */
const search = (query: string): SearchHit[] => {
const needle = fold(query.trim())
if (needle.length === 0) return []
const hits: SearchHit[] = []
for (const clause of col.clauses) {
if (hits.length >= MAX_HITS) break
if (fold(clause.code).includes(needle) || fold(clause.title).includes(needle)) {
const doc = col.docs.find(d => d.id === clause.docId)
hits.push({
kind: 'clause',
id: clause.id,
label: `${clause.code} — ${clause.title}`,
sublabel: doc?.title ?? '',
})
}
}
for (const decision of col.decisions) {
if (hits.length >= MAX_HITS) break
if (fold(decision.title).includes(needle)) {
hits.push({
kind: 'decision',
id: decision.id,
label: decision.title,
sublabel: STATUS_LABELS[decision.status],
})
}
}
for (const mandate of col.mandates) {
if (hits.length >= MAX_HITS) break
if (fold(mandate.title).includes(needle)) {
const holder = col.people.find(p => p.id === mandate.holderId)
hits.push({
kind: 'mandate',
id: mandate.id,
label: mandate.title,
sublabel: holder?.displayName ?? '',
})
}
}
return hits
}
return { search }
}
+179
View File
@@ -0,0 +1,179 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — local-first persistence (IndexedDB via idb-keyval).
// One key per collective: `ld2:<collectiveId>` holds a Bundle-shaped state.
// Seeds and user imports go through the SAME importBundle() path.
// Sync-ready invariants live in types/domain.ts — this layer stays dumb.
// ─────────────────────────────────────────────────────────────
import { get, set, del, keys } from 'idb-keyval'
import type { Bundle, Collective, Id, ISODate } from '~/types/domain'
const KEY_PREFIX = 'ld2:'
const ACTIVE_KEY = 'ld2-active'
/** In-memory state of one collective — exactly a Bundle minus export metadata. */
export type CollectiveState = Omit<Bundle, 'schemaVersion' | 'exportedAt'>
export function emptyState(collective: Collective): CollectiveState {
return {
collective,
people: [],
circles: [],
decisions: [],
concerns: [],
objections: [],
advices: [],
assents: [],
mandates: [],
docs: [],
clauses: [],
versions: [],
protocols: [],
sessions: [],
votes: [],
}
}
// ── Load / save ──────────────────────────────────────────────
export async function listCollectiveIds(): Promise<Id[]> {
const all = await keys()
return all
.filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX))
.map(k => k.slice(KEY_PREFIX.length))
}
export async function loadState(collectiveId: Id): Promise<CollectiveState | undefined> {
return await get<CollectiveState>(KEY_PREFIX + collectiveId)
}
const pendingSaves = new Map<Id, ReturnType<typeof setTimeout>>()
/** Debounced write (500 ms) — every mutation calls this; last write wins. */
export function saveStateDebounced(state: CollectiveState): void {
const id = state.collective.id
const existing = pendingSaves.get(id)
if (existing) clearTimeout(existing)
pendingSaves.set(
id,
setTimeout(() => {
pendingSaves.delete(id)
void set(KEY_PREFIX + id, toRaw(state))
}, 500),
)
}
export async function saveStateNow(state: CollectiveState): Promise<void> {
const id = state.collective.id
const existing = pendingSaves.get(id)
if (existing) {
clearTimeout(existing)
pendingSaves.delete(id)
}
await set(KEY_PREFIX + id, toRaw(state))
}
export async function deleteCollective(collectiveId: Id): Promise<void> {
await del(KEY_PREFIX + collectiveId)
const active = await getActiveCollectiveId()
if (active === collectiveId) await set(ACTIVE_KEY, null)
}
export async function getActiveCollectiveId(): Promise<Id | null> {
return (await get<Id | null>(ACTIVE_KEY)) ?? null
}
export async function setActiveCollectiveId(id: Id | null): Promise<void> {
await set(ACTIVE_KEY, id)
}
// ── Export / import — the single bundle path ─────────────────
export function toBundle(state: CollectiveState, exportedAt: ISODate): Bundle {
return { schemaVersion: 2, exportedAt, ...toRaw(state) }
}
export async function sha256Hex(text: string): Promise<string> {
const data = new TextEncoder().encode(text)
const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
export interface ImportIssue {
level: 'error' | 'warning'
message: string
}
/** Structural validation — never throws, returns readable French issues. */
export function validateBundle(raw: unknown): { bundle?: Bundle; issues: ImportIssue[] } {
const issues: ImportIssue[] = []
if (typeof raw !== 'object' || raw === null) {
return { issues: [{ level: 'error', message: 'Ce fichier ne contient pas un collectif lisible.' }] }
}
const b = raw as Partial<Bundle>
if (b.schemaVersion !== 2)
issues.push({ level: 'error', message: 'Version de fichier inconnue (schemaVersion ≠ 2).' })
if (!b.collective?.id || !b.collective?.slug || !b.collective?.name)
issues.push({ level: 'error', message: 'Le collectif du fichier est incomplet (id, slug ou nom manquant).' })
for (const key of [
'people', 'circles', 'decisions', 'concerns', 'objections', 'advices', 'assents',
'mandates', 'docs', 'clauses', 'versions', 'protocols', 'sessions', 'votes',
] as const) {
if (!Array.isArray(b[key]))
issues.push({ level: 'error', message: `Collection manquante ou invalide : ${key}.` })
}
// Invariant: every collective carries a Consent protocol (import validates it).
if (Array.isArray(b.protocols) && !b.protocols.some(p => p.method === 'consent'))
issues.push({
level: 'warning',
message: 'Aucun protocole de consentement — les chemins collectifs retomberont sur « décider sur avis » jusqu\'à sa création.',
})
if (issues.some(i => i.level === 'error')) return { issues }
return { bundle: b as Bundle, issues }
}
export interface ImportResult {
state?: CollectiveState
issues: ImportIssue[]
collided: boolean
}
/**
* Import a bundle as a new local collective.
* Foreign bundles (exportedAt present, not one of ours) get a lineage stamp — « essaimé de … ».
*/
export async function importBundle(json: string, opts?: { asSeed?: boolean }): Promise<ImportResult> {
let raw: unknown
try {
raw = JSON.parse(json)
} catch {
return { issues: [{ level: 'error', message: 'Fichier illisible : ce n\'est pas du JSON valide.' }], collided: false }
}
const { bundle, issues } = validateBundle(raw)
if (!bundle) return { issues, collided: false }
const existingIds = await listCollectiveIds()
const collided = existingIds.includes(bundle.collective.id)
const { schemaVersion: _v, exportedAt, ...stateRest } = bundle
const state: CollectiveState = stateRest
if (!opts?.asSeed) {
state.collective = {
...state.collective,
lineage: {
sourceSlug: bundle.collective.slug,
exportedAt,
sha256: await sha256Hex(json),
},
}
}
return { state, issues, collided }
}
/** Deep-clone to plain JSON — strips Pinia/Vue reactivity proxies before IndexedDB. */
function toRaw<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
/**
* The seven collective templates (BLUEPRINT-V2.md « Seeds » GABARITS + Δ10/Δ11/Δ20).
*
* buildTemplateBundle() produces a COMPLETE schemaVersion-2 Bundle that goes
* through the SAME import path as a user bundle (importBundle, asSeed:true).
*
* Doctrinal invariants honored here:
* - every template seeds a Consent protocol (protocolByRange.consent — Δ10);
* - every ClauseVersion is born of a Decision: one FOUNDING decision
* (route 'record', adopted) carries every seeded clause version — a single
* shared founding act, exactly what a real constitutive gesture is;
* - blank stays a blank page: preamble + A1 + the « consignation » clause +
* requireEffects='none' + the Consent protocol ONLY — every other setting
* falls back to SETTINGS_DEFAULTS through resolveSettings;
* - the five standard templates (informal/association/cooperative/commune/
* free-currency) seed the 10 triage setting clauses + Consent, Nuancé,
* Réglage collectif, Élection — 'protocols.large' = the NUANCED protocol
* everywhere EXCEPT free-currency, where large = the inertial binary
* (D30M50B.1G.2 — the Ğ1 heritage put back in its place);
* - free-currency: the default resources unit is 'DU' — v2 keeps it as a
* DESCRIPTIVE default suggested by the tunnel UI (Resources.unit), never a
* conversion: DU and € are oil and water, never summed (huile/eau);
* - symmetric: full transparency (isTransparent), a « Déclaration de
* symétrie » reference TextDoc, and the draft decision « Déclarer notre
* symétrie ». Two MandateDrafts are SUGGESTED by the onboarding UI (not
* seeded — a mandate is born of a decision, never of a template):
* { title: 'Ambassadeur', domainCircleIds: [root], domainTags: ['liaison'],
* durationDays: 180, reportEveryDays: 30 }
* { title: 'Négociateur', domainCircleIds: [root], domainTags: ['symétrie'],
* durationDays: 180, reportEveryDays: 30 }
* - every template proposes the first decision « Adopter notre Pacte »
* (route 'collective', status 'draft', consent protocol).
*/
import type {
Bundle,
Circle,
Clause,
ClauseVersion,
Collective,
CollectiveTemplate,
Decision,
FormulaParams,
Id,
InertiaPreset,
ISODate,
Json,
Person,
Protocol,
TextDoc,
VoteMethod,
} from '../types/domain'
import { formatModeParams } from '../engine/modeParams'
export type TemplateId = CollectiveTemplate
// ─────────────────────────────────────────────────────────────
// The seven cards (/creer, écran 2) — blank FIRST
// ─────────────────────────────────────────────────────────────
export interface TemplateCard {
id: TemplateId
title: string
subtitle: string
description: string
}
const STANDARD_SUBTITLE = 'point de départ — tout est amendable par décision'
export const TEMPLATE_CARDS: TemplateCard[] = [
{
id: 'blank',
title: 'Page blanche',
subtitle: "observatoire d'abord",
description:
"Une clause : nous consignons nos décisions ; le reste s'écrira au fil de l'eau. "
+ 'Consentement seul installé.',
},
{
id: 'informal',
title: 'Informel',
subtitle: STANDARD_SUBTITLE,
description:
"Un groupe d'amis, un projet naissant : des chemins légers, aucune matière exigée.",
},
{
id: 'association',
title: 'Association',
subtitle: STANDARD_SUBTITLE,
description:
'Un Bureau, le consentement et le vote nuancé — la vie associative sans les camps.',
},
{
id: 'cooperative',
title: 'Coopérative',
subtitle: STANDARD_SUBTITLE,
description:
'Décider ensemble du travail et des ressources — matière exigée sur ce qui engage.',
},
{
id: 'commune',
title: 'Commune',
subtitle: STANDARD_SUBTITLE,
description:
'Des quartiers, des périmètres emboîtés — la subsidiarité en actes.',
},
{
id: 'free-currency',
title: 'Communauté monnaie libre',
subtitle: STANDARD_SUBTITLE,
description:
"L'héritage Toile de Confiance : le pour/contre inertiel, outil de dernier recours "
+ 'des très grands corps.',
},
{
id: 'symmetric',
title: 'Institution symétrique',
subtitle: 'le geste fondateur',
description:
'Transparence totale, Déclaration de symétrie, et la première décision : '
+ 'déclarer notre symétrie.',
},
]
// ─────────────────────────────────────────────────────────────
// buildTemplateBundle
// ─────────────────────────────────────────────────────────────
export interface TemplateBuildOptions {
name: string
slug: string
color: string
icon: string
meName: string
memberNames: string[]
now: ISODate
newId: () => Id
}
/** Which protocols a template installs. */
const STANDARD_TEMPLATES: TemplateId[] = [
'informal',
'association',
'cooperative',
'commune',
'free-currency',
'symmetric',
]
export function buildTemplateBundle(id: TemplateId, opts: TemplateBuildOptions): Bundle {
const { name, slug, color, icon, meName, memberNames, now, newId } = opts
const collectiveId = newId()
const entity = () => ({
id: newId(),
collectiveId,
createdAt: now,
updatedAt: now,
})
// ── People — me + the initial members ──────────────────────
const me: Person = { ...entity(), displayName: meName, isMe: true }
const members: Person[] = memberNames.map(displayName => ({
...entity(),
displayName,
isMe: false,
}))
const people = [me, ...members]
// ── Circles — root « Tous » + one sober typed circle per template ──
const rootCircle: Circle = {
...entity(),
name: 'Tous',
purpose: 'Toutes les personnes du collectif',
memberIds: people.map(p => p.id),
domains: [],
}
const circles: Circle[] = [rootCircle]
const typed: Partial<Record<TemplateId, { name: string; purpose: string; kind: Circle['kind'] }>> = {
association: { name: 'Bureau', purpose: "L'équipe qui fait tourner l'association", kind: 'team' },
cooperative: { name: 'Conseil', purpose: 'Le conseil de la coopérative', kind: 'team' },
commune: { name: 'Quartiers', purpose: 'Les lieux de la commune', kind: 'place' },
'free-currency': { name: 'Forgerons', purpose: 'Celles et ceux qui forgent les blocs', kind: 'team' },
}
const typedDef = typed[id]
if (typedDef) {
circles.push({
...entity(),
name: typedDef.name,
purpose: typedDef.purpose,
kind: typedDef.kind,
memberIds: [],
parentCircleId: rootCircle.id,
domains: [],
})
}
// ── Protocols ──────────────────────────────────────────────
const protocols: Protocol[] = []
const mkProtocol = (
pName: string,
method: VoteMethod,
description: string,
durationDays: number,
formula: FormulaParams,
): Protocol => {
const protocol: Protocol = {
...entity(),
name: pName,
method,
description,
durationDays,
ballot: 'open',
formula,
modeParams: formatModeParams({
duration_days: durationDays,
majority_pct: formula.majorityPct,
base_exponent: formula.baseExponent,
gradient_exponent: formula.gradientExponent,
constant_base: formula.constantBase,
}),
}
protocols.push(protocol)
return protocol
}
const baseFormula = { majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2, constantBase: 0 }
const consent = mkProtocol(
'Consentement',
'consent',
'Ça me va / J\'objecte — zéro objection maintenue vaut adoption.',
7,
{ ...baseFormula },
)
let nuanced: Protocol | undefined
let parametric: Protocol | undefined
let election: Protocol | undefined
let binary: Protocol | undefined
if (STANDARD_TEMPLATES.includes(id)) {
nuanced = mkProtocol(
'Vote nuancé',
'nuanced',
'Chacun·e se prononce en nuances, pas en camps.',
14,
{ ...baseFormula, nuancedThresholdPct: 60, nuancedMinParticipants: 3 },
)
parametric = mkProtocol(
'Réglage collectif',
'parametric',
'Décider au curseur — le collectif retient la médiane de chaque curseur.',
14,
{ ...baseFormula, parametricMinParticipants: 3 },
)
election = mkProtocol(
'Élection',
'election',
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez.",
14,
{ ...baseFormula, electionMinParticipants: 3, tieBreak: 'runoff' },
)
}
if (id === 'free-currency') {
// D30M50B.1G.2 — the exact inherited inertia of the Ğ1 web of trust.
binary = mkProtocol(
'Vote WoT binaire inertiel',
'binary',
"l'outil de dernier recours des très grands corps — héritage Toile de Confiance",
30,
{ ...baseFormula },
)
}
// ── The Pact + clauses, each founded by ONE adopted decision ──
const pactDoc: TextDoc = {
...entity(),
slug: 'pacte',
title: 'Notre Pacte',
role: 'pact',
description: 'Notre contrat social — sacralisé, jamais immuable',
}
const docs: TextDoc[] = [pactDoc]
const founding: Decision = {
...entity(),
authorId: me.id,
title: 'Fonder notre Pacte',
tags: ['pacte'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'record',
triageRule: 'R0a',
routeOverridden: false,
decidedHow: 'posé à la création du collectif — chaque clause reste amendable',
status: 'adopted',
decidedAt: now,
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
}
const clauses: Clause[] = []
const versions: ClauseVersion[] = []
let position = 0
const mkClause = (
section: string,
code: string,
title: string,
inertia: InertiaPreset,
content: string,
settingKey?: string,
settingValue?: Json,
): Clause => {
const clause: Clause = {
...entity(),
docId: pactDoc.id,
section,
position: position++,
code,
title,
inertia,
...(settingKey !== undefined ? { settingKey } : {}),
}
const version: ClauseVersion = {
...entity(),
clauseId: clause.id,
decisionId: founding.id,
versionLabel: 'v1',
content,
...(settingValue !== undefined ? { settingValue } : {}),
status: 'current',
adoptedAt: now,
}
clause.currentVersionId = version.id
clauses.push(clause)
versions.push(version)
return clause
}
// Preamble P0 — Autonomie–Équilibre–Liaison, proposed and MODIFIABLE.
mkClause(
'Préambule',
'P0',
'Préambule',
'standard',
'Autonomie — chacun·e décide de ce qui ne concerne que lui ou elle. '
+ 'Équilibre — ce qui engage le collectif se décide ensemble, à la juste échelle. '
+ 'Liaison — chaque décision garde sa trace et reste révisable. '
+ 'Ce préambule est proposé, jamais imposé : modifiable comme tout le reste.',
)
// A1 — Notre finalité (default compasses: vitalité / émancipation).
mkClause(
'Finalité',
'A1',
'Notre finalité',
'high',
"La vitalité du collectif et l'émancipation de chacun·e sont nos deux boussoles.",
)
if (id === 'blank') {
// The one practice clause of the blank page.
mkClause(
'Pratique',
'C1',
'Nous consignons nos décisions',
'standard',
"Nous consignons nos décisions ; le reste s'écrira au fil de l'eau.",
)
}
// ── Setting clauses ────────────────────────────────────────
const requireEffects = id === 'blank' || id === 'informal' ? 'none' : 'binding'
const requireEffectsContent =
requireEffects === 'none'
? "Aucune matière exigée — le collectif s'outillera quand la pratique le demandera."
: 'Matière exigée : décisions engageantes et structurantes — au moins un effet recherché avant tout vote collectif.'
if (id === 'blank') {
// Blank page: requireEffects='none' + the consent protocol, NOTHING else —
// every other setting falls back to SETTINGS_DEFAULTS via resolveSettings.
mkClause('Réglages', 'S1', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', 'none')
mkClause(
'Réglages',
'S2',
'Protocole de consentement',
'high',
'Notre protocole de consentement : Consentement (7 jours).',
'protocols.consent',
consent.id,
)
} else {
// The 10 triage keys of SETTINGS_DEFAULTS, in clear French.
mkClause('Réglages', 'S1', 'Petit groupe', 'standard', "Jusqu'à 5 personnes, un avis suffit avant de décider.", 'triage.smallGroupMax', 5)
mkClause('Réglages', 'S2', 'Grand collectif', 'standard', 'Au-delà de 50 personnes, la modalité des grands corps s\'applique.', 'triage.collectiveMin', 50)
mkClause('Réglages', 'S3', 'Consentement', 'standard', "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection, c'est adopté.", 'triage.consentMax', 7)
mkClause('Réglages', 'S4', "Fenêtre d'objection", 'standard', "Quarante-huit heures pour dire « Ça me va » ou objecter.", 'triage.objectionWindowHours', 48)
mkClause('Réglages', 'S5', "Fenêtre d'avis", 'standard', 'Soixante-douze heures pour déposer un avis.', 'triage.adviceWindowHours', 72)
mkClause('Réglages', 'S6', 'Formulation', 'standard', "Quatorze jours pour s'instruire et formuler des contre-propositions.", 'triage.framingDays', 14)
mkClause('Réglages', 'S7', 'Débordement du périmètre', 'standard', 'À la moitié de déclarations spontanées, on élargit ou on motive publiquement.', 'triage.concernEscalateRatio', 0.5)
mkClause('Réglages', 'S8', 'Récurrence', 'standard', 'À la troisième décision semblable, réclamer un mandat ou créer une règle.', 'triage.recurrenceThreshold', 3)
mkClause('Réglages', 'S9', "Épreuve du réel", 'standard', "Quatre-vingt-dix jours après l'adoption, le réel a-t-il suivi ?", 'triage.reviewDelayDays', 90)
mkClause('Réglages', 'S10', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', requireEffects)
// Protocol clauses — the Pact references its protocols (pactClauseId).
const consentClause = mkClause(
'Réglages',
'S11',
'Protocole de consentement',
'high',
'Notre protocole de consentement : Consentement (7 jours).',
'protocols.consent',
consent.id,
)
consent.pactClauseId = consentClause.id
if (nuanced) {
const c = mkClause(
'Réglages',
'S12',
'Protocole nuancé',
'high',
'De 8 à 50 personnes : le vote nuancé, six nuances, jamais deux camps.',
'protocols.nuanced',
nuanced.id,
)
nuanced.pactClauseId = c.id
}
// 'protocols.large' — nuanced everywhere, inertial binary ONLY here.
const large = id === 'free-currency' ? binary : nuanced
if (large) {
const c = mkClause(
'Réglages',
'S13',
'Modalité des grands corps',
'max',
id === 'free-currency'
? 'Au-delà de 50 : le pour/contre inertiel hérité de la Toile de Confiance (D30M50B.1G.2).'
: 'Au-delà de 50 : le vote nuancé, la modalité que notre Pacte a choisie.',
'protocols.large',
large.id,
)
if (id === 'free-currency' && binary) binary.pactClauseId = c.id
}
if (parametric) {
const c = mkClause(
'Réglages',
'S14',
'Réglage collectif',
'high',
'Montant, taux, répartition : décider au curseur — le collectif retient la médiane.',
'protocols.parametric',
parametric.id,
)
parametric.pactClauseId = c.id
}
if (election) {
const c = mkClause(
'Réglages',
'S15',
"Protocole d'élection",
'high',
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez, jamais l'outil.",
'protocols.election',
election.id,
)
election.pactClauseId = c.id
}
}
if (id === 'symmetric') {
// Full transparency, declared in the Pact itself.
mkClause(
'Symétrie',
'T1',
'Transparence totale',
'max',
'Tout ce que nous décidons est public — la symétrie commence par la transparence.',
)
docs.push({
...entity(),
slug: 'declaration-de-symetrie',
title: 'Déclaration de symétrie',
role: 'reference',
description:
'Le geste fondateur : notre institution se déclare symétrique de celles qui nous gouvernent.',
})
}
// ── Decisions — founding (adopted) + first proposals (draft) ──
const decisions: Decision[] = [founding]
decisions.push({
...entity(),
authorId: me.id,
title: 'Adopter notre Pacte',
body: 'Relire le Pacte proposé par le gabarit, l\'amender si besoin, et l\'adopter ensemble.',
tags: ['pacte'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'collective',
triageRule: 'R5',
routeOverridden: false,
protocolId: consent.id,
status: 'draft',
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
})
if (id === 'symmetric') {
decisions.push({
...entity(),
authorId: me.id,
title: 'Déclarer notre symétrie',
body: 'Adopter la Déclaration de symétrie et la rendre publique.',
tags: ['symétrie'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'collective',
triageRule: 'R5',
routeOverridden: false,
protocolId: consent.id,
status: 'draft',
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
})
}
// ── The tenant ─────────────────────────────────────────────
const collective: Collective = {
id: collectiveId,
slug,
name,
color,
icon,
template: id,
isTransparent: id === 'symmetric' || id === 'free-currency',
pactDocId: pactDoc.id,
rootCircleId: rootCircle.id,
createdAt: now,
updatedAt: now,
}
return {
schemaVersion: 2,
exportedAt: now,
collective,
people,
circles,
decisions,
concerns: [],
objections: [],
advices: [],
assents: [],
mandates: [],
docs,
clauses,
versions,
protocols,
sessions: [],
votes: [],
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Perimeter engine — who is concerned by THIS decision.
*
* Pure functions — no store, no I/O. The store assembles the inputs and
* persists the result as Concern rows (origin 'computed').
*
* The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») :
* members of the scoped circles ∪ persons named by the author
* ∪ holders of active mandates whose domain intersects the scoped circles.
* Every inclusion carries its human-readable French reason — auditable in
* one tap, never « la machine t'a désigné ».
*
* MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per
* circle (Circle.memberIds is the ONLY membership rule). Circle nesting
* (parentCircleId, « couches d'oignon ») does NOT propagate membership:
* a parent circle does NOT automatically include the members of its child
* circles, nor the reverse. Nesting serves ONE gesture — widening the
* perimeter by one notch (« élargir d'un cran ») — which explicitly adds
* the parent circle to the scope; the computation never infers it.
*/
import type { Circle, Decision, Id, Mandate } from '~/types/domain'
export interface ConcernedEntry {
personId: Id
/** French reason shown on tap (becomes Concern.reason). */
reason: string
}
/**
* Flatten circle ids into the set of their DIRECT nominative members.
*
* NON-PROPAGATION (see module header): only Circle.memberIds of the listed
* circles count. Members of child (or parent) circles are NOT included —
* widening is a scope gesture, never an automatic inference.
* Unknown circle ids are ignored (robustness over crash).
*/
export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set<Id> {
const members = new Set<Id>()
for (const circleId of circleIds) {
const circle = circles.find(c => c.id === circleId)
if (!circle) continue
for (const personId of circle.memberIds) members.add(personId)
}
return members
}
/**
* Compute the concerned persons of a decision scope, each with its reason.
*
* Deduplication: first reason wins, with source priority
* circle membership > named by author > mandate holder.
* The author is EXCLUDED from the list (they decide, they are not
* « concerned » by their own perimeter).
*
* Mandates count only when status 'active' and when their domain.circleIds
* INTERSECTS the scoped circles (any shared circle is enough — a mandate
* holder is concerned as soon as the decision touches their domain).
*/
export function computeConcerned(
scope: Decision['scope'],
circles: Circle[],
mandates: Mandate[],
authorId: Id,
): ConcernedEntry[] {
// Map preserves insertion order; first reason wins (priority by pass order).
const concerned = new Map<Id, string>()
// 1. Members of the scoped circles (highest priority reason).
for (const circleId of scope.circleIds) {
const circle = circles.find(c => c.id === circleId)
if (!circle) continue
for (const personId of circle.memberIds) {
if (personId === authorId) continue
if (!concerned.has(personId)) {
concerned.set(personId, `membre du cercle ${circle.name}`)
}
}
}
// 2. Persons named by the author.
for (const personId of scope.personIds) {
if (personId === authorId) continue
if (!concerned.has(personId)) {
concerned.set(personId, 'nommé·e par l\'auteur')
}
}
// 3. Holders of active mandates whose domain intersects the scoped circles.
const scopedCircleIds = new Set(scope.circleIds)
for (const mandate of mandates) {
if (mandate.status !== 'active') continue
if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue
if (mandate.holderId === authorId) continue
if (!concerned.has(mandate.holderId)) {
concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`)
}
}
return [...concerned].map(([personId, reason]) => ({ personId, reason }))
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Pure engine barrel — the SINGLE import surface of app/engine/.
*
* Re-exports EVERYTHING (functions, constants and types) from the eight
* pure modules. Stores and components import from '~/engine' (or relative
* '../engine') and never reach into individual files.
*/
export * from './threshold'
export * from './nuanced'
export * from './modeParams'
export * from './parametric'
export * from './settings'
export * from './state'
export * from './triage'
export * from './impact'
+163
View File
@@ -0,0 +1,163 @@
/**
* Mode-params DSL: parse and format compact parameter strings.
*
* Exact TypeScript port of backend/app/engine/mode_params.py
* (Python is the reference implementation for parsing; the Python
* module has no formatter, so formatModeParams() is defined as the
* canonical inverse of parseModeParams()).
*
* A mode-params string encodes voting formula parameters in a compact format.
* Example: "D30M50B.1G.2T.1"
*
* Supported codes:
* D = duration_days (int)
* M = majority_pct (int, 0-100)
* B = base_exponent (float)
* G = gradient_exponent (float)
* C = constant_base (float)
* S = smith_exponent (float)
* T = techcomm_exponent (float)
* N = ratio_multiplier (float)
* R = is_ratio_mode (bool, 0 or 1)
*
* Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1.
*/
export interface ModeParams {
duration_days: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_exponent: number | null
techcomm_exponent: number | null
ratio_multiplier: number | null
is_ratio_mode: boolean
}
type CodeType = 'int' | 'float' | 'bool'
// Ordered list of recognised codes and their target keys + types
const CODES: Record<string, { key: keyof ModeParams; type: CodeType }> = {
D: { key: 'duration_days', type: 'int' },
M: { key: 'majority_pct', type: 'int' },
B: { key: 'base_exponent', type: 'float' },
G: { key: 'gradient_exponent', type: 'float' },
C: { key: 'constant_base', type: 'float' },
S: { key: 'smith_exponent', type: 'float' },
T: { key: 'techcomm_exponent', type: 'float' },
N: { key: 'ratio_multiplier', type: 'float' },
R: { key: 'is_ratio_mode', type: 'bool' },
}
// Regex: a single uppercase letter followed by a numeric value (int or float,
// possibly starting with '.' for values like .1 meaning 0.1)
const PARAM_RE = /([A-Z])(\d*\.?\d+)/g
function getDefaults(): ModeParams {
return {
duration_days: 30,
majority_pct: 50,
base_exponent: 0.1,
gradient_exponent: 0.2,
constant_base: 0.0,
smith_exponent: null,
techcomm_exponent: null,
ratio_multiplier: null,
is_ratio_mode: false,
}
}
/**
* Parse a mode-params string into a parameter object.
*
* Defaults are applied for any code not present in the string.
*
* @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1"
* @returns Parsed parameters with defaults for codes not found
* @throws Error if an unrecognised code letter is found
*/
export function parseModeParams(paramsStr: string): ModeParams {
const result = getDefaults()
if (!paramsStr || !paramsStr.trim()) {
return result
}
for (const match of paramsStr.matchAll(PARAM_RE)) {
const code = match[1]!
const rawValue = match[2]!
const entry = CODES[code]
if (!entry) {
throw new Error(`Code de parametre inconnu : '${code}'`)
}
const { key, type } = entry
if (type === 'int') {
// Python: int(float(raw)) truncates toward zero
;(result as Record<keyof ModeParams, unknown>)[key] = Math.trunc(parseFloat(rawValue))
} else if (type === 'float') {
;(result as Record<keyof ModeParams, unknown>)[key] = parseFloat(rawValue)
} else {
;(result as Record<keyof ModeParams, unknown>)[key] = parseFloat(rawValue) !== 0.0
}
}
return result
}
/**
* Format a numeric value in DSL form: 0.1 -> ".1", 1 -> "1", 2.5 -> "2.5".
*/
function formatValue(value: number): string {
const s = String(value)
return value > 0 && value < 1 ? s.replace(/^0\./, '.') : s
}
/**
* Format a parameter object back into a canonical mode-params string.
*
* Canonical form matches the strings used across the project
* (e.g. "D30M50B.1G.2S.1"): D, M, B, G always present in that order,
* then C when non-zero, then S/T/N when set, then R1 when ratio mode.
* Round-trip: parseModeParams(formatModeParams(p)) is identical to p.
*
* @param params - Parameters to encode (missing keys take defaults)
* @returns Compact string, e.g. "D30M50B.1G.2"
*/
export function formatModeParams(params: Partial<ModeParams> = {}): string {
const p = getDefaults()
for (const key of Object.keys(p) as Array<keyof ModeParams>) {
const value = params[key]
if (value !== undefined) {
;(p[key] as ModeParams[typeof key]) = value
}
}
const parts: string[] = [
`D${formatValue(p.duration_days)}`,
`M${formatValue(p.majority_pct)}`,
`B${formatValue(p.base_exponent)}`,
`G${formatValue(p.gradient_exponent)}`,
]
if (p.constant_base !== 0) {
parts.push(`C${formatValue(p.constant_base)}`)
}
if (p.smith_exponent !== null) {
parts.push(`S${formatValue(p.smith_exponent)}`)
}
if (p.techcomm_exponent !== null) {
parts.push(`T${formatValue(p.techcomm_exponent)}`)
}
if (p.ratio_multiplier !== null) {
parts.push(`N${formatValue(p.ratio_multiplier)}`)
}
if (p.is_ratio_mode) {
parts.push('R1')
}
return parts.join('')
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Six-level nuanced vote evaluation.
*
* Exact TypeScript port of backend/app/engine/nuanced_vote.py
* (Python is the reference implementation).
*
* Levels:
* 0 - CONTRE
* 1 - PAS DU TOUT
* 2 - PAS D'ACCORD
* 3 - NEUTRE
* 4 - D'ACCORD
* 5 - TOUT A FAIT
*
* Adoption rule:
* The sum of votes at levels 3 + 4 + 5 must be >= thresholdPct% of total votes.
* A minimum number of participants is also required.
*/
export const LEVEL_LABELS: Record<number, string> = {
0: 'CONTRE',
1: 'PAS DU TOUT',
2: "PAS D'ACCORD",
3: 'NEUTRE',
4: "D'ACCORD",
5: 'TOUT A FAIT',
}
export const NUM_LEVELS = 6
/** Result shape mirrors the Python dict returned by evaluate_nuanced(). */
export interface NuancedResult {
total: number
per_level_counts: Record<number, number>
positive_count: number
positive_pct: number
threshold_met: boolean
min_participants_met: boolean
adopted: boolean
}
/**
* Round to 2 decimals with ties-to-even, mirroring Python's round(x, 2).
*/
function round2(value: number): number {
const scaled = value * 100
const floor = Math.floor(scaled)
const diff = scaled - floor
let rounded: number
if (diff > 0.5) {
rounded = floor + 1
} else if (diff < 0.5) {
rounded = floor
} else {
rounded = floor % 2 === 0 ? floor : floor + 1
}
return rounded / 100
}
/**
* Evaluate a nuanced vote from a list of individual vote levels.
*
* @param votes - List of vote levels (each 0-5), one entry per voter
* @param thresholdPct - Minimum percentage of positive votes (levels 3-5) for adoption
* @param minParticipants - Minimum number of participants required for validity
* @returns Aggregated result (counts, percentage, adoption flags)
* @throws Error if any vote value is outside the 0-5 integer range
*/
export function nuancedResult(
votes: number[],
thresholdPct: number = 80,
minParticipants: number = 59,
): NuancedResult {
// Validate vote levels (integer check added: JS has no KeyError safety net)
for (const v of votes) {
if (!Number.isInteger(v) || v < 0 || v > 5) {
throw new Error(`Niveau de vote invalide : ${v}. Les niveaux valides sont 0-5.`)
}
}
const total = votes.length
const perLevelCounts: Record<number, number> = {}
for (let level = 0; level < NUM_LEVELS; level++) {
perLevelCounts[level] = 0
}
for (const v of votes) {
perLevelCounts[v] = (perLevelCounts[v] ?? 0) + 1
}
// Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
const positiveCount = (perLevelCounts[3] ?? 0) + (perLevelCounts[4] ?? 0) + (perLevelCounts[5] ?? 0)
const positivePct = total > 0 ? (positiveCount / total) * 100.0 : 0.0
const thresholdMet = positivePct >= thresholdPct
const minParticipantsMet = total >= minParticipants
const adopted = thresholdMet && minParticipantsMet
return {
total,
per_level_counts: perLevelCounts,
positive_count: positiveCount,
positive_pct: round2(positivePct),
threshold_met: thresholdMet,
min_participants_met: minParticipantsMet,
adopted,
}
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Parametric decision engine — « Réglage collectif » (collective tuning).
*
* Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3,
* Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human
* gesture (crystallization) lives in state.ts/UI, never here.
*
* LOCKED SPECS (blueprint repairs):
* - LOW median, element by element: for an even vote count, take the LOWER
* central element (index floor((n-1)/2) after ascending sort). Invariant:
* every median value is a value someone actually voted, so the step is
* honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ».
* - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true
* (the absorption variable), resolved linearly: 100 − Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* - At vote time the resolved derived must stay within its bounds, otherwise
* the vote is rejected (validateVote).
* - At crystallization the derived is never aggregated: it is resolved from
* the median of the voted shares; if it exits its bounds ⇒ clamp to the
* violated bound + PROPORTIONAL renormalization of the non-derived shares
* (each multiplied by (100 − clampedDerived) / Σ median shares) so the
* sum-100 invariant is restored. The renormalized shares may leave the
* step grid — accepted and documented: this is the one specified exception.
* - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen.
* - computeMyImpact 'linear-share' NEVER invents a number: missing attribute
* or empty declaring corpus ⇒ null.
* - detectBimodality is a simple documented heuristic, NEVER blocking:
* it returns false on any degenerate input instead of throwing.
*
* Code and comments in English; thrown error messages in French (UI-facing).
*/
import type { ParamDef, ParamSpec } from '../types/domain'
/** Blueprint limit: a small manipulable space (SejeteralO lesson). */
const MAX_PARAMS = 7
/**
* Absolute tolerance for floating-point comparisons (bounds and step grid).
* Vote values are human-scale (percent shares, bounded sliders), so an
* absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value.
*/
const FLOAT_EPS = 1e-6
/** Params that are actually voted, in spec order (derived excluded). */
function votableParams(spec: ParamSpec): ParamDef[] {
return spec.params.filter(p => p.derived !== true)
}
/**
* Guarded indexed access (project compiles with noUncheckedIndexedAccess).
* Every call site is protected by a prior length check or loop bound —
* this throw is an internal-invariant guard, not a reachable user error.
*/
function at(arr: number[], i: number): number {
const v = arr[i]
if (v === undefined) {
throw new Error('Incohérence interne : index hors du vecteur.')
}
return v
}
// ---------------------------------------------------------------------------
// validateParamSpec
// ---------------------------------------------------------------------------
/**
* Validate a ParamSpec at creation time. Throws (French message) when:
* - no param, or more than 7 params;
* - a business label is missing (never raw a, b, c);
* - bounds are inconsistent (min >= max) or step is not strictly positive;
* - a baseline lies outside its own bounds;
* - constraint 'sum100' does not have EXACTLY ONE 'share' param with
* derived:true (0 or 2+ derived, or derived on a 'slider');
* - a derived param exists without a constraint able to resolve it.
*/
export function validateParamSpec(spec: ParamSpec): void {
if (spec.params.length === 0) {
throw new Error('Au moins un paramètre est requis.')
}
if (spec.params.length > MAX_PARAMS) {
throw new Error(
`Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`,
)
}
for (const p of spec.params) {
if (typeof p.label !== 'string' || p.label.trim() === '') {
throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`)
}
if (!(p.min < p.max)) {
throw new Error(
`Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`,
)
}
if (!(p.step > 0)) {
throw new Error(
`Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`,
)
}
if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) {
throw new Error(
`Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}–${p.max}).`,
)
}
}
const derived = spec.params.filter(p => p.derived === true)
if (spec.constraint === 'sum100') {
if (derived.some(p => p.kind !== 'share')) {
throw new Error(
'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.',
)
}
if (derived.length === 0) {
throw new Error(
'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).',
)
}
if (derived.length > 1) {
throw new Error(
`Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`,
)
}
} else if (derived.length > 0) {
throw new Error(
'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.',
)
}
}
// ---------------------------------------------------------------------------
// validateVote
// ---------------------------------------------------------------------------
/**
* Validate one vote vector against the spec.
* `values` follows the order of spec.params EXCLUDING derived params
* (Vote.values contract). Throws (French message) when:
* - the vector length does not match the number of votable params;
* - a value is not a finite number (NaN / ±Infinity sanitization);
* - a value is out of bounds or off the step grid (FLOAT_EPS tolerance);
* - constraint 'sum100': the resolved derived (100 − Σ voted shares) would
* exit its own [min, max] bounds ⇒ the vote is rejected.
*/
export function validateVote(spec: ParamSpec, values: number[]): void {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
votable.forEach((p, i) => {
const v = values[i]
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(
`Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`,
)
}
if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) {
throw new Error(
`Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}–${p.max}).`,
)
}
// Step grid: v must equal min + k×step for an integer k (float tolerance).
const k = Math.round((v - p.min) / p.step)
if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) {
throw new Error(
`Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`,
)
}
})
if (spec.constraint === 'sum100') {
const derivedParam = spec.params.find(p => p.derived === true)
if (derivedParam) {
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
const resolved = 100 - shareSum
if (
resolved < derivedParam.min - FLOAT_EPS
|| resolved > derivedParam.max + FLOAT_EPS
) {
throw new Error(
`La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}–${derivedParam.max}).`,
)
}
}
}
}
// ---------------------------------------------------------------------------
// resolveDerived
// ---------------------------------------------------------------------------
/**
* Expand a votable vector into the COMPLETE vector in spec.params order.
* The derived share (sum100) is resolved linearly: 100 − Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* Throws on a length mismatch (misuse guard — same message as validateVote).
*/
export function resolveDerived(spec: ParamSpec, values: number[]): number[] {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
let cursor = 0
return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++)))
}
// ---------------------------------------------------------------------------
// medianByElement
// ---------------------------------------------------------------------------
/**
* LOW median, element by element.
* Each column is sorted ascending and the element at index floor((n-1)/2) is
* taken — for an even n this is the LOWER of the two central elements.
* Invariant: every median value is a value actually voted by someone, so the
* step grid is honored by construction (« une position que chacun aurait pu
* proposer »).
* 0 votes ⇒ [] (the caller falls back to the baseline vector).
*/
export function medianByElement(votesValues: number[][]): number[] {
const n = votesValues.length
if (n === 0) return []
const width = votesValues[0]?.length ?? 0
const lowMedianIndex = Math.floor((n - 1) / 2)
const medians: number[] = []
for (let j = 0; j < width; j++) {
const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b)
medians.push(at(column, lowMedianIndex))
}
return medians
}
// ---------------------------------------------------------------------------
// crystallize
// ---------------------------------------------------------------------------
/**
* Compute the crystallized position: LOW median of the VOTED vectors, then
* derived resolution. Returns the complete vector in spec.params order.
*
* sum100 repair (locked spec): the derived is never aggregated — it is
* resolved from the median of the voted shares. If it exits its bounds:
* - clamp it to the violated bound;
* - renormalize the non-derived shares PROPORTIONALLY to restore sum 100:
* each share is multiplied by (100 − clampedDerived) / Σ median shares.
* (Renormalized shares may leave the step grid — accepted, documented.)
* - degenerate sub-case Σ median shares = 0: proportionality is undefined,
* the remainder (100 − clampedDerived) is spread equally instead.
* 'slider' params are outside the constraint and are never renormalized.
*
* 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an
* empty screen; the crystallization GESTURE itself stays human (Δ3).
*/
export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] {
if (votesValues.length === 0) {
return spec.params.map(p => p.baseline ?? p.min)
}
const median = medianByElement(votesValues)
const full = resolveDerived(spec, median)
if (spec.constraint !== 'sum100') return full
const derivedIndex = spec.params.findIndex(p => p.derived === true)
const derivedParam = spec.params[derivedIndex]
if (derivedIndex === -1 || derivedParam === undefined) {
return full // unreachable on a validated spec
}
const resolved = at(full, derivedIndex)
const withinBounds
= resolved >= derivedParam.min - FLOAT_EPS
&& resolved <= derivedParam.max + FLOAT_EPS
if (withinBounds) return full
// Clamp to the violated bound, then restore the sum-100 invariant.
const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max)
const remainder = 100 - clamped
let shareSum = 0
let shareCount = 0
spec.params.forEach((p, i) => {
if (p.derived !== true && p.kind === 'share') {
shareSum += at(full, i)
shareCount++
}
})
return full.map((v, i) => {
if (i === derivedIndex) return clamped
const p = spec.params[i]
if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched
if (shareSum === 0) return remainder / shareCount // degenerate: equal spread
return v * (remainder / shareSum) // proportional renormalization
})
}
// ---------------------------------------------------------------------------
// computeMyImpact
// ---------------------------------------------------------------------------
/** One line of the « Pour moi » card: my quota for one share param. */
export interface ImpactLine {
key: string
label: string
amount: number
}
/** « Pour moi » card content — per share param + total. */
export interface MyImpact {
perParam: ImpactLine[]
total: number
}
/**
* 'linear-share' personal impact (Δ15 — « Pour moi » card).
* Applies ONLY when constraint is 'sum100' AND resources.amount is set AND
* spec.impactAttrKey is set. For EACH share param p (derived included):
* amount(p) = resources.amount × value(p) / 100
* myQuota(p) = amount(p) × myAttr / Σ corpusAttrs
* `fullValues` is the COMPLETE vector in spec.params order (resolveDerived /
* crystallize output). 'slider' params are outside the constraint: no line.
*
* Returns null — NEVER an invented number — when myAttr is undefined,
* when Σ corpusAttrs is 0 (nobody declared), or on a malformed input.
*/
export function computeMyImpact(
spec: ParamSpec,
resources: { amount?: number },
fullValues: number[],
myAttr: number | undefined,
corpusAttrs: number[],
): MyImpact | null {
if (spec.constraint !== 'sum100') return null
if (!resources.amount || !Number.isFinite(resources.amount)) return null
if (!spec.impactAttrKey) return null
if (myAttr === undefined || !Number.isFinite(myAttr)) return null
if (fullValues.length !== spec.params.length) return null // misuse guard
const attrSum = corpusAttrs.reduce(
(sum, a) => sum + (Number.isFinite(a) ? a : 0),
0,
)
if (attrSum === 0) return null
const amount = resources.amount
const perParam: ImpactLine[] = []
let total = 0
spec.params.forEach((p, i) => {
if (p.kind !== 'share') return
const paramAmount = (amount * at(fullValues, i)) / 100
const myQuota = (paramAmount * myAttr) / attrSum
perParam.push({ key: p.key, label: p.label, amount: myQuota })
total += myQuota
})
return { perParam, total }
}
// ---------------------------------------------------------------------------
// detectBimodality
// ---------------------------------------------------------------------------
/**
* Simple documented heuristic over ONE param's voted values — NEVER blocking
* (it only feeds the non-blocking banner and the crystallization reminder,
* Δ16): two distinct positions are detected when, after ascending sort,
* the LARGEST gap between consecutive values satisfies ALL of:
* - n >= 4 (below that, no distribution to speak of);
* - gap > 40% of the total range (max − min);
* - at least 2 values on EACH side of the gap (a single outlier is not a
* second position).
* Non-finite values are ignored; any degenerate input returns false.
*/
export function detectBimodality(values: number[]): boolean {
const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b)
const n = sorted.length
if (n < 4) return false
const range = at(sorted, n - 1) - at(sorted, 0)
if (range <= 0) return false
let maxGap = 0
for (let i = 0; i < n - 1; i++) {
const gap = at(sorted, i + 1) - at(sorted, i)
if (gap > maxGap) maxGap = gap
}
if (maxGap <= 0.4 * range) return false
// The max gap must split the values 2+ / 2+ (ties: any qualifying position).
for (let i = 1; i <= n - 3; i++) {
if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true
}
return false
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Pact settings resolution — the Pact IS the settings store (Δ10).
*
* resolveSettings() reads the Pact clauses that carry a settingKey, finds the
* 'current' ClauseVersion bearing a settingValue for each, and produces the
* resolved CollectiveSettings consumed by triage.ts and state.ts. Pure
* function — NOT a settings table: every threshold is a voted clause.
*
* Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10):
* - any absent key falls back to SETTINGS_DEFAULTS;
* - invalid values (wrong type, negative number, unknown enum member) fall
* back to the default SILENTLY — a corrupted Pact never crashes the engine;
* - any unresolved OPTIONAL protocolByRange key stays undefined: the caller
* falls back to the consent protocol;
* - a missing 'protocols.consent' (corrupted bundle) still returns a full
* settings object with protocolByRange.consent === '' — hasConsentProtocol()
* lets the triage route 'advice' with the banner « Aucun protocole — crée-le
* ou décide sur avis », NEVER a crash.
*/
import type {
Clause,
ClauseVersion,
CollectiveSettings,
Id,
InertiaPreset,
Json,
} from '../types/domain'
/** Default triage settings — used for every key the Pact does not resolve. */
export const SETTINGS_DEFAULTS = {
smallGroupMax: 5,
collectiveMin: 50,
consentMax: 7,
objectionWindowHours: 48,
adviceWindowHours: 72,
framingDays: 14,
concernEscalateRatio: 0.5,
recurrenceThreshold: 3,
reviewDelayDays: 90,
requireEffects: 'binding',
} as const satisfies CollectiveSettings['triage']
const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
/** Most recent wins when a clause carries several 'current' versions (data anomaly). */
function stampOf(version: ClauseVersion): string {
return version.adoptedAt ?? version.updatedAt
}
/** The settingValue of the clause's 'current' version, if any. */
function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined {
let best: ClauseVersion | undefined
for (const version of versions) {
if (version.clauseId !== clause.id || version.archivedAt) continue
if (version.status !== 'current' || version.settingValue === undefined) continue
if (!best || stampOf(version) > stampOf(best)) best = version
}
return best?.settingValue
}
/** First non-archived clause holding this settingKey with a resolvable current value. */
function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined {
for (const clause of clauses) {
if (clause.settingKey !== key || clause.archivedAt) continue
const value = currentValueOf(clause, versions)
if (value !== undefined) return value
}
return undefined
}
/** Wrong type or negative number ⇒ default, silently. */
function numberOrDefault(value: Json | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
}
/** Unknown enum member ⇒ default, silently. */
function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' {
return value === 'none' || value === 'structural' || value === 'binding'
? value
: SETTINGS_DEFAULTS.requireEffects
}
/** Protocol ids must be non-empty strings — anything else counts as unresolved. */
function idOrUndefined(value: Json | undefined): Id | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
/**
* Resolve the collective's settings from its Pact clauses.
*
* Recognized setting keys:
* triage.smallGroupMax · triage.collectiveMin · triage.consentMax ·
* triage.objectionWindowHours · triage.adviceWindowHours ·
* triage.framingDays · triage.concernEscalateRatio ·
* triage.recurrenceThreshold · triage.reviewDelayDays ·
* triage.requireEffects · protocols.consent · protocols.nuanced ·
* protocols.large · protocols.parametric · protocols.election ·
* protocols.clauseByInertia.{low,standard,high,max}
*/
export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings {
const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions)
const triage: CollectiveSettings['triage'] = {
smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax),
collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin),
consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax),
objectionWindowHours: numberOrDefault(
raw('triage.objectionWindowHours'),
SETTINGS_DEFAULTS.objectionWindowHours,
),
adviceWindowHours: numberOrDefault(
raw('triage.adviceWindowHours'),
SETTINGS_DEFAULTS.adviceWindowHours,
),
framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays),
concernEscalateRatio: numberOrDefault(
raw('triage.concernEscalateRatio'),
SETTINGS_DEFAULTS.concernEscalateRatio,
),
recurrenceThreshold: numberOrDefault(
raw('triage.recurrenceThreshold'),
SETTINGS_DEFAULTS.recurrenceThreshold,
),
reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays),
requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')),
}
// Consent is the MANDATORY invariant key. When unfindable (corrupted
// bundle), we still return a complete object with consent === '' so the
// caller can degrade gracefully (hasConsentProtocol) — never a crash.
const protocolByRange: CollectiveSettings['protocolByRange'] = {
consent: idOrUndefined(raw('protocols.consent')) ?? '',
}
const nuanced = idOrUndefined(raw('protocols.nuanced'))
if (nuanced) protocolByRange.nuanced = nuanced
const large = idOrUndefined(raw('protocols.large'))
if (large) protocolByRange.large = large
const parametric = idOrUndefined(raw('protocols.parametric'))
if (parametric) protocolByRange.parametric = parametric
const election = idOrUndefined(raw('protocols.election'))
if (election) protocolByRange.election = election
// The Ğ1 heritage lives here, intact: the map is exposed only when ALL four
// presets resolve (the type is a complete Record — a partial map would lie).
// Otherwise the caller falls back to consent, like any optional key.
const byInertia: Partial<Record<InertiaPreset, Id>> = {}
for (const preset of INERTIA_PRESETS) {
const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`))
if (id) byInertia[preset] = id
}
if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) {
protocolByRange.clauseByInertia = byInertia as Record<InertiaPreset, Id>
}
return { triage, protocolByRange }
}
/**
* True when the collective has a resolvable consent protocol.
* False ⇒ the triage routes 'advice' with the banner
* « Aucun protocole — crée-le ou décide sur avis » — never a crash.
*/
export function hasConsentProtocol(settings: CollectiveSettings): boolean {
return settings.protocolByRange.consent.trim().length > 0
}
+263
View File
@@ -0,0 +1,263 @@
/**
* The ONE state machine of the decision (Δ29).
*
* Every mutation goes through canTransition(decision, to, ctx): the doctrinal
* guards (subsidiarity, instruction, consent) are tested code, not intentions.
* The context is assembled by the store; the function stays pure and testable.
*
* Guards, in order (BLUEPRINT-V2.md « Cycle de vie » + Δ13, Δ17, Δ27, Δ29):
* a) unknown transition;
* b) boundary — windowSuspendedAt suspends any exit from objection/advice;
* c) influx — concernEscalateRatio reached ⇒ widen or scopeKeptNote;
* d) matter — requireEffects at collective session opening;
* e) resources — « Ce que ça engage » at window/session opening;
* f) assent — non-easy objection windows adopt only on an explicit third-party
* agreement, never by pure silence;
* g) open objection — no adoption over an open objection;
* h) dossier — framing→closed only when every element child is terminal;
* i) crystallization — parametric sessions close by a dated human gesture,
* the engine NEVER crystallizes.
*
* Every refusal reason is one French sentence whose subject is the collective
* or the person — never the engine.
*/
import type {
Assent,
CollectiveSettings,
Concern,
Decision,
DecisionStatus,
ISODate,
Objection,
VoteSession,
} from '../types/domain'
/** Allowed transitions — statuses absent from the table are terminal. */
export const TRANSITIONS: Readonly<Partial<Record<DecisionStatus, readonly DecisionStatus[]>>> = {
draft: ['advice', 'objection', 'framing', 'voting', 'adopted', 'transmitted', 'rejected'],
advice: ['adopted', 'voting'],
objection: ['adopted', 'framing', 'voting'],
framing: ['voting', 'closed'],
voting: ['adopted', 'rejected', 'framing'],
// voting→framing: ONLY the « Reformuler » choice of the crystallization gesture —
// guarded below to a frozen, non-crystallized parametric session (Δ3).
adopted: ['revoked', 'closed'],
}
/** Terminal statuses of an element child — condition of the dossier closure. */
export const TERMINAL_STATUSES: readonly DecisionStatus[] = [
'adopted',
'rejected',
'revoked',
'closed',
'transmitted',
]
/** Window states — a boundary objection suspends any exit from these. */
const WINDOW_STATUSES: readonly DecisionStatus[] = ['objection', 'advice']
/** Sources whose closing is subject to the influx guard. */
const INFLUX_SOURCES: readonly DecisionStatus[] = ['advice', 'objection', 'voting']
/** Adoption/closure targets watched by the influx guard. */
const INFLUX_TARGETS: readonly DecisionStatus[] = ['adopted', 'rejected', 'closed']
const REASONS = {
unknown: 'La décision ne peut pas prendre ce chemin depuis son état actuel.',
boundary:
'La frontière est contestée — le compte à rebours reste suspendu jusqu’à l’intégration ou une réponse motivée.',
influx: 'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
matterMissing: 'Le collectif s’instruit avant de voter — formule au moins un effet recherché.',
matterTarget:
'Une décision structurante se mesure — donne une cible à au moins un effet recherché.',
resources:
'Le collectif doit savoir ce que ça engage — écris la note de ressources avant d’ouvrir.',
assent: 'Il manque un accord explicite — la fenêtre se prolonge.',
openObjection: 'Une objection reste ouverte — le collectif l’entend avant d’adopter.',
dossierEmpty: 'Le dossier n’a pas d’éléments — découpe-le avant de le clore.',
dossierPending:
'Des éléments du dossier sont encore en cours — le dossier se clôt quand tous ont abouti.',
crystallization: 'Les votes sont figés — la cristallisation attend son geste.',
reformulate:
'Reformuler n’est possible que sur un réglage collectif figé, avant sa cristallisation.',
} as const
export interface TransitionContext {
concerns: Concern[]
settings: CollectiveSettings
session?: VoteSession
children?: Decision[]
assents?: Assent[]
objections?: Objection[]
now: ISODate
}
export type TransitionResult = { ok: true } | { ok: false; reason: string }
export type WindowOutcome = 'adopt' | 'extend' | 'wait'
function refuse(reason: string): TransitionResult {
return { ok: false, reason }
}
/** ≥1 non-archived Assent on this decision from someone other than the author. */
function hasThirdPartyAssent(decision: Decision, assents: Assent[] | undefined): boolean {
return (assents ?? []).some(
(assent) =>
assent.decisionId === decision.id &&
!assent.archivedAt &&
assent.personId !== decision.authorId,
)
}
/**
* Can this decision move to `to`? Pure — the store assembles ctx.
* Refusals carry one French sentence (subject: the collective or the person).
*/
export function canTransition(
decision: Decision,
to: DecisionStatus,
ctx: TransitionContext,
): TransitionResult {
const from = decision.status
// ── (a) Unknown transition ─────────────────────────────────────────────
// A window state may explicitly return to itself (re-arming after a
// resolved boundary objection, extension of a window) — nothing else loops.
const isWindowSelfReturn = WINDOW_STATUSES.includes(from) && to === from
if (!isWindowSelfReturn && !(TRANSITIONS[from] ?? []).includes(to)) {
return refuse(REASONS.unknown)
}
// ── (b) Boundary — the contestation of frontiers precedes substance ────
if (WINDOW_STATUSES.includes(from) && decision.windowSuspendedAt && to !== from) {
return refuse(REASONS.boundary)
}
// ── (c) Influx — the scale-up is DECIDED, never evaporated (Δ17) ───────
if (INFLUX_SOURCES.includes(from) && INFLUX_TARGETS.includes(to)) {
const concerns = ctx.concerns.filter(
(concern) => concern.decisionId === decision.id && !concern.archivedAt,
)
const computed = concerns.filter((concern) => concern.origin === 'computed').length
const declared = concerns.filter((concern) => concern.origin === 'declared').length
const ratioReached =
computed > 0 && declared >= ctx.settings.triage.concernEscalateRatio * computed
if (ratioReached && !decision.scopeKeptNote) {
return refuse(REASONS.influx)
}
}
const opensCollectiveSession = (from === 'draft' || from === 'framing') && to === 'voting'
// ── (d) Matter — no engaging collective vote without instruction ───────
if (opensCollectiveSession && decision.route === 'collective') {
const mode = ctx.settings.triage.requireEffects
const underGuard =
mode === 'binding'
? decision.weight === 'binding' || decision.weight === 'structural'
: mode === 'structural' && decision.weight === 'structural'
if (underGuard) {
const effects = decision.brief?.effects ?? []
if (effects.length === 0) {
return refuse(REASONS.matterMissing)
}
const hasMeasurableEffect = effects.some(
(effect) => effect.target !== undefined && effect.target.trim().length > 0,
)
if (decision.weight === 'structural' && !hasMeasurableEffect) {
return refuse(REASONS.matterTarget)
}
}
}
// ── (e) Resources — « Ce que ça engage » lives at the opening ──────────
const opensWindowOrSession =
opensCollectiveSession || (from === 'draft' && (to === 'objection' || to === 'advice'))
if (
opensWindowOrSession &&
decision.weight !== 'light' &&
decision.route !== 'solo' &&
decision.route !== 'record'
) {
const note = decision.resources?.note ?? ''
if (note.trim().length === 0) {
return refuse(REASONS.resources)
}
}
// ── (f) Assent — outside easy, agreement is a gesture, not silence (Δ27)
if (from === 'objection' && to === 'adopted' && decision.reversibility !== 'easy') {
if (!hasThirdPartyAssent(decision, ctx.assents)) {
return refuse(REASONS.assent)
}
}
// ── (g) Open objection — never adopted over someone's maintained voice ─
if (from === 'objection' && to === 'adopted') {
const hasOpenObjection = (ctx.objections ?? []).some(
(objection) =>
objection.decisionId === decision.id &&
!objection.archivedAt &&
objection.status === 'open',
)
if (hasOpenObjection) {
return refuse(REASONS.openObjection)
}
}
// ── (h) Dossier — the closure is a steward gesture on a complete map (Δ13)
if (from === 'framing' && to === 'closed') {
const elements = (ctx.children ?? []).filter(
(child) =>
child.chainKind === 'element' &&
child.parentDecisionId === decision.id &&
!child.archivedAt,
)
if (elements.length === 0) {
return refuse(REASONS.dossierEmpty)
}
if (elements.some((child) => !TERMINAL_STATUSES.includes(child.status))) {
return refuse(REASONS.dossierPending)
}
}
// ── (i) Crystallization — the engine NEVER crystallizes ────────────────
// A parametric session is recognized by decision.paramSpec, or by the
// 'frozen' status (only parametric sessions ever freeze).
if (from === 'voting' && (to === 'adopted' || to === 'rejected') && ctx.session) {
const isParametric = decision.paramSpec !== undefined || ctx.session.status === 'frozen'
const crystallized = ctx.session.status === 'closed' && !!ctx.session.crystallizedById
if (isParametric && !crystallized) {
return refuse(REASONS.crystallization)
}
}
// voting→framing is EXCLUSIVELY the « Reformuler » branch of the crystallization
// gesture (Δ3): a frozen, not-yet-crystallized parametric session may go back to
// formulation instead of being crystallized. Any other voting→framing is refused.
if (from === 'voting' && to === 'framing') {
const reformulable =
ctx.session?.status === 'frozen' && !ctx.session.crystallizedById
if (!reformulable) {
return refuse(REASONS.reformulate)
}
}
return { ok: true }
}
/**
* Outcome of an objection window at its deadline, WITHOUT open objection
* (the caller handles open objections — escalation or suspension):
* - suspended boundary ⇒ 'wait' (the countdown is not running);
* - easy ⇒ 'adopt' (silence counts as agreement — only there);
* - otherwise ⇒ 'adopt' on a third-party Assent, else 'extend' by one notch
* (+objectionWindowHours, Fil reminder) — never adoption by pure silence.
*/
export function windowOutcome(decision: Decision, ctx: TransitionContext): WindowOutcome {
if (decision.windowSuspendedAt) return 'wait'
if (decision.reversibility === 'easy') return 'adopt'
return hasThirdPartyAssent(decision, ctx.assents) ? 'adopt' : 'extend'
}
+213
View File
@@ -0,0 +1,213 @@
/**
* WoT members threshold formula for binary votes.
*
* Exact TypeScript port of backend/app/engine/threshold.py,
* smith_threshold.py and techcomm_threshold.py (Python is the
* reference implementation — same guards, same ceil rounding).
*
* Core formula:
* Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
*
* Where:
* C = constantBase
* B = baseExponent
* W = wotSize (corpus of eligible voters)
* T = totalVotes (for + against)
* M = majorityRatio (majorityPct / 100)
* G = gradientExponent
*
* Inertia behaviour:
* - Low participation (T << W) -> near-unanimity required
* - High participation (T -> W) -> simple majority M suffices
*
* Reference test case:
* wotSize=7224, votesFor=97, votesAgainst=23 (total=120)
* params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
*/
/**
* Compute the minimum number of *for* votes required for adoption.
*
* A vote passes when `votesFor >= wotThreshold(...)`.
*
* @param wotSize - Size of the eligible voter corpus (WoT members)
* @param totalVotes - Number of votes cast (for + against)
* @param majorityPct - Majority percentage (0-100). 50 = simple majority at full participation
* @param baseExponent - B in the formula; B^W is a vanishingly small offset when W is large
* @param gradientExponent - G controls how fast the super-majority decays toward M
* @param constantBase - C, a fixed additive floor on the threshold
* @returns The ceiling of the computed threshold
*/
export function wotThreshold(
wotSize: number,
totalVotes: number,
majorityPct: number = 50,
baseExponent: number = 0.1,
gradientExponent: number = 0.2,
constantBase: number = 0.0,
): number {
if (wotSize <= 0) {
throw new Error('wotSize doit etre strictement positif')
}
if (totalVotes < 0) {
throw new Error('totalVotes ne peut pas etre negatif')
}
if (majorityPct < 0 || majorityPct > 100) {
throw new Error('majorityPct doit etre entre 0 et 100')
}
const C = constantBase
const B = baseExponent
const W = wotSize
const T = totalVotes
const M = majorityPct / 100.0
const G = gradientExponent
// Guard: if no votes, threshold is at least ceil(C + B^W)
if (T === 0) {
return Math.ceil(C + B ** W)
}
// Core formula
const participationRatio = T / W
const inertiaFactor = 1.0 - participationRatio ** G
const requiredRatio = M + (1.0 - M) * inertiaFactor
const result = C + B ** W + requiredRatio * Math.max(0.0, T - C)
return Math.ceil(result)
}
/**
* Smith sub-WoT threshold criterion.
*
* Formula: ceil(smithWotSize ^ S)
*
* @param smithWotSize - Number of active Smith members (forgerons)
* @param exponent - S in the formula
* @returns Minimum Smith votes required
*/
export function smithThreshold(smithWotSize: number, exponent: number = 0.1): number {
if (smithWotSize <= 0) {
throw new Error('smithWotSize doit etre strictement positif')
}
return Math.ceil(smithWotSize ** exponent)
}
/**
* Technical Committee threshold criterion.
*
* Formula: ceil(cotecSize ^ T)
*
* @param cotecSize - Number of Technical Committee members
* @param exponent - T in the formula
* @returns Minimum TechComm votes required
*/
export function techcommThreshold(cotecSize: number, exponent: number = 0.1): number {
if (cotecSize <= 0) {
throw new Error('cotecSize doit etre strictement positif')
}
return Math.ceil(cotecSize ** exponent)
}
// ─────────────────────────────────────────────────────────────
// Election — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4)
// ─────────────────────────────────────────────────────────────
import type { FormulaParams, Id, Vote } from '~/types/domain'
/**
* Outcome of an election tally.
*
* Discriminated union on `outcome`:
* - 'elected' — a single person leads by simple plurality.
* - 'tie' — several persons share the top count. The engine NEVER
* breaks a tie (no randomness, no first-come): the closure
* flow proposes a chained runoff among `exAequoIds`, or a
* draw only if the Pact planned it (FormulaParams.tieBreak).
* - 'rejected' — reason 'quorum': participants (blanks included) below
* electionMinParticipants; `required` = that quorum.
* reason 'no-designation': quorum reached (or absent) but
* every vote is blank — nobody was designated, and an
* empty tie would be meaningless; `required` still carries
* the quorum (0 when none) for display purposes.
*/
export type ElectionOutcome =
| {
outcome: 'elected'
winnerId: Id
counts: Record<Id, number>
blanks: number
participants: number
}
| {
outcome: 'tie'
exAequoIds: Id[]
counts: Record<Id, number>
blanks: number
participants: number
}
| {
outcome: 'rejected'
reason: 'quorum' | 'no-designation'
participants: number
required: number
}
/**
* Tally an election by simple plurality.
*
* CONTRACT — the caller passes the LAST ACTIVE votes only: one vote per
* voter, `supersedesVoteId` chains already resolved (the store filters
* superseded votes). The engine does NOT deduplicate by voterId;
* `participants` is simply `votes.length`.
*
* Rules (Δ28):
* - A vote without `choicePersonId` is a BLANK: it counts for
* participation (quorum), never for designation.
* - Quorum: when `formula.electionMinParticipants` is set and
* participants (blanks included) < quorum ⇒ rejected ('quorum').
* - Designation by simple PLURALITY of the non-blank votes.
* - Tie at the top ⇒ 'tie' with `exAequoIds` sorted (lexicographic —
* a deterministic display order, NEVER a tie-break: the engine does
* not pick a winner among equals, no randomness, no first-come).
* - Zero designation (all blanks) ⇒ rejected ('no-designation').
*
* @param votes - Last active votes of the session (see contract above)
* @param formula - Protocol formula params (only electionMinParticipants is read)
* @returns The election outcome — never a tie silently broken
*/
export function electionResult(
votes: Vote[],
formula: Pick<FormulaParams, 'electionMinParticipants'>,
): ElectionOutcome {
const participants = votes.length
const quorum = formula.electionMinParticipants ?? 0
if (formula.electionMinParticipants !== undefined && participants < formula.electionMinParticipants) {
return { outcome: 'rejected', reason: 'quorum', participants, required: formula.electionMinParticipants }
}
const counts: Record<Id, number> = {}
let blanks = 0
for (const vote of votes) {
if (vote.choicePersonId) {
counts[vote.choicePersonId] = (counts[vote.choicePersonId] ?? 0) + 1
} else {
blanks++ // blank: participation only, never designation
}
}
const designatedIds = Object.keys(counts)
if (designatedIds.length === 0) {
return { outcome: 'rejected', reason: 'no-designation', participants, required: quorum }
}
const topCount = Math.max(...designatedIds.map(id => counts[id]!))
const leaders = designatedIds.filter(id => counts[id] === topCount)
if (leaders.length === 1) {
return { outcome: 'elected', winnerId: leaders[0]!, counts, blanks, participants }
}
return { outcome: 'tie', exAequoIds: [...leaders].sort(), counts, blanks, participants }
}
+341
View File
@@ -0,0 +1,341 @@
/**
* Routing engine — UI name « Le chemin » (BLUEPRINT-V2.md « Algorithme de triage »).
*
* Pure function triage(input, ctx, settings) → Verdict. No store, no I/O:
* the store assembles TriageContext, the engine only reasons on it.
* Rule codes (R-U, R0a…R6) NEVER reach the card: the UI shows the one-sentence
* French explanation alone; codes live in the « pourquoi ? » disclosure,
* the PV and the derogation journal.
*
* Rule order — first match wins:
* R-U (urgency overlay) · R0a already decided · R0b my mandate ·
* R0c someone else's mandate · R2 self only · R3 targeted clause ·
* R4 small reversible group · R5 collective default · R6 recurrence overlay.
*
* DOCUMENTED INTERPRETATION CHOICES:
* - R0b comes BEFORE R2: a selfOnly decision whose scoped circles are covered
* by my mandate still routes 'mandate' (the mandate trace duty prevails).
* - R0a yields ONLY when the author explicitly targets the clause
* (amendsClauseId set): contesting the rule IS R3, and R3 looks the clause
* up in ctx.matchingClauses — R0a fires for a bare match.
* - Specified fallback (Δ10): protocolByRange.consent unresolvable ⇒ route
* 'advice' with the « Aucun protocole » banner, prior to EVERYTHING except
* R2 (solo needs no protocol) and R0a (record needs no protocol).
* Its rule code is 'R5' — it degrades the collective default.
* - Urgency refused (urgent && irreversible): the underlying route is kept
* with its MINIMAL windows (never doubled), rule 'R-U', refusal sentence.
* - R6 is an overlay: it sets Verdict.suggestion but never changes route or
* rule; 'claim-mandate' wins over 'protocolize' when both thresholds match.
*/
import type {
Clause,
CollectiveSettings,
Id,
Mandate,
TriageContext,
TriageInput,
Verdict,
} from '~/types/domain'
import {
BINARY_COST,
METHOD_LABELS,
NO_PROTOCOL_BANNER,
PARAMETRIC_ALT,
RECORD_ALT,
URGENT_BADGE,
URGENT_REFUSED,
} from '~/lexicon'
// TODO-lexicon: alternative costs missing from app/lexicon.ts (frozen file) —
// move these two constants there once it thaws.
const PARAMETRIC_COST = 'formuler les curseurs avant de voter'
const RECORD_COST = 'aucune fenêtre — la décision est déjà prise'
/** Verdict before the permanent alternatives are attached. */
type BaseVerdict = Omit<Verdict, 'alternatives'>
// ─────────────────────────────────────────────────────────────
// Parametric hint — number / % / amount (€, DU) in the capture sentence
// ─────────────────────────────────────────────────────────────
const DIGIT_PERCENT_CURRENCY = /\d|%|€/
// Ğ1 universal dividend — uppercase only: the French article « du » never counts.
const DU_UNIT = /\bDU\b/
/** Detect a number, percentage or amount (€, DU, digits) in the title. */
export function detectParametricHint(title: string): boolean {
return DIGIT_PERCENT_CURRENCY.test(title) || DU_UNIT.test(title)
}
// ─────────────────────────────────────────────────────────────
// Main entry
// ─────────────────────────────────────────────────────────────
export function triage(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): Verdict {
const hint = detectParametricHint(input.title)
let base = routeIgnoringUrgency(input, ctx, settings)
// R-U — urgency overlay.
if (input.urgent) {
if (input.reversibility === 'irreversible') {
// Refusal: normal route, MINIMAL windows (never doubled), no chain.
base = { ...base, rule: 'R-U', explanation: URGENT_REFUSED }
} else {
// Conservatory: the lightest legitimate route NOW, doubled window,
// chained ratification (created by the store when validating).
base = {
...base,
rule: 'R-U',
explanation: `${URGENT_BADGE}.`,
conservatoryChain: true,
...(base.windowHours !== undefined ? { windowHours: base.windowHours * 2 } : {}),
}
}
}
// R6 — recurrence & maturation overlay (never blocking, never re-routing).
const suggestion = recurrenceOverlay(input, ctx, settings)
return {
...base,
...(hint ? { parametricHint: true } : {}),
...(suggestion ? { suggestion } : {}),
alternatives: buildAlternatives(hint),
}
}
// ─────────────────────────────────────────────────────────────
// Rules R0a → R5 (urgency stripped)
// ─────────────────────────────────────────────────────────────
function routeIgnoringUrgency(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): BaseVerdict {
// R0a — already decided. Skipped when the author explicitly targets the
// clause (that is R3 — contesting the rule). Route 'record' proposed as
// principal: act under the standing rule, and keep the trace.
const matched = ctx.matchingClauses[0]
if (matched && !input.amendsClauseId) {
return {
route: 'record',
rule: 'R0a',
explanation: `C'est déjà décidé (${matched.code}, ${matched.title}) — agis, ou conteste la règle.`,
reviewRequired: false,
engravingSuggested: false,
}
}
// Specified fallback (Δ10): no consent protocol resolvable — prior to
// everything except R2/record. Rule code 'R5': the collective default,
// degraded to advice because the collective has no protocol yet.
if (!settings.protocolByRange.consent) {
if (input.scope.selfOnly) return r2SelfOnly(input)
return {
route: 'advice',
rule: 'R5',
explanation: `${NO_PROTOCOL_BANNER}.`,
windowHours: settings.triage.adviceWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R0b — my mandate covers. Before R2: even selfOnly, the mandate trace
// duty prevails. Empty scope.circleIds never counts as covered (a trivial
// ⊆ match would hand every uncircled decision to the first mandate).
const myMandate = coveringMandate(input, ctx.myActiveMandates)
if (myMandate) {
return {
route: 'mandate',
rule: 'R0b',
explanation: `Ton mandat ${myMandate.title} couvre — décide, c'est tracé.`,
windowHours: settings.triage.objectionWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R0c — someone else's mandate covers. TriageContext carries no person
// names: the mandate TITLE names the power, the store resolves the holder.
const otherMandate = coveringMandate(input, ctx.otherActiveMandates)
if (otherMandate) {
return {
route: 'transmit',
rule: 'R0c',
explanation: `Le mandat ${otherMandate.title} couvre — transmets à sa ou son titulaire.`,
reviewRequired: false,
engravingSuggested: false,
}
}
// R2 — self only.
if (input.scope.selfOnly) return r2SelfOnly(input)
// R3 — targeted clause: its inertia applies, vote of those it governs.
if (input.amendsClauseId) return r3TargetedClause(input, ctx, settings)
const n = ctx.computedConcernedIds.length
// R4 — small reversible group: ask for advice, then decide.
if (input.reversibility === 'easy' && n <= settings.triage.smallGroupMax) {
const plural = n > 1 ? 's' : ''
return {
route: 'advice',
rule: 'R4',
explanation: `Réversible et ${n} personne${plural} concernée${plural} — demande leur avis puis décide.`,
windowHours: settings.triage.adviceWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R5 — collective default, modality by perimeter size.
return r5Collective(input, n, settings)
}
/** R2 — « Personne d'autre n'est concerné ». Zero window. On irreversible the
* engine SUGGESTS a review (reviewRequired) — the UI shows it pre-checked but
* REMOVABLE: on oneself the tool never interposes; the pre-check is a UI fact,
* the engine only recommends. */
function r2SelfOnly(input: TriageInput): BaseVerdict {
return {
route: 'solo',
rule: 'R2',
explanation: 'Personne d\'autre n\'est concerné — décide.',
reviewRequired: input.reversibility === 'irreversible',
engravingSuggested: false,
}
}
/** R3 — amendsClauseId set. Protocol resolved by the clause's inertia
* (clauseByInertia), specified fallback → consent. The clause is looked up in
* ctx.matchingClauses (the Q0 index); if absent, consent applies and the
* sentence names « cette règle » instead of a code. */
function r3TargetedClause(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): BaseVerdict {
const clause: Clause | undefined = ctx.matchingClauses.find(
c => c.id === input.amendsClauseId,
)
const protocolId: Id
= (clause && settings.protocolByRange.clauseByInertia?.[clause.inertia])
?? settings.protocolByRange.consent
const name = clause ? clause.code : 'cette règle'
return {
route: 'collective',
rule: 'R3',
explanation: `Tu proposes une version de ${name} — son inertie s'applique : vote de ceux qu'elle gouverne.`,
protocolId,
...(input.weight === 'structural' ? { framingDays: settings.triage.framingDays } : {}),
reviewRequired: false,
engravingSuggested: false,
}
}
/** R5 — modality by computed perimeter size, protocols resolved by the Pact
* with the specified fallback → consent. */
function r5Collective(
input: TriageInput,
n: number,
settings: CollectiveSettings,
): BaseVerdict {
const range = settings.protocolByRange
let protocolId: Id
let explanation: string
if (n <= settings.triage.consentMax) {
protocolId = range.consent
explanation = `Vous êtes ${n} — un tour d'accord suffit : sans objection, c'est adopté.`
} else if (n <= settings.triage.collectiveMin) {
protocolId = range.nuanced ?? range.consent
explanation = `Vous êtes ${n} — vote nuancé : chacun se prononce en nuances, pas en camps.`
} else {
protocolId = range.large ?? range.consent
explanation = `Vous êtes ${n} — la modalité que votre Pacte a choisie s'applique.`
}
const structural = input.weight === 'structural'
return {
route: 'collective',
rule: 'R5',
explanation,
protocolId,
...(structural ? { framingDays: settings.triage.framingDays } : {}),
reviewRequired: structural || input.reversibility === 'irreversible',
engravingSuggested: structural,
}
}
// ─────────────────────────────────────────────────────────────
// R6 overlay & permanent alternatives
// ─────────────────────────────────────────────────────────────
/** R6 — recurrence & maturation. Overlay only: a suggestion, never a route.
* 'claim-mandate' (recent adopted look-alikes) wins over 'protocolize'
* (recorded look-alikes) when both thresholds are reached. */
function recurrenceOverlay(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): Verdict['suggestion'] | undefined {
const threshold = settings.triage.recurrenceThreshold
if (ctx.similarRecentCount >= threshold) {
return {
kind: 'claim-mandate',
prefill: {
title: input.title,
domainTags: input.tags,
domainCircleIds: input.scope.circleIds,
},
}
}
if (ctx.similarRecordedCount >= threshold) {
return {
kind: 'protocolize',
prefill: { title: input.title, tags: input.tags },
}
}
return undefined
}
/** The permanent « Je choisis autrement » alternatives — ALWAYS present.
* Parametric comes FIRST when a number/%/amount was detected (parametricHint);
* otherwise record leads (the observatory gesture stays one tap away). */
function buildAlternatives(parametricFirst: boolean): Verdict['alternatives'] {
const parametric = {
route: 'collective' as const,
label: PARAMETRIC_ALT,
cost: PARAMETRIC_COST,
}
const record = { route: 'record' as const, label: RECORD_ALT, cost: RECORD_COST }
const binary = {
route: 'collective' as const,
label: METHOD_LABELS.binary,
cost: BINARY_COST,
}
return parametricFirst ? [parametric, record, binary] : [record, parametric, binary]
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/** A mandate covers when scope.circleIds is a NON-EMPTY subset of its
* domain.circleIds. Defensive status filter: only 'active' mandates count. */
function coveringMandate(input: TriageInput, mandates: Mandate[]): Mandate | undefined {
const { circleIds } = input.scope
if (circleIds.length === 0) return undefined
return mandates.find(
m => m.status === 'active' && circleIds.every(id => m.domain.circleIds.includes(id)),
)
}
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
/** Layout minimal (onboarding /creer) — fond d'ambiance, contenu centré, sceau discret. */
</script>
<template>
<!-- ld-v2 -->
<div class="bare">
<main class="bare__main">
<slot />
</main>
<LdSeal class="app-seal bare__seal" />
</div>
</template>
<style scoped>
.bare {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--mood-bg);
color: var(--mood-text);
}
.bare__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(1.25rem, 4vw, 3rem);
}
.bare__seal {
position: fixed;
right: 1.25rem;
bottom: 1rem;
}
</style>
+609
View File
@@ -0,0 +1,609 @@
<script setup lang="ts">
/**
* Shell v2 — header sticky, sidebar 4 entrées, drawer mobile, FAB « Décider »,
* sceau 井 bas-droite, footer. Rien tant que le store n'est pas prêt (flash évité) ;
* plein-écran d'accueil quand aucun collectif n'existe encore.
*/
import { useCollectiveStore } from '~/stores/collective'
import { CLOSING_LINE, CREATE_SIGNATURE, OPENING_DREAM } from '~/lexicon'
const store = useCollectiveStore()
const route = useRoute()
// ── Navigation : 4 entrées, pas une de plus ──
const navigationItems = [
{ label: "Aujourd'hui", icon: 'i-lucide-sun-medium', to: '/' },
{ label: 'Décisions', icon: 'i-lucide-scale', to: '/decisions' },
{ label: 'Textes', icon: 'i-lucide-book-open', to: '/textes' },
{ label: 'Mandats', icon: 'i-lucide-key-round', to: '/mandats' },
]
function isActive(to: string) {
if (to === '/') return route.path === '/'
return route.path === to || route.path.startsWith(to + '/')
}
// ── Drawer mobile ──
const mobileMenuOpen = ref(false)
watch(() => route.path, () => { mobileMenuOpen.value = false })
// ── Sidebar repliable, persistée ──
const SIDEBAR_KEY = 'ld2-sidebar'
const sidebarCollapsed = ref(false)
watch(sidebarCollapsed, val => localStorage.setItem(SIDEBAR_KEY, String(val)))
onMounted(() => {
const saved = localStorage.getItem(SIDEBAR_KEY)
if (saved !== null) sidebarCollapsed.value = saved === 'true'
})
// ── Accueil : explorer un collectif d'exemple (tolérant si loadSeed absent) ──
const seeds = [
{ id: 'duniter-g1', label: 'Explorer Duniter Ğ1' },
{ id: 'atelier-du-canal', label: "Explorer l'Atelier du Canal" },
]
const loadingSeed = ref<string | null>(null)
async function explore(id: string) {
const s = store as unknown as { loadSeed?: (id: string) => unknown }
if (typeof s.loadSeed === 'function') {
loadingSeed.value = id
try {
await s.loadSeed(id)
} finally {
loadingSeed.value = null
}
} else {
await navigateTo('/creer')
}
}
</script>
<template>
<!-- ld-v2 -->
<div v-if="store.ready" class="app-shell">
<!-- ════ Accueil plein écran — aucun collectif ════ -->
<div v-if="!store.current" class="app-welcome">
<div class="app-welcome__logo">
<span class="logo-stamp logo-stamp--lg">
<LdSeal :size="26" />
</span>
<span class="logo-text">
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
</span>
</div>
<h1 class="app-welcome__dream">{{ OPENING_DREAM }}</h1>
<p class="app-welcome__line">{{ CLOSING_LINE }}</p>
<div class="app-welcome__actions">
<NuxtLink to="/creer" class="ld-btn">
<UIcon name="i-lucide-sparkles" />
<span>Créer un collectif</span>
</NuxtLink>
<button
v-for="seed in seeds"
:key="seed.id"
class="ld-btn ld-btn--ghost"
:disabled="loadingSeed !== null"
@click="explore(seed.id)"
>
<UIcon name="i-lucide-compass" />
<span>{{ seed.label }}</span>
</button>
</div>
<LdSeal class="app-seal app-welcome__seal" />
</div>
<!-- ════ Shell ordinaire ════ -->
<template v-else>
<!-- Header -->
<header class="app-header">
<div class="app-header__inner">
<div class="app-header__left">
<button
class="app-header__menu-btn"
aria-label="Ouvrir le menu"
@click="mobileMenuOpen = true"
>
<UIcon name="i-lucide-menu" class="app-header__menu-icon" />
</button>
<NuxtLink to="/" class="app-header__logo">
<span class="logo-stamp">
<LdSeal :size="18" />
</span>
<span class="logo-text">
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
</span>
</NuxtLink>
</div>
<div class="app-header__center">
<LdWorkspaceSelector />
<LdMoodSwitcher />
</div>
<div class="app-header__right">
<NuxtLink to="/decider" class="ld-btn app-header__decide">
Décider
</NuxtLink>
</div>
</div>
</header>
<!-- Drawer mobile -->
<USlideover
v-model:open="mobileMenuOpen"
side="left"
title="Menu"
description="Navigation, collectif et ambiance."
:ui="{ header: 'ldov-header', title: 'ldov-title', description: 'ldov-sr-only', close: 'ldov-close', body: 'ldov-body' }"
>
<template #body>
<nav class="app-mobile-nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-mobile-nav__link"
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
@click="mobileMenuOpen = false"
>
<UIcon :name="item.icon" class="app-mobile-nav__icon" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<div class="app-mobile-row">
<span class="app-mobile-row__label">Collectif</span>
<LdWorkspaceSelector />
</div>
<div class="app-mobile-row">
<span class="app-mobile-row__label">Ambiance</span>
<LdMoodSwitcher />
</div>
</template>
</USlideover>
<!-- Corps : sidebar + page -->
<div class="app-body">
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
<nav class="app-sidebar__nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
>
<UIcon :name="item.icon" class="app-sidebar__icon" />
<span class="app-sidebar__link-label">{{ item.label }}</span>
</NuxtLink>
<div class="app-sidebar__divider" />
<button
class="app-sidebar__toggle"
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<UIcon
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
class="app-sidebar__icon"
/>
<span class="app-sidebar__link-label">Replier</span>
</button>
</nav>
</aside>
<main class="app-main">
<slot />
<LdSeal class="app-seal app-main__seal" />
</main>
</div>
<!-- FAB mobile « Décider » — au-dessus du sceau, qui reste visible -->
<NuxtLink to="/decider" class="app-fab" aria-label="Décider">
<UIcon name="i-lucide-zap" class="app-fab__icon" />
</NuxtLink>
<!-- Footer -->
<footer class="app-footer">
<span>libreDecision v2</span>
<span class="app-footer__sep">·</span>
<span>{{ CREATE_SIGNATURE }}</span>
</footer>
</template>
</div>
<!-- !ready : rien — le flash est évité -->
</template>
<style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
background: var(--mood-bg);
color: var(--mood-text);
}
/* === Logo (partagé header / accueil) === */
.logo-stamp {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--mood-accent);
color: var(--mood-accent-text);
border-radius: 8px;
transform: rotate(-10deg);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.app-header__logo:hover .logo-stamp {
transform: rotate(-16deg) scale(1.08);
}
.logo-stamp--lg {
width: 2.75rem;
height: 2.75rem;
border-radius: 10px;
}
.logo-text {
display: flex;
align-items: baseline;
letter-spacing: -0.01em;
}
.logo-text__libre {
font-size: 1.0625rem;
font-weight: 400;
font-style: italic;
color: var(--mood-text-muted);
}
.logo-text__decision {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
/* === Accueil plein écran === */
.app-welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: clamp(1.5rem, 5vw, 4rem);
text-align: center;
background: var(--mood-gradient);
}
.app-welcome__logo {
display: flex;
align-items: center;
gap: 0.875rem;
margin-bottom: 0.5rem;
}
.app-welcome__logo .logo-text__libre { font-size: 1.375rem; }
.app-welcome__logo .logo-text__decision { font-size: 1.5rem; }
.app-welcome__dream {
max-width: 34rem;
font-size: clamp(1.5rem, 4.5vw, 2.25rem);
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.02em;
margin: 0;
}
.app-welcome__line {
margin: 0;
font-size: 1rem;
font-style: italic;
color: var(--mood-text-muted);
}
.app-welcome__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
}
.app-welcome__seal {
position: fixed;
right: 1.25rem;
bottom: 1rem;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
box-shadow: 0 1px 2px var(--mood-shadow);
}
.app-header__inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
height: 3.5rem;
}
.app-header__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: var(--r-input);
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__menu-icon { font-size: 1.25rem; }
.app-header__center {
display: none;
align-items: center;
gap: 1rem;
flex: 1;
justify-content: center;
min-width: 0;
}
.app-header__right {
display: flex;
align-items: center;
}
.app-header__decide {
display: none;
}
@media (min-width: 768px) {
.app-header__menu-btn { display: none; }
.app-header__center { display: flex; }
.app-header__decide { display: inline-flex; }
}
/* === Corps + sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 13rem;
flex-shrink: 0;
background: var(--mood-surface);
display: none;
transition: width 0.22s ease;
overflow: hidden;
}
.app-sidebar--collapsed { width: 3.75rem; }
@media (min-width: 768px) {
.app-sidebar { display: block; }
}
.app-sidebar__nav {
position: sticky;
top: 3.5rem;
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-sidebar__link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: var(--r-input);
transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
}
.app-sidebar__link:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-sidebar__icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.app-sidebar__link-label {
overflow: hidden;
white-space: nowrap;
transition: opacity 0.18s ease, max-width 0.22s ease;
max-width: 10rem;
}
.app-sidebar--collapsed .app-sidebar__link-label {
opacity: 0;
max-width: 0;
}
.app-sidebar--collapsed .app-sidebar__link {
justify-content: center;
padding: 0.625rem;
}
.app-sidebar__divider {
height: 1px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
margin: 0.375rem 0.25rem;
}
.app-sidebar__toggle {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: pointer;
border-radius: var(--r-input);
width: 100%;
transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
}
.app-sidebar__toggle:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar--collapsed .app-sidebar__toggle {
justify-content: center;
padding: 0.5rem;
}
/* === Drawer mobile === */
.app-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.75rem;
}
.app-mobile-nav__link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.25rem;
font-size: 1.0625rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: var(--r-icon);
min-height: 3rem;
}
.app-mobile-nav__link:hover,
.app-mobile-nav__link:active {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.app-mobile-nav__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-mobile-nav__icon { font-size: 1.125rem; }
.app-mobile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
margin-top: 0.5rem;
}
.app-mobile-row__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
flex-shrink: 0;
}
/* === Main + sceau === */
.app-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 1.5rem 1.25rem;
}
@media (min-width: 640px) {
.app-main { padding: 2rem 1.75rem; }
}
@media (min-width: 1024px) {
.app-main { padding: 2rem 2.5rem; }
}
.app-main__seal {
margin: auto 0 0.5rem auto;
padding-top: 1.5rem;
box-sizing: content-box;
}
/* === FAB mobile === */
.app-fab {
position: fixed;
right: 1rem;
bottom: 4.75rem; /* décalage vertical : le sceau reste visible dessous */
z-index: 35;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
box-shadow: var(--shadow-raised);
text-decoration: none;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.app-fab:hover {
transform: translateY(-1px);
}
.app-fab:active {
transform: translateY(0);
}
.app-fab__icon { font-size: 1.5rem; }
@media (min-width: 768px) {
.app-fab { display: none; }
}
/* === Footer === */
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.app-footer__sep { opacity: 0.3; }
</style>
+252
View File
@@ -0,0 +1,252 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — lexicon: SINGLE source of French UI labels.
// Tone rules (BLUEPRINT-V2.md):
// 1. tutoyer la personne dans ses gestes ;
// 2. actes collectifs à l'infinitif (« Adopter notre Pacte ») ;
// 3. sujet grammatical des phrases générées = les personnes ou le collectif,
// JAMAIS la formule ou le système.
// The anti-lexicon vitest test (tests/lexicon.spec.ts) enforces FORBIDDEN_UI_TERMS
// over every UI-visible string (lexicon + .vue templates).
// ─────────────────────────────────────────────────────────────
import type {
DecisionRoute,
DecisionStatus,
InertiaPreset,
NominationMethod,
NuancedValue,
VoteMethod,
Weight,
Reversibility,
} from '~/types/domain'
/** Words that must NEVER appear in UI-visible text (case-insensitive, accent-aware).
* They may exist as code identifiers — the test only scans UI strings. */
export const FORBIDDEN_UI_TERMS = [
'déléguer',
'délégation',
'délégué',
'dette',
'retard',
'en souffrance',
'corpus',
'snapshot',
'verdict',
'triage',
'oligarchie',
'peuple adulte',
] as const
// ── Routes — first person: the path is a gesture, not a category ──
export const ROUTE_LABELS: Record<DecisionRoute, string> = {
solo: 'Je décide',
mandate: 'Je décide, sous mandat',
transmit: 'Je transmets',
advice: "J'écoute, puis je décide",
collective: 'Nous décidons',
record: 'Je le consigne',
}
/** Short pill forms (registry filters). */
export const ROUTE_SHORT: Record<DecisionRoute, string> = {
solo: 'seul',
mandate: 'sous mandat',
transmit: 'transmis',
advice: 'sur avis',
collective: 'ensemble',
record: 'consigné',
}
export const ROUTE_ICONS: Record<DecisionRoute, string> = {
solo: 'i-lucide-zap',
mandate: 'i-lucide-key-round',
transmit: 'i-lucide-send',
advice: 'i-lucide-message-circle',
collective: 'i-lucide-users',
record: 'i-lucide-notebook-pen',
}
export const ICONS = {
urgent: 'i-lucide-siren',
alreadyDecided: 'i-lucide-book-open-check',
recurrence: 'i-lucide-repeat',
parametric: 'i-lucide-sliders-horizontal',
crystallize: 'i-lucide-stamp',
} as const
// ── Decision states — plain French pills ──
export const STATUS_LABELS: Record<DecisionStatus, string> = {
draft: 'préparation',
advice: 'avis',
objection: 'objection',
framing: 'formulation',
voting: 'vote',
adopted: 'en vigueur',
rejected: 'rejeté',
revoked: 'révoqué',
transmitted: 'transmis',
closed: 'clos',
}
/** Session display state (frozen lives on VoteSession, shown as a decision badge). */
export const FROZEN_LABEL = 'figé'
export const FROZEN_BANNER = 'Votes figés — en attente de cristallisation'
// ── Weight & reversibility — the tunnel chips ──
export const WEIGHT_LABELS: Record<Weight, string> = {
light: 'rien de lourd',
binding: 'du temps ou des ressources',
structural: 'la structure, les règles, du long terme',
}
export const REVERSIBILITY_LABELS: Record<Reversibility, string> = {
easy: 'facilement réversible',
costly: 'coûteux à défaire',
irreversible: 'irréversible',
}
// ── Nuanced scale — never « CONTRE » ──
export const NUANCED_LABELS: Record<NuancedValue, string> = {
0: 'Pas du tout',
1: 'Plutôt non',
2: 'Réservé',
3: 'Plutôt oui',
4: 'Oui',
5: 'Tout à fait',
}
// ── Vote methods ──
export const METHOD_LABELS: Record<VoteMethod, string> = {
consent: 'Consentement',
binary: 'Pour / contre',
nuanced: 'Vote nuancé',
parametric: 'Réglage collectif',
election: 'Élection',
}
export const BINARY_COST = 'deux camps, un perdant'
export const BINARY_DESCRIPTION = "l'outil de dernier recours des très grands corps"
// ── The review — l'épreuve du réel ──
export const REVIEW_TITLE = "L'épreuve du réel"
export const REVIEW_QUESTION = 'Le réel a-t-il suivi ?'
export const REVIEW_VERDICTS = {
confirmed: 'Ça tient',
revise: 'À revoir',
revoke: 'À révoquer',
} as const
// ── Windows, assent, perimeter ──
export const WINDOW_OK = 'Ça me va'
export const WINDOW_OBJECT = "J'objecte"
export const ASSENT_MISSING = 'Il manque un accord explicite'
export const BOUNDARY_SUSPENDED = 'compte à rebours suspendu — frontière contestée'
export const CONCERN_FIRST = 'Concerné·e en premier lieu'
export const CONCERN_SECOND = "Concerné·e en second lieu — s'est déclaré·e"
export const CONCERN_ME = 'Ça me concerne'
export const PERIMETER_OVERFLOW = "Le périmètre déborde — élargis d'un cran"
export const SCOPE_WIDEN = "Élargir d'un cran"
export const SCOPE_KEEP = 'Maintenir — en motivant publiquement'
// ── Parametric — le réglage collectif ──
export const PARAMETRIC_ALT = 'C\'est un réglage (montant, taux, répartition) — décidez au curseur'
export const PARAM_CONSTRAINT_LABEL = 'contrainte'
export const PARAM_DERIVED_LABEL = 'calculé'
export const IMPACT_CARD_TITLE = 'Pour moi'
export const IMPACT_DISCLAIMER = 'estimation sur données déclarées'
export const EXPLORE_LABEL = 'Explorer'
export const EXPLORE_DISCLAIMER = 'exploration — ne compte pas'
export const CRYSTALLIZE_ACTION = 'Cristalliser'
export const CRYSTALLIZE_CARD = 'Les votes sont figés — cristallise la médiane'
export const MEDIAN_EXPLANATION =
'Le collectif retient la médiane de chaque curseur — robuste aux extrêmes, '
+ "et c'est une position que chacun aurait pu proposer."
export const BIMODAL_BANNER = (paramLabel: string) =>
`Deux positions distinctes se dessinent sur ${paramLabel} — scinder la question ou changer de référentiel ?`
export const CRYSTALLIZE_CHOICES = ['Cristalliser quand même', 'Scinder', 'Reformuler'] as const
export const CRYSTALLIZE_MODAL_DESC = "Tu figes d'un geste la position que le collectif dessine."
// ── Record & observatory ──
export const RECORD_ALT = 'Déjà tranché — je le consigne'
export const RECORD_HOW = "Comment ça s'est décidé ?"
export const OBSERVATORY_TITLE = "L'Observatoire"
export const OBSERVATORY_SUBTITLE = 'comment nous décidons, dans les faits'
export const REVOKED_SECTION = "Révoquées — ce qu'on en a appris"
export const MATURATION_CARD = 'Cette pratique est mûre — protocolise-la en quelques phrases'
// ── Élection ──
export const BLANK_VOTE = 'Vote blanc'
export const ELECTION_RULE =
"La personne la plus désignée est nommée ; en cas d'égalité, vous départagez — jamais l'outil."
// ── Urgence ──
export const URGENT_TOGGLE = "C'est urgent"
export const URGENT_BADGE = 'Décidé en urgence — le collectif ratifie'
export const URGENT_REFUSED = "Irréversible : l'urgence ne peut pas contourner le collectif."
// ── Fil / Aujourd'hui ──
export const FEED_HEADER = 'À toi de décider'
export const CAPTURE_PLACEHOLDER = "Qu'est-ce que tu décides ?"
export const DOSSIER_COMPLETE_CARD = 'Le dossier est complet — clore et publier la cartographie de clôture'
// ── Fiche / tunnel ──
export const PATH_CARD_TITLE = 'Le chemin'
export const CHOOSE_OTHERWISE = 'Je choisis autrement'
export const WHY_DISCLOSURE = 'Pourquoi ?'
export const ENGAGES_LABEL = 'Ce que ça engage'
export const BASELINE_PREFIX = "Aujourd'hui :"
export const BASELINE_ARROW = "Aujourd'hui → Proposé"
export const INSTRUCT_BLOCK = "S'instruire"
export const REOPEN_HANDLE = 'Remettre en question'
export const ADOPTED_STAMP = 'Décidé'
export const ADOPTED_TOAST = "C'est décidé — et révisable, comme tout ici."
export const VOTE_PRIVACY = "Ton vote est remplaçable — ton historique n'est visible que de toi."
export const WHO_VOTES = (n: number, date: string) =>
`Qui vote : ${n} personne${n > 1 ? 's' : ''} — liste arrêtée le ${date}`
// ── Mandats ──
export const MANDATES_TITLE = 'Mandats — les pouvoirs confiés'
export const MANDATE_EXERCISE = 'Exercice du mandat'
export const MANDATE_SPOTLIGHT = 'Feux de la rampe'
export const MANDATE_SPOTLIGHT_SUB = 'toutes les décisions prises sous ce mandat — contestables'
export const MANDATE_CLAIM = 'Réclamer un mandat'
export const MANDATE_REVOKE = 'Demander la révocation'
export const NOMINATION_LABELS: Record<NominationMethod, string> = {
'ratified-self': 'Auto-désignation ratifiée',
'election-no-candidate': 'Élection sans candidat',
'nuanced-vote': 'Vote nuancé',
consent: 'Consentement',
draw: 'Tirage au sort',
rotation: 'Rotation',
}
// ── Textes / Pacte ──
export const PACT_BADGE = 'les règles du jeu'
export const PACT_SUBTITLE = 'Notre contrat social — sacralisé, jamais immuable'
export const INERTIA_LABELS: Record<InertiaPreset, string> = {
low: 'inertie faible',
standard: 'inertie standard',
high: 'inertie haute',
max: 'inertie maximale',
}
export const FIRST_DECISION = 'Adopter notre Pacte'
export const CREATE_SIGNATURE = 'Osons, nous verrons'
export const OPENING_DREAM = "Une démocratie qui n'a jamais existé — à votre échelle."
export const CLOSING_LINE = 'La ville change, le puits demeure.'
// ── Preuve / gravure / données ──
export const PROOF_LOCAL = 'empreinte locale — démo'
export const SECRET_DISPLAY = "secret d'affichage — démo"
export const LINEAGE_PREFIX = 'essaimé de'
export const WORKSHOP_MODE = 'mode atelier'
export const WORKSHOP_RECORDED = (by: string, forWhom: string, date: string) =>
`saisi par ${by} pour ${forWhom} — atelier du ${date}`
export const ATTRIBUTES_HINT =
'servent uniquement ta carte Pour moi, jamais montrés aux autres'
export const SYNC_NOTE =
'Un serveur pourra se brancher plus tard, tes données ne changent pas de forme.'
export const NO_PROTOCOL_BANNER = 'Aucun protocole — crée-le ou décide sur avis'
// ── Génération des phrases de seuil (sujet = le collectif, jamais la formule) ──
export const thresholdSentence = (W: number, T: number, threshold: number) =>
`${T.toLocaleString('fr-FR')} votant${T > 1 ? 's' : ''} sur ${W.toLocaleString('fr-FR')} : `
+ `à si faible participation, il faut presque l'unanimité — ${threshold.toLocaleString('fr-FR')} pour. `
+ 'Plus de monde vote, plus le seuil descend.'
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* /creer — onboarding en 3 étapes (layout bare) : le premier usage EST le
* tutoriel. Étape 1 identité, étape 2 gabarit, étape 3 personnes.
* Atterrit sur / où la première décision « Adopter notre Pacte » attend.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import type { TemplateId } from '~/data/templates'
import { CREATE_SIGNATURE, FIRST_DECISION, OPENING_DREAM } from '~/lexicon'
import { COLOR_CHOICES } from '~/components/onboarding/OnboardingIdentity.vue'
import { TEMPLATE_ICONS } from '~/components/onboarding/OnboardingTemplates.vue'
definePageMeta({ layout: 'bare' })
const store = useCollectiveStore()
const step = ref<1 | 2 | 3>(1)
const name = ref('')
const color = ref(COLOR_CHOICES[0]!.hex)
const template = ref<TemplateId | null>(null)
const meName = ref('')
const members = ref<string[]>([])
const busy = ref(false)
const issues = ref<string[]>([])
const STEP_TITLES: Record<1 | 2 | 3, string> = {
1: 'Donne-lui un nom, une couleur, une ambiance.',
2: 'Choisis un point de départ — tout reste amendable par décision.',
3: 'Toi, puis les premières personnes autour de la table.',
}
const canContinue = computed(() => {
if (busy.value) return false
if (step.value === 1) return name.value.trim().length > 0
if (step.value === 2) return template.value !== null
return meName.value.trim().length > 0
})
function next() {
if (!canContinue.value) return
issues.value = []
if (step.value < 3) step.value++
else void create()
}
function back() {
if (busy.value || step.value === 1) return
issues.value = []
step.value--
}
/** « Les Jardins du Canal » → « les-jardins-du-canal ». */
function slugify(text: string): string {
return (
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'collectif'
)
}
function keepBlockingIssues(all: { level: string; message: string }[]): boolean {
const blocking = all.filter(i => i.level === 'error').map(i => i.message)
issues.value = blocking
return blocking.length > 0
}
async function create() {
if (!template.value || busy.value) return
busy.value = true
const result = await store.createFromTemplate(template.value, {
name: name.value.trim(),
slug: slugify(name.value),
color: color.value,
icon: TEMPLATE_ICONS[template.value],
meName: meName.value.trim(),
memberNames: members.value,
})
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
async function explore(seed: SeedName) {
if (busy.value) return
busy.value = true
issues.value = []
const result = await store.loadSeed(seed)
if (keepBlockingIssues(result.issues) || !result.state) {
busy.value = false
return
}
await navigateTo('/')
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key !== 'Enter') return
const target = ev.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(target.tagName)) return
next()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<!-- ld-v2 -->
<div class="creer">
<header class="creer__head">
<h1 class="creer__dream">{{ OPENING_DREAM }}</h1>
<p class="creer__step-title">{{ STEP_TITLES[step] }}</p>
</header>
<OnboardingIdentity
v-if="step === 1"
v-model:name="name"
v-model:color="color"
@next="next"
/>
<OnboardingTemplates
v-else-if="step === 2"
v-model="template"
@next="next"
@seed="explore"
/>
<OnboardingMembers
v-else
v-model:me-name="meName"
v-model:members="members"
/>
<ul v-if="issues.length" class="creer__issues">
<li v-for="msg in issues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
<footer class="creer__nav">
<button v-if="step > 1" type="button" class="ld-btn ld-btn--quiet" :disabled="busy" @click="back">
<UIcon name="i-lucide-arrow-left" /> Retour
</button>
<span v-else class="creer__spacer" />
<span class="creer__dots" role="status" :aria-label="`Étape ${step} sur 3`">
<span
v-for="n in 3"
:key="n"
class="creer__dot"
:class="{ 'creer__dot--active': step === n, 'creer__dot--done': step > n }"
/>
</span>
<button type="button" class="ld-btn" :disabled="!canContinue" @click="next">
<template v-if="step < 3">
Continuer <UIcon name="i-lucide-arrow-right" />
</template>
<template v-else>
<UIcon name="i-lucide-sparkles" /> Fonder le collectif
</template>
</button>
</footer>
<p v-if="step === 3" class="creer__hint">
La première décision t'attendra : « {{ FIRST_DECISION }} ».
</p>
<p v-if="step === 3" class="creer__signature">{{ CREATE_SIGNATURE }}</p>
</div>
</template>
<style scoped>
.creer {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2.25rem);
width: min(100%, 56rem);
margin-inline: auto;
padding-bottom: 3.5rem;
}
.creer__head {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.creer__dream {
margin: 0;
font-size: clamp(1.6rem, 5vw, 2.6rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--mood-text);
}
.creer__step-title {
margin: 0;
font-size: clamp(0.9375rem, 2.5vw, 1.0625rem);
color: var(--mood-text-muted);
}
.creer__issues {
margin: 0;
padding: 0.875rem 1.1rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.9rem;
font-weight: 600;
}
.creer__issues li {
display: flex;
align-items: center;
gap: 0.45rem;
}
.creer__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.creer__spacer {
min-width: 5.5rem;
}
.creer__dots {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.creer__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--mood-text-muted);
opacity: 0.3;
transition: transform 0.12s ease, opacity 0.12s ease, background 0.12s ease;
}
.creer__dot--active {
background: var(--mood-accent);
opacity: 1;
transform: scale(1.25);
}
.creer__dot--done {
background: var(--mood-accent);
opacity: 0.55;
}
.creer__hint {
margin: -0.75rem 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.creer__signature {
margin: 0;
text-align: center;
font-size: 0.875rem;
font-style: italic;
opacity: 0.65;
color: var(--mood-accent);
}
@media (max-width: 480px) {
.creer__nav {
flex-wrap: wrap;
justify-content: center;
}
.creer__dots {
order: -1;
width: 100%;
justify-content: center;
}
.creer__spacer { display: none; }
}
</style>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
/**
* « /decider — Le chemin » : une phrase → un chemin exécutable en un tap.
* Le moteur pur recalcule à chaque geste ; tout est expliqué en une phrase,
* tout est contournable (alourdir libre, alléger motivé). Chemin nominal :
* phrase + Entrée (au Fil) puis LE bouton — 2 gestes.
*/
import type { ApplyPathEdits } from '~/stores/decisions'
import type {
ChainKind, Decision, DecisionRoute, Id, ParamSpec, Reversibility,
TriageInput, Verdict, Weight,
} from '~/types/domain'
import { computeConcerned } from '~/engine'
import {
ADOPTED_STAMP, ADOPTED_TOAST, METHOD_LABELS,
CAPTURE_PLACEHOLDER, ROUTE_LABELS, WHY_DISCLOSURE,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
type AltKey = 'parametric' | 'record' | 'binary'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
// ── State of the tunnel ──
const title = ref('')
const scope = ref<Decision['scope']>({ selfOnly: true, circleIds: [], personIds: [] })
const reversibility = ref<Reversibility>('easy')
const weight = ref<Weight>('light')
const urgent = ref(false)
const amendsClauseId = ref<Id | undefined>()
const parentId = ref<Id | undefined>()
const chainKind = ref<ChainKind | undefined>()
const baselineNote = ref('')
const resourceNote = ref('')
const resourceAmount = ref<number | null>(null)
const resourceUnit = ref('heures')
const chosenAlt = ref<AltKey | null>(null)
const decidedHow = ref('')
const overrideNote = ref('')
const spec = ref<ParamSpec | undefined>()
const reviewChecked = ref(false)
const engraveChecked = ref(false)
const mandateAttached = ref(false)
const error = ref('')
const stamping = ref(false)
// ── Prefill from the query (?titre ?clause ?parent ?chain ?mandat) ──
const q = route.query
if (typeof q.titre === 'string') title.value = q.titre
if (typeof q.clause === 'string') amendsClauseId.value = q.clause
if (typeof q.parent === 'string') {
const prefill = decisions.reopen(q.parent)
if (!('ok' in prefill)) {
parentId.value = prefill.parentDecisionId
chainKind.value = prefill.chainKind
if (title.value.length === 0) title.value = prefill.title
scope.value = prefill.scope
reversibility.value = prefill.reversibility
weight.value = prefill.weight
if (prefill.amendsClauseId) amendsClauseId.value = prefill.amendsClauseId
}
}
if (typeof q.chain === 'string') chainKind.value = q.chain as ChainKind
if (typeof q.mandat === 'string') {
const mandate = col.mandates.find(m => m.id === q.mandat)
if (mandate) {
scope.value = { selfOnly: false, circleIds: [...mandate.domain.circleIds], personIds: [] }
}
}
// ── The live path — the pure engine recomputes at every gesture ──
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
const tags = computed(() => [...title.value.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()))
const engineInput = computed<TriageInput>(() => ({
title: title.value,
tags: tags.value,
scope: scope.value,
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
...(amendsClauseId.value ? { amendsClauseId: amendsClauseId.value } : {}),
}))
const chemin = computed<Verdict | null>(() => {
const res = decisions.runTriage(engineInput.value)
return 'ok' in res ? null : res
})
watch(() => chemin.value?.reviewRequired, v => { reviewChecked.value = v === true }, { immediate: true })
watch(() => chemin.value?.engravingSuggested, v => { engraveChecked.value = v === true }, { immediate: true })
// ── Concernés pré-calculés (plancher) ──
const stack = computed(() => {
if (scope.value.selfOnly || !col.me) return []
const active = col.mandates.filter(m => m.status === 'active')
return computeConcerned(scope.value, col.circles, active, col.me.id).flatMap((entry) => {
const person = col.people.find(p => p.id === entry.personId)
return person ? [{ person, origin: 'computed' as const, reason: entry.reason }] : []
})
})
// ── Route effective & asymétrie de dérogation ──
const ROUTE_ORDER: Record<DecisionRoute, number> = {
record: 0, solo: 0, mandate: 1, transmit: 1, advice: 2, collective: 3,
}
const effectiveRoute = computed<DecisionRoute>(() => {
if (!chemin.value) return 'solo'
if (!chosenAlt.value) return chemin.value.route
return chosenAlt.value === 'record' ? 'record' : 'collective'
})
const overridden = computed(() => chemin.value !== null && effectiveRoute.value !== chemin.value.route)
const lightening = computed(
() => chemin.value !== null && ROUTE_ORDER[effectiveRoute.value] < ROUTE_ORDER[chemin.value.route],
)
const overrideLabel = computed(() => {
if (chosenAlt.value === 'parametric') return METHOD_LABELS.parametric
if (chosenAlt.value === 'binary') return METHOD_LABELS.binary
if (chosenAlt.value === 'record') return ROUTE_LABELS.record
return ''
})
// ── Frise : durée / seuil (si connus) ──
const protocol = computed(() => {
if (effectiveRoute.value !== 'collective') return undefined
let id = chemin.value?.protocolId
if (chosenAlt.value === 'parametric') id = col.settings?.protocolByRange.parametric ?? id
if (chosenAlt.value === 'binary') id = col.protocols.find(p => p.method === 'binary')?.id ?? id
return col.protocols.find(p => p.id === id)
})
const framingPlanned = computed(
() => chemin.value?.framingDays !== undefined && effectiveRoute.value === 'collective',
)
const durationLabel = computed(() => {
const v = chemin.value
if (!v || effectiveRoute.value === 'solo' || effectiveRoute.value === 'record') return ''
if (framingPlanned.value) return `${v.framingDays} jours de formulation`
if (v.windowHours !== undefined && effectiveRoute.value === v.route) return `fenêtre de ${v.windowHours} h`
return protocol.value ? `${protocol.value.durationDays} jours de vote` : ''
})
const thresholdLabel = computed(() => {
const p = protocol.value
if (!p) return ''
if (p.method === 'consent') return 'zéro objection maintenue'
return METHOD_LABELS[p.method]
})
// ── Bouton principal ──
const mainLabel = computed(() => {
switch (effectiveRoute.value) {
case 'advice': return 'Demande leur avis'
case 'transmit': return 'Transmets'
case 'record': return 'Consigne'
case 'collective': return framingPlanned.value ? 'Ouvre la formulation' : 'Ouvre le vote'
default: return 'Décide'
}
})
const blockReason = computed(() =>
lightening.value && overrideNote.value.trim().length === 0
? 'Alléger se motive — écris pourquoi, ta note s\'affichera.'
: '',
)
const disabled = computed(
() => title.value.trim().length === 0 || blockReason.value !== '' || chemin.value === null,
)
const engagesOpen = computed(
() => (weight.value === 'binding' || weight.value === 'structural') && !scope.value.selfOnly,
)
const baselineSuggested = computed(() => ['advice', 'collective'].includes(effectiveRoute.value))
const suggestion = computed(() => chemin.value?.suggestion)
const reviewLocked = computed(
() => chemin.value?.reviewRequired === true && effectiveRoute.value !== 'solo',
)
// ── Valider : capture → applyPath → fiche (ou Fil si adopté sur-le-champ) ──
async function validate() {
const v = chemin.value
if (!v || disabled.value) return
const created = decisions.capture(title.value)
if ('ok' in created) { error.value = created.reason; return }
if (parentId.value) {
created.parentDecisionId = parentId.value
created.chainKind = chainKind.value ?? 'revision'
}
const edits: ApplyPathEdits = {
scope: {
selfOnly: scope.value.selfOnly,
circleIds: [...scope.value.circleIds],
personIds: [...scope.value.personIds],
},
reversibility: reversibility.value,
weight: weight.value,
urgent: urgent.value,
visibility: scope.value.selfOnly ? 'private' : 'scope',
}
if (effectiveRoute.value !== v.route) edits.route = effectiveRoute.value
if (amendsClauseId.value) edits.amendsClauseId = amendsClauseId.value
if (baselineSuggested.value && baselineNote.value.trim()) edits.baselineNote = baselineNote.value.trim()
if (resourceNote.value.trim().length > 0 || typeof resourceAmount.value === 'number') {
edits.resources = {
note: resourceNote.value.trim(),
...(typeof resourceAmount.value === 'number'
? { amount: resourceAmount.value, unit: resourceUnit.value }
: {}),
}
}
if (chosenAlt.value === 'record' && decidedHow.value.trim()) edits.decidedHow = decidedHow.value.trim()
if (chosenAlt.value === 'parametric') {
if (spec.value) edits.paramSpec = spec.value
const pid = col.settings?.protocolByRange.parametric
if (pid) edits.protocolId = pid
}
if (chosenAlt.value === 'binary') {
const pid = col.protocols.find(p => p.method === 'binary')?.id
if (pid) edits.protocolId = pid
}
if (lightening.value) edits.overrideNote = overrideNote.value.trim()
if (mandateAttached.value && suggestion.value?.kind === 'claim-mandate') {
edits.createsMandate = {
title: `Mandat — ${title.value.trim()}`,
domainCircleIds: [...scope.value.circleIds],
domainTags: [...tags.value],
durationDays: 90,
reportEveryDays: 30,
}
}
const applied = decisions.applyPath(created, { ...v, reviewRequired: reviewChecked.value }, edits)
if ('ok' in applied) { error.value = applied.reason; return }
if (engraveChecked.value && applied.status === 'adopted') await decisions.engrave(applied.id)
if (applied.status === 'adopted'
&& (effectiveRoute.value === 'solo' || effectiveRoute.value === 'record' || v.conservatoryChain)) {
stamping.value = true
setTimeout(() => navigateTo('/'), 1100)
return
}
await navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="decider">
<div v-if="!col.current" class="ld-card decider__none">
<p>Aucun collectif actif — ouvre ou crée un collectif d'abord.</p>
<NuxtLink to="/creer" class="ld-btn">Créer un collectif</NuxtLink>
</div>
<template v-else>
<input
v-model="title"
type="text"
class="decider__title"
:placeholder="CAPTURE_PLACEHOLDER"
:aria-label="CAPTURE_PLACEHOLDER"
>
<CheminQ0
:title="title"
:linked-clause-id="amendsClauseId"
@contest="amendsClauseId = $event"
/>
<CheminChips
v-model:scope="scope"
v-model:reversibility="reversibility"
v-model:weight="weight"
v-model:urgent="urgent"
v-model:resource-note="resourceNote"
v-model:resource-amount="resourceAmount"
v-model:resource-unit="resourceUnit"
v-model:baseline-note="baselineNote"
:stack="stack"
:engages-open="engagesOpen"
:baseline-suggested="baselineSuggested"
/>
<template v-if="chemin">
<CheminPathCard
:path="chemin"
:route-shown="effectiveRoute"
:overridden="overridden"
:override-label="overrideLabel"
:stack="stack"
:self-only="scope.selfOnly"
:duration-label="durationLabel"
:threshold-label="thresholdLabel"
:main-label="mainLabel"
v-model:review-checked="reviewChecked"
v-model:engrave-checked="engraveChecked"
:review-locked="reviewLocked"
:disabled="disabled"
:block-reason="blockReason"
@validate="validate"
/>
<p v-if="error" class="decider__error">{{ error }}</p>
<CheminAlternatives
v-model:chosen="chosenAlt"
v-model:decided-how="decidedHow"
v-model:override-note="overrideNote"
v-model:spec="spec"
:alternatives="chemin.alternatives"
:parametric-hint="chemin.parametricHint === true"
:lightening="lightening"
/>
<details class="decider__why">
<summary>{{ WHY_DISCLOSURE }}</summary>
<p>Règle {{ chemin.rule }} — {{ chemin.explanation }}</p>
<p>
Le chemin est recommandé par le Pacte de ton collectif, jamais imposé :
alourdir est libre en un tap, alléger se motive et s'affiche à tous.
</p>
</details>
<CheminSuggestion
v-if="suggestion"
v-model:mandate-attached="mandateAttached"
:suggestion="suggestion"
/>
</template>
</template>
<!-- Micro-célébration — tampon bref puis retour au Fil -->
<Transition name="stamp">
<div v-if="stamping" class="decider__overlay">
<span class="ld-stamp decider__stamp">井 {{ ADOPTED_STAMP }}</span>
<p class="decider__toast">{{ ADOPTED_TOAST }}</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.decider {
width: 100%;
max-width: 44rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.decider__none {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
}
.decider__title {
width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: clamp(1.125rem, 3.5vw, 1.375rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.decider__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.decider__why {
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.decider__why summary {
cursor: pointer;
font-weight: 700;
padding: 0.25rem;
border-radius: var(--r-input);
width: fit-content;
}
.decider__why summary:hover { color: var(--mood-text); }
.decider__why p { margin: 0.5rem 0 0 0.25rem; line-height: 1.5; }
.decider__overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--mood-bg) 88%, transparent);
}
.decider__stamp { font-size: 1.5rem; padding: 0.75rem 1.5rem; }
.decider__toast { margin: 0; font-size: 1rem; font-weight: 600; }
.stamp-enter-active { transition: opacity 0.12s ease; }
.stamp-enter-from { opacity: 0; }
.stamp-enter-active .decider__stamp {
animation: stamp-in 0.12s ease;
}
@keyframes stamp-in {
from { transform: rotate(-10deg) scale(1.4); opacity: 0; }
to { transform: rotate(-10deg) scale(1); opacity: 1; }
}
</style>
@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* /decisions/[id] — la fiche décision, URL permanente de l'objet pivot.
* Progressive disclosure par état : timeline + périmètre toujours ;
* s'instruire (formulation/vote), fenêtre, formulation, éléments de dossier,
* session, vigueur, chaînage. Imprimable en PV A4 ; gravure locale.
*/
import {
ADOPTED_STAMP, BASELINE_ARROW, ENGAGES_LABEL, INERTIA_LABELS,
REOPEN_HANDLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE, WEIGHT_LABELS,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, displayStatusLabel, dateFr } from '~/components/decisions/decisionUi'
const route = useRoute()
const col = useCollectiveStore()
const decisionId = computed(() => route.params.id as string)
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const shown = computed(() =>
decision.value ? displayStatus(decision.value, session.value) : 'draft')
// ── Origine : protocole et inertie (poignée « Remettre en question ») ──
const originProtocol = computed(() =>
decision.value?.protocolId
? col.protocols.find(p => p.id === decision.value!.protocolId)?.name
: undefined)
const originInertia = computed(() => {
const clauseId = decision.value?.amendsClauseId
if (!clauseId) return undefined
const clause = col.clauses.find(c => c.id === clauseId)
return clause ? INERTIA_LABELS[clause.inertia] : undefined
})
const originLine = computed(() => {
const parts: string[] = []
if (originProtocol.value) parts.push(originProtocol.value)
if (originInertia.value) parts.push(originInertia.value)
return parts.length > 0
? `Le protocole et l'inertie d'origine s'appliquent : ${parts.join(' — ')}.`
: 'Le protocole et l\'inertie d\'origine s\'appliquent.'
})
// ── Blocs selon l'état ──
const showInstruct = computed(() =>
decision.value?.status === 'framing' || decision.value?.status === 'voting')
const showWindow = computed(() =>
decision.value?.status === 'objection' || decision.value?.status === 'advice')
const hasElements = computed(() =>
col.decisions.some(d =>
d.parentDecisionId === decisionId.value && d.chainKind === 'element'))
const framingDays = computed(() => col.settings?.triage.framingDays ?? 14)
// ── Gestes ──
async function splitDossier() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=element`)
}
async function reopen() {
await navigateTo(`/decider?parent=${decisionId.value}&chain=revision`)
}
</script>
<template>
<!-- ld-v2 -->
<article v-if="decision" class="fiche">
<!-- ── En-tête ── -->
<header class="fiche__head">
<div class="fiche__status-row">
<span class="status-pill" :class="`status-${shown}`">
{{ displayStatusLabel(shown) }}
</span>
<span v-if="decision.status === 'adopted'" class="ld-stamp">
井 {{ ADOPTED_STAMP }}
</span>
<span v-if="decision.engraving" class="fiche__well" title="gravée">井</span>
</div>
<h1 class="fiche__title">{{ decision.title }}</h1>
<p v-if="decision.baselineNote" class="fiche__baseline">
<span class="fiche__baseline-arrow">{{ BASELINE_ARROW }}</span>
<span>{{ decision.baselineNote }}</span>
</p>
<div class="fiche__meta">
<span class="fiche__route" :style="{ color: `var(--route-${decision.route})` }">
<UIcon :name="ROUTE_ICONS[decision.route]" />
<span>{{ ROUTE_LABELS[decision.route] }}</span>
</span>
<span class="fiche__chip">{{ WEIGHT_LABELS[decision.weight] }}</span>
<span v-if="decision.urgent" class="fiche__urgent">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_BADGE }}</span>
</span>
</div>
<p v-if="decision.decidedHow" class="fiche__how">
Comment ça s'est décidé : « {{ decision.decidedHow }} »
</p>
<div v-if="decision.routeOverridden && decision.overrideNote" class="fiche__banner">
<UIcon name="i-lucide-feather" />
<span><strong>Chemin allégé</strong> — « {{ decision.overrideNote }} »</span>
</div>
<div v-if="decision.scopeKeptNote" class="fiche__banner">
<UIcon name="i-lucide-circle-dot" />
<span><strong>Périmètre maintenu</strong> — « {{ decision.scopeKeptNote }} »</span>
</div>
</header>
<!-- ── Actions + fiche de preuve ── -->
<DecisionProof :decision="decision" />
<!-- ── Cycle de vie + périmètre ── -->
<div class="fiche__grid">
<div class="ld-card fiche__card">
<h2 class="fiche__card-title">Cycle de vie</h2>
<DecisionTimeline :decision="decision" :session="session" />
</div>
<div class="ld-card fiche__card">
<DecisionPerimeter :decision="decision" />
</div>
</div>
<!-- ── S'instruire ── -->
<div v-if="showInstruct" class="ld-card fiche__card">
<DecisionInstruct :decision="decision" />
</div>
<!-- ── Fenêtre ── -->
<div v-if="showWindow" class="ld-card fiche__card">
<DecisionWindow :decision="decision" />
</div>
<!-- ── Formulation ── -->
<div v-if="decision.status === 'framing'" class="ld-card fiche__card">
<h2 class="fiche__card-title">Formulation</h2>
<p class="fiche__framing-phrase">
{{ framingDays }} jours pour s'instruire et formuler des contre-propositions.
</p>
<LdCountdown v-if="decision.windowEndsAt" :ends-at="decision.windowEndsAt" />
<div v-if="decision.weight === 'structural'" class="no-print">
<button type="button" class="ld-btn ld-btn--ghost" @click="splitDossier()">
<UIcon name="i-lucide-scissors" />
<span>Découper en micro-décisions</span>
</button>
</div>
</div>
<!-- ── Éléments du dossier ── -->
<div v-if="hasElements" class="ld-card fiche__card">
<DecisionElements :decision="decision" />
</div>
<!-- ── Session ── -->
<div v-if="session" class="ld-card fiche__card">
<DecisionSession :decision="decision" />
</div>
<!-- ── Vigueur ── -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card">
<DecisionVigor :decision="decision" />
</div>
<!-- ── Remettre en question ── -->
<div v-if="decision.status === 'adopted'" class="ld-card fiche__card fiche__reopen no-print">
<div>
<p class="fiche__reopen-title">{{ REOPEN_HANDLE }}</p>
<p class="fiche__reopen-line">{{ originLine }}</p>
</div>
<button type="button" class="ld-btn ld-btn--ghost" @click="reopen()">
<UIcon name="i-lucide-rotate-ccw" />
<span>{{ REOPEN_HANDLE }}</span>
</button>
</div>
<!-- ── Chaînage ── -->
<div class="fiche__chain-wrap">
<DecisionChain :decision="decision" />
</div>
<!-- ── Pied de PV (impression) ── -->
<p class="print-only fiche__pv-foot">
{{ ENGAGES_LABEL }} : {{ decision.resources?.note ?? '—' }}
— fiche imprimée le {{ dateFr(new Date().toISOString()) }} · libreDecision
</p>
</article>
<div v-else class="ld-card fiche__missing">
<p>Cette décision est introuvable dans ce collectif.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Retour au registre</NuxtLink>
</div>
</template>
<style scoped>
.fiche {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.fiche__head { display: flex; flex-direction: column; gap: 0.625rem; }
.fiche__status-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.fiche__well {
font-size: 1.25rem;
font-weight: 800;
color: var(--mood-status-vigueur);
transform: rotate(-10deg);
}
.fiche__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.875rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.fiche__baseline {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
font-size: 0.9375rem;
}
.fiche__baseline-arrow {
font-weight: 800;
font-size: 0.8125rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.fiche__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.fiche__route {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 800;
font-size: 0.9375rem;
}
.fiche__chip {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__urgent {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 11%, transparent);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.fiche__how {
margin: 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.fiche__banner {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--r-input);
font-size: 0.9375rem;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
box-shadow: inset 0 0 0 1.5px var(--mood-status-fenetre);
}
.fiche__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.fiche__grid { grid-template-columns: 1fr 1fr; }
}
.fiche__card {
padding: 1.25rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fiche__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.fiche__framing-phrase { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.fiche__reopen {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.fiche__reopen-title { margin: 0; font-weight: 800; font-size: 0.9375rem; }
.fiche__reopen-line {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.fiche__pv-foot { font-size: 0.75rem; color: #333333; }
.fiche__missing {
max-width: 52rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
@media print {
.fiche__grid { grid-template-columns: 1fr 1fr; }
.fiche__card { padding: 0.5rem 0; }
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
// <!-- ld-v2 --> La salle de vote — le geste démocratique central : instruit,
// puis prononcé. Cinq modalités, un seul lieu ; la clôture due est constatée
// au chargement, l'adoption s'applique d'elle-même et se fête sobrement.
import { wotThreshold } from '~/engine'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
ADOPTED_STAMP, ADOPTED_TOAST, ELECTION_RULE, SECRET_DISPLAY, STATUS_LABELS,
VOTE_PRIVACY, WHO_VOTES, WORKSHOP_MODE,
} from '~/lexicon'
import type { Id, VoteSession } from '~/types/domain'
const route = useRoute()
const col = useCollectiveStore()
const store = useDecisionsStore()
const decisionId = computed(() => String(route.params.id))
const decision = computed(() => col.decisions.find(d => d.id === decisionId.value))
const session = computed<VoteSession | undefined>(() =>
col.sessions
.filter(s => s.decisionId === decisionId.value)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0])
const protocol = computed(() => col.protocols.find(p => p.id === session.value?.protocolId))
const method = computed(() => protocol.value?.method)
const secret = computed(() => protocol.value?.ballot === 'secret')
// ── Qui vote — la liste arrêtée ──
const eligibleIds = computed<Id[]>(() => session.value?.corpusPersonIds ?? [])
const voterEntries = computed(() =>
col.people.filter(p => eligibleIds.value.includes(p.id)).map(person => ({ person })))
const arrestedDate = computed(() => session.value
? new Date(session.value.opensAt).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
: '')
// ── Mode atelier — saisir pour quelqu'un, en présence ──
const workshop = ref(false)
const asId = ref<Id | ''>('')
onMounted(() => { workshop.value = !!localStorage.getItem('ld2-workshop') })
const votingAs = computed<Id | undefined>(() =>
workshop.value && asId.value && asId.value !== col.me?.id ? asId.value : undefined)
const voterId = computed(() => votingAs.value ?? col.me?.id)
const inList = computed(() => voterId.value !== undefined && eligibleIds.value.includes(voterId.value))
const canAct = computed(() => session.value?.status === 'open' && inList.value)
const isSteward = computed(() => {
const d = decision.value
const me = col.me
if (!d || !me) return false
return d.stewardIds.length > 0 ? d.stewardIds.includes(me.id) : d.authorId === me.id
})
// ── Clôture due, constatée au chargement ──
const toast = ref('')
function celebrate() {
toast.value = ADOPTED_TOAST
setTimeout(() => { toast.value = '' }, 5000)
}
onMounted(() => {
const s = session.value
if (s && s.status === 'open' && s.closesAt <= col.now()) {
const closed = store.closeSession(s)
if (!('ok' in closed) && closed.outcome === 'adopted') celebrate()
}
})
const displayStatus = computed(() => {
if (session.value?.status === 'frozen') return { css: 'status-frozen', label: 'figé' }
const s = decision.value?.status ?? 'voting'
return { css: `status-${s}`, label: STATUS_LABELS[s] }
})
// ── Affiche de session (impression A4) ──
const closesAtLong = computed(() => session.value
? new Date(session.value.closesAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })
: '')
const posterRule = computed(() => {
const f = protocol.value?.formula
if (!f) return ''
switch (method.value) {
case 'consent': return 'Zéro objection maintenue à l\'échéance — le collectif consent.'
case 'nuanced': return `${f.nuancedThresholdPct ?? 80} % de nuances positives (3 à 5) requis.`
case 'binary': {
const active = session.value ? store.activeVotes(session.value.id) : []
const total = active.filter(v => v.value === 'for' || v.value === 'against').length
const threshold = wotThreshold(Math.max(session.value?.corpusSize ?? 1, 1), total,
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase)
return `Seuil actuel : ${threshold.toLocaleString('fr-FR')} pour — il descend quand la participation monte.`
}
case 'parametric': return `Le collectif retient la médiane de chaque curseur, cristallisée par le garant${f.parametricMinParticipants !== undefined ? ` (quorum ${f.parametricMinParticipants})` : ''}.`
case 'election': return ELECTION_RULE
default: return ''
}
})
function printPoster() { window.print() }
</script>
<template>
<!-- ld-v2 -->
<div class="vote-room">
<template v-if="decision && session && protocol">
<div class="no-print vote-room__inner">
<!-- En-tête -->
<header class="vr__head">
<NuxtLink :to="`/decisions/${decision.id}`" class="vr__back">
<UIcon name="i-lucide-arrow-left" />
<span>Fiche décision</span>
</NuxtLink>
<div class="vr__title-row">
<h1 class="vr__title">{{ decision.title }}</h1>
<span v-if="decision.status === 'adopted'" class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
</div>
<div class="vr__pills">
<span class="status-pill" :class="displayStatus.css">{{ displayStatus.label }}</span>
<span class="vr__protocol">{{ protocol.name }}</span>
<span v-if="secret" class="vr__secret">
<UIcon name="i-lucide-eye-off" />
<span>{{ SECRET_DISPLAY }}</span>
</span>
<LdCountdown v-if="session.status === 'open'" :ends-at="session.closesAt" />
<button class="ld-btn ld-btn--quiet vr__print" type="button" @click="printPoster()">
<UIcon name="i-lucide-printer" />
<span>Imprimer</span>
</button>
</div>
<div class="vr__who">
<span>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</span>
<LdAvatarStack :people="voterEntries" :size="28" :max="8" />
</div>
</header>
<!-- Mode atelier -->
<div v-if="workshop" class="vr__workshop">
<UIcon name="i-lucide-users-round" />
<span>{{ WORKSHOP_MODE }} — saisir pour</span>
<select v-model="asId" class="vr__workshop-select">
<option value="">moi-même</option>
<option v-for="entry in voterEntries" :key="entry.person.id" :value="entry.person.id">
{{ entry.person.displayName }}
</option>
</select>
</div>
<p v-if="session.status === 'open' && !inList" class="vr__consultative">
Tu n'es pas dans la liste arrêtée — ta voix est consultative.
</p>
<!-- S'instruire, puis se prononcer -->
<VoteInstruct :decision="decision" :secret="secret" />
<VoteConsent
v-if="method === 'consent'"
:decision="decision" :session="session"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteNuanced
v-else-if="method === 'nuanced'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteBinary
v-else-if="method === 'binary'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<VoteParametric
v-else-if="method === 'parametric' && decision.paramSpec"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
:is-steward="isSteward"
@adopted="celebrate()"
/>
<VoteElection
v-else-if="method === 'election'"
:decision="decision" :session="session" :protocol="protocol"
:secret="secret" :can-act="canAct" :as-person-id="votingAs"
/>
<!-- Transversal : re-vote et discrétion -->
<p class="vr__privacy">
<UIcon name="i-lucide-shield" />
<span>{{ VOTE_PRIVACY }}</span>
</p>
<VoteMyHistory v-if="col.me && voterId === col.me.id" :session="session" :voter-id="col.me.id" />
</div>
<!-- Affiche de session A4 -->
<div class="print-only vr__poster">
<h1>{{ decision.title }}</h1>
<p v-if="decision.body" class="vr__poster-body">{{ decision.body }}</p>
<hr>
<p><strong>{{ WHO_VOTES(eligibleIds.length, arrestedDate) }}</strong></p>
<p v-if="!secret">{{ voterEntries.map(e => e.person.displayName).join(' · ') }}</p>
<p>{{ posterRule }}</p>
<p><strong>Échéance :</strong> {{ closesAtLong }}</p>
<p><strong>Comment participer :</strong> ouvre la salle de vote « {{ decision.title }} » dans libreDecision, ou confie ton geste en présence (mode atelier).</p>
<p class="vr__poster-seal">井</p>
</div>
</template>
<!-- Garde-fous -->
<div v-else-if="!decision" class="ld-card vr__empty">
<p>Cette décision est introuvable.</p>
<NuxtLink to="/decisions" class="ld-btn ld-btn--ghost">Toutes les décisions</NuxtLink>
</div>
<div v-else class="ld-card vr__empty">
<p>Cette décision n'a pas encore de session de vote.</p>
<NuxtLink :to="`/decisions/${decisionId}`" class="ld-btn ld-btn--ghost">Fiche décision</NuxtLink>
</div>
<!-- Micro-célébration — aucune fanfare -->
<Transition name="vr-toast">
<div v-if="toast" class="vr__toast">
<span class="ld-stamp">井 {{ ADOPTED_STAMP }}</span>
<span>{{ toast }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.vote-room { max-width: 46rem; width: 100%; margin: 0 auto; }
.vote-room__inner { display: flex; flex-direction: column; gap: 1rem; }
.vr__head { display: flex; flex-direction: column; gap: 0.6rem; }
.vr__back {
display: inline-flex; align-items: center; gap: 0.35rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); text-decoration: none;
}
.vr__back:hover { color: var(--mood-accent); }
.vr__title-row { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
.vr__title {
margin: 0; font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 800;
line-height: 1.25; letter-spacing: -0.01em; flex: 1; min-width: 0;
}
.vr__pills { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.vr__protocol {
font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent);
background: var(--mood-accent-soft); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__secret {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-status-fige);
background: var(--mood-status-fige-bg); padding: 4px 13px; border-radius: var(--r-pill);
}
.vr__print { margin-left: auto; padding: 0.25rem 0.75rem; font-size: 0.8125rem; }
.vr__who {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem 0.9rem;
font-size: 0.9375rem; font-weight: 600; color: var(--mood-text-muted);
}
.vr__workshop {
display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;
padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-secondary) 10%, var(--mood-surface));
color: var(--mood-secondary); font-size: 0.875rem; font-weight: 700;
}
.vr__workshop-select {
min-height: 2.25rem; padding: 0.25rem 0.6rem; font-size: 0.875rem; font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.vr__consultative {
margin: 0; padding: 0.55rem 0.9rem; border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre);
font-size: 0.875rem; font-weight: 600;
}
.vr__privacy {
display: flex; align-items: baseline; gap: 0.45rem; margin: 0;
font-size: 0.8125rem; color: var(--mood-text-muted);
}
.vr__empty {
padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;
text-align: center; font-weight: 600;
}
.vr__empty p { margin: 0; }
.vr__toast {
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
z-index: 50; display: flex; align-items: center; gap: 0.75rem;
background: var(--mood-surface); color: var(--mood-text);
padding: 0.75rem 1.25rem; border-radius: var(--r-card);
box-shadow: var(--shadow-raised); font-weight: 600; font-size: 0.9375rem;
max-width: min(92vw, 30rem);
}
.vr-toast-enter-active, .vr-toast-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; }
.vr-toast-enter-from, .vr-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* Affiche A4 — noir sur blanc, sans ambiance */
.vr__poster { color: #111; font-size: 12pt; line-height: 1.5; }
.vr__poster h1 { font-size: 22pt; margin: 0 0 8pt; }
.vr__poster hr { border: none; border-top: 1pt solid #111; margin: 8pt 0; }
.vr__poster-body { white-space: pre-wrap; }
.vr__poster-seal { font-size: 28pt; text-align: right; margin-top: 16pt; }
</style>
@@ -0,0 +1,320 @@
<script setup lang="ts">
/**
* /decisions — le registre v2. Pills d'état cliquables (mapping unique),
* pills courtes de route, filtres (cercle/tag/me concerne/poids/gravées/
* consignées), recherche locale. Zéro bouton de création : on crée par la
* capture — l'état vide le rappelle.
*/
import type { Decision, DecisionRoute, Weight } from '~/types/domain'
import { ROUTE_SHORT, STATUS_LABELS, FROZEN_LABEL, CAPTURE_PLACEHOLDER } from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { displayStatus, fold, type DisplayStatus } from '~/components/decisions/decisionUi'
const col = useCollectiveStore()
// ── Filtres ──
const query = ref('')
const statusFilter = ref<DisplayStatus | null>(null)
const routeFilter = ref<DecisionRoute | null>(null)
const filterCircle = ref<string | null>(null)
const filterTag = ref<string | null>(null)
const filterMine = ref(false)
const filterWeight = ref<Weight | null>(null)
const filterEngraved = ref(false)
const filterRecorded = ref(false)
const latestSession = (decisionId: string) =>
col.sessions
.filter(s => s.decisionId === decisionId)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
const shownStatus = (d: Decision): DisplayStatus => displayStatus(d, latestSession(d.id))
// ── Pills d'état — l'ordre du cycle, comptées, masquées à zéro ──
const STATUS_ORDER: DisplayStatus[] = [
'draft', 'advice', 'objection', 'framing', 'voting', 'frozen',
'adopted', 'closed', 'rejected', 'revoked', 'transmitted',
]
const statusPills = computed(() =>
STATUS_ORDER
.map(status => ({
status,
label: status === 'frozen' ? FROZEN_LABEL : STATUS_LABELS[status],
count: col.decisions.filter(d => shownStatus(d) === status).length,
}))
.filter(pill => pill.count > 0),
)
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routePills = computed(() =>
ROUTES
.map(route => ({ route, count: col.decisions.filter(d => d.route === route).length }))
.filter(pill => pill.count > 0),
)
// ── Liste filtrée puis triée : en cours par échéance, terminées ensuite ──
const meId = computed(() => col.me?.id ?? null)
const ACTIVE: DisplayStatus[] = ['draft', 'advice', 'objection', 'framing', 'voting', 'frozen']
function concernsMe(d: Decision): boolean {
if (!meId.value) return false
if (d.authorId === meId.value) return true
return col.concerns.some(c => c.decisionId === d.id && c.personId === meId.value)
}
function deadlineOf(d: Decision): string {
return d.windowEndsAt ?? latestSession(d.id)?.closesAt ?? d.createdAt
}
const filtered = computed(() => {
const needle = fold(query.value.trim())
return col.decisions.filter((d) => {
if (statusFilter.value && shownStatus(d) !== statusFilter.value) return false
if (routeFilter.value && d.route !== routeFilter.value) return false
if (filterCircle.value && !d.scope.circleIds.includes(filterCircle.value)) return false
if (filterTag.value && !d.tags.includes(filterTag.value)) return false
if (filterMine.value && !concernsMe(d)) return false
if (filterWeight.value && d.weight !== filterWeight.value) return false
if (filterEngraved.value && !d.engraving) return false
if (filterRecorded.value && d.route !== 'record') return false
if (needle.length > 0 && !fold(d.title).includes(needle)
&& !d.tags.some(t => fold(t).includes(needle))) return false
return true
})
})
const sorted = computed(() => {
const active = filtered.value
.filter(d => ACTIVE.includes(shownStatus(d)))
.sort((a, b) => deadlineOf(a).localeCompare(deadlineOf(b)))
const settled = filtered.value
.filter(d => !ACTIVE.includes(shownStatus(d)))
.sort((a, b) => (b.decidedAt ?? b.updatedAt).localeCompare(a.decidedAt ?? a.updatedAt))
return [...active, ...settled]
})
const hasAny = computed(() => col.decisions.length > 0)
function toggleStatus(status: DisplayStatus) {
statusFilter.value = statusFilter.value === status ? null : status
}
function toggleRoute(route: DecisionRoute) {
routeFilter.value = routeFilter.value === route ? null : route
}
</script>
<template>
<!-- ld-v2 -->
<div class="reg">
<header class="reg__header">
<div>
<h1 class="reg__title">Décisions</h1>
<p class="reg__subtitle">le registre — chaque décision a son URL, pour toujours</p>
</div>
<NuxtLink to="/decisions/observatoire" class="reg__observatory">
<UIcon name="i-lucide-telescope" />
<span>L'Observatoire</span>
</NuxtLink>
</header>
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par état">
<button
v-for="pill in statusPills"
:key="pill.status"
type="button"
class="status-pill is-clickable"
:class="[`status-${pill.status}`, { active: statusFilter === pill.status }]"
@click="toggleStatus(pill.status)"
>
<span>{{ pill.label }}</span>
<span class="reg__count">{{ pill.count }}</span>
</button>
</div>
<div v-if="hasAny" class="reg__pills" role="group" aria-label="Filtrer par chemin">
<button
v-for="pill in routePills"
:key="pill.route"
type="button"
class="reg__route-pill"
:class="{ 'reg__route-pill--on': routeFilter === pill.route }"
@click="toggleRoute(pill.route)"
>
{{ ROUTE_SHORT[pill.route] }}
</button>
</div>
<div v-if="hasAny" class="reg__tools">
<div class="reg__search">
<UIcon name="i-lucide-search" class="reg__search-icon" />
<input
v-model="query"
type="search"
placeholder="Chercher une décision…"
aria-label="Chercher une décision"
>
</div>
<DecisionRegistryFilters
v-model:circle-id="filterCircle"
v-model:tag="filterTag"
v-model:mine="filterMine"
v-model:weight="filterWeight"
v-model:engraved="filterEngraved"
v-model:recorded="filterRecorded"
/>
</div>
<div v-if="sorted.length > 0" class="reg__list">
<DecisionRegistryCard v-for="d in sorted" :key="d.id" :decision="d" />
</div>
<div v-else class="ld-card reg__empty">
<template v-if="!hasAny">
<span class="reg__empty-well">井</span>
<p class="reg__empty-title">Aucune décision pour l'instant.</p>
<p class="reg__empty-line">
Rien ne se crée ici : tout part de la capture —
« {{ CAPTURE_PLACEHOLDER }} » sur Aujourd'hui.
</p>
<NuxtLink to="/" class="ld-btn ld-btn--ghost">
<UIcon name="i-lucide-sun-medium" />
<span>Aller à Aujourd'hui</span>
</NuxtLink>
</template>
<template v-else>
<p class="reg__empty-title">Rien ne correspond à ces filtres.</p>
<p class="reg__empty-line">Élargis la recherche — ou décide par la capture, sur Aujourd'hui.</p>
</template>
</div>
</div>
</template>
<style scoped>
.reg {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 52rem;
width: 100%;
margin: 0 auto;
}
.reg__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.reg__title {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.reg__subtitle {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.reg__observatory {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.375rem 1rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
font-weight: 700;
text-decoration: none;
transition: transform 0.1s ease;
}
.reg__observatory:hover { transform: translateY(-1px); }
.reg__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reg__count {
font-size: 0.75rem;
opacity: 0.75;
}
.reg__route-pill {
min-height: 2.25rem;
padding: 0.25rem 0.875rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s ease;
}
.reg__route-pill:hover { transform: translateY(-1px); color: var(--mood-text); }
.reg__route-pill--on {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.reg__tools {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.reg__search {
position: relative;
flex: 1;
min-width: 14rem;
}
.reg__search input {
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem 0.5rem 2.5rem;
font-size: 0.9375rem;
box-shadow: var(--shadow-card);
}
.reg__search-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--mood-text-muted);
pointer-events: none;
}
.reg__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reg__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
text-align: center;
}
.reg__empty-well {
font-size: 2rem;
font-weight: 800;
color: var(--mood-accent);
opacity: 0.5;
transform: rotate(-10deg);
}
.reg__empty-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.reg__empty-line {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
max-width: 28rem;
}
</style>
@@ -0,0 +1,385 @@
<script setup lang="ts">
// <!-- ld-v2 --> /decisions/observatoire — L'Observatoire (Δ12) : comment nous
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
// faits comptés, des barres sobres aux couleurs du mood — jamais un score.
import type { DecisionRoute, Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import {
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
} from '~/lexicon'
import { formatDay } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const decisionsStore = useDecisionsStore()
const reviewLabels = REVIEW_VERDICTS
// ── Filtres : personne / cercle / tag / période ──
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
const since = computed(() =>
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
)
const filtered = computed(() => col.decisions.filter(d =>
(fPerson.value === '' || d.authorId === fPerson.value)
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
&& (fTag.value === '' || d.tags.includes(fTag.value))
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
))
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
// ── Autonomie : ratio par route, maturité, temps médians, participation ──
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routeBars = computed(() => {
const total = Math.max(1, engaged.value.length)
return ROUTES.map((r) => {
const n = engaged.value.filter(d => d.route === r).length
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
})
})
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
function median(xs: number[]): number {
if (xs.length === 0) return 0
const s = [...xs].sort((a, b) => a - b)
const mid = Math.floor(s.length / 2)
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
}
const medianBars = computed(() => ROUTES.map((r) => {
const days = filtered.value
.filter(d => d.route === r && d.decidedAt !== undefined)
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
const m = median(days)
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
}))
const participation = computed(() => {
const ids = new Set(filtered.value.map(d => d.id))
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
})
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
// ── Ressources PAR UNITÉ — l'huile et l'eau, jamais additionnées ──
const resourceLines = computed(() => {
const byUnit = new Map<string, { total: number; count: number }>()
for (const d of engaged.value) {
if (!d.resources?.amount) continue
const unit = d.resources.unit ?? 'sans unité'
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
line.total += d.resources.amount
line.count += 1
byUnit.set(unit, line)
}
return [...byUnit.entries()].map(([unit, l]) => ({
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
}))
})
// ── Épreuves du réel ──
const now = new Date().toISOString()
const reviewsDue = computed(() => filtered.value
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
filtered.value.filter(d => d.review?.verdict === v).length
// ── Révoquées, consignations, maturation ──
const revoked = computed(() => filtered.value
.filter(d => d.status === 'revoked')
.map(d => ({
decision: d,
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
})))
const records = computed(() => filtered.value
.filter(d => d.route === 'record')
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
const maturations = computed(() => {
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
for (const r of records.value) {
const tags = [...new Set(r.tags)].sort()
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
const key = `${tags[i]}|${tags[j]}`
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
entry.ids.add(r.id)
pairs.set(key, entry)
}
}
}
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
.sort((a, b) => b.ids.size - a.ids.size)
const seen = new Set<string>()
return ripe.filter((p) => {
const sig = [...p.ids].sort().join(',')
if (seen.has(sig)) return false
seen.add(sig)
return true
}).slice(0, 4)
})
// ── Élagage & décisions jamais revues ──
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
const unusedProtocols = computed(() => col.protocols.filter(p =>
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
const idleMandates = computed(() => col.mandates.filter(m =>
m.status === 'active' && m.startsAt <= sixMonthsAgo
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
const neverReviewed = computed(() => {
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
return filtered.value.filter(d =>
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
&& d.decidedAt !== undefined && d.decidedAt <= limit)
})
</script>
<template>
<!-- ld-v2 -->
<div class="obs">
<header class="obs__header">
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
</header>
<!-- Filtres -->
<div class="obs__filters">
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
<option value="">Toutes les personnes</option>
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
</select>
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
<option value="">Tous les cercles</option>
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
<option value="">Tous les tags</option>
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
</select>
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
<option value="">Depuis toujours</option>
<option value="30">30 derniers jours</option>
<option value="90">90 derniers jours</option>
<option value="365">Cette année</option>
</select>
</div>
<!-- Autonomie -->
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
<ObservatoryBars :items="routeBars" />
<div class="obs__tiles">
<ObservatoryStat
label="Maturité"
:value="`${recordedCount} / ${tooledCount}`"
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
/>
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
</div>
<h3 class="obs__h3">Temps médians par route</h3>
<ObservatoryBars :items="medianBars" />
<h3 class="obs__h3">Dérogations au chemin</h3>
<div class="obs__tiles">
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
</div>
</ObservatorySection>
<!-- Ressources par unité -->
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
<ul v-if="resourceLines.length" class="obs__list">
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
</li>
</ul>
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
</ObservatorySection>
<!-- Épreuves du réel -->
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
<div class="obs__tiles">
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
</div>
<ul v-if="reviewsDue.length" class="obs__list">
<li v-for="r in reviewsDue" :key="r.id">
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
<span class="obs__row-title">{{ r.title }}</span>
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
</NuxtLink>
</li>
</ul>
</ObservatorySection>
<!-- Révoquées — ce qu'on en a appris -->
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
<ul v-if="revoked.length" class="obs__list">
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
<span class="obs__row-title">{{ entry.decision.title }}</span>
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
</NuxtLink>
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
<UIcon name="i-lucide-link" />
la décision qui l'a révoquée
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant — aucune révoquée sur la période.</p>
</ObservatorySection>
<!-- Consignations -->
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs — consigné ici, tel quel">
<ul v-if="records.length" class="obs__list">
<li v-for="d in records" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
<span class="obs__row-title">{{ d.title }}</span>
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
</NuxtLink>
</li>
</ul>
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
<p class="obs__card-title">
<UIcon name="i-lucide-sprout" />
{{ MATURATION_CARD }}
</p>
<p class="obs__muted">
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
</p>
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
Protocoliser
</NuxtLink>
</div>
</ObservatorySection>
<!-- Élagage & relances -->
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue — pas un enterrement silencieux">
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
</div>
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
<UIcon name="i-lucide-key-round" />
voir la fiche — une revue s'impose peut-être
</NuxtLink>
</div>
<template v-if="neverReviewed.length">
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
<ul class="obs__list">
<li v-for="d in neverReviewed" :key="d.id">
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
<span class="obs__row-title">{{ d.title }}</span>
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
</NuxtLink>
</li>
</ul>
</template>
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
Rien à élaguer — tout ce qui existe sert encore.
</p>
</ObservatorySection>
</div>
</template>
<style scoped>
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.obs__select {
flex: 1 1 10rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-input-bg);
border: none;
border-radius: var(--r-input);
box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.obs__h3 {
margin: 0.375rem 0 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
.obs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
text-decoration: none;
color: var(--mood-text);
transition: transform 0.1s ease;
flex-wrap: wrap;
}
.obs__row:hover { transform: translateY(-1px); }
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
.obs__learned {
margin: 0;
padding-left: 0.75rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
.obs__chain {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
padding-left: 0.75rem;
}
.obs__chain:hover { text-decoration: underline; }
.obs__card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.875rem 1rem;
border-radius: var(--r-icon);
background: var(--mood-accent-soft);
}
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
.obs__card-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
/**
* /donnees — local-first assumé : tes données, ton fichier, ta machine.
* Sections : collectifs locaux · import · jeux de démonstration · identité ·
* mode atelier · niveaux de preuve. Note synchro en pied.
*/
import { useCollectiveStore } from '~/stores/collective'
import type { SeedName } from '~/stores/collective'
import { PROOF_LOCAL, SYNC_NOTE, WORKSHOP_MODE } from '~/lexicon'
const store = useCollectiveStore()
// ── Jeux de démonstration — désactivés si déjà présents ──────
const seedBusy = ref<SeedName | null>(null)
const seedIssues = ref<string[]>([])
const hasSeed = (slug: string) => store.index.some(entry => entry.slug === slug)
async function loadSeed(name: SeedName) {
if (seedBusy.value) return
seedBusy.value = name
seedIssues.value = []
const result = await store.loadSeed(name)
seedIssues.value = result.issues.filter(i => i.level === 'error').map(i => i.message)
seedBusy.value = null
}
// ── Mode atelier — flag local lu par les écrans de vote ──────
const workshop = ref(false)
onMounted(() => {
workshop.value = localStorage.getItem('ld2-workshop') === '1'
})
function toggleWorkshop() {
workshop.value = !workshop.value
localStorage.setItem('ld2-workshop', workshop.value ? '1' : '0')
}
</script>
<template>
<!-- ld-v2 -->
<div class="donnees">
<header class="donnees__head">
<h1 class="donnees__title">Données locales</h1>
<p class="donnees__sub">Tes données, ton fichier, ta machine.</p>
</header>
<DataCollectivesCard />
<DataImportCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-sprout" /> Jeux de démonstration
</h2>
<p class="data-card__text">
Deux collectifs complets, importés par le même chemin qu'un fichier à toi.
</p>
<div class="seed-row">
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('duniter-g1') || seedBusy !== null"
@click="loadSeed('duniter-g1')"
>
<UIcon name="i-lucide-coins" />
{{ hasSeed('duniter-g1') ? 'Duniter Ğ1 — déjà là' : 'Explorer Duniter Ğ1' }}
</button>
<button
type="button"
class="ld-btn ld-btn--ghost"
:disabled="hasSeed('atelier-du-canal') || seedBusy !== null"
@click="loadSeed('atelier-du-canal')"
>
<UIcon name="i-lucide-hammer" />
{{ hasSeed('atelier-du-canal') ? "L'Atelier du Canal — déjà là" : "Explorer l'Atelier du Canal" }}
</button>
</div>
<ul v-if="seedIssues.length" class="seed-issues">
<li v-for="msg in seedIssues" :key="msg">
<UIcon name="i-lucide-circle-alert" /> {{ msg }}
</li>
</ul>
</section>
<DataIdentityCard />
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-users-round" />
<span class="cap-word">{{ WORKSHOP_MODE }}</span>
</h2>
<div class="workshop">
<p class="data-card__text">
En présence, une seule machine circule : chaque saisie garde qui a saisi,
pour qui, et la date de l'atelier.
</p>
<button
type="button"
class="switch"
:class="{ 'switch--on': workshop }"
role="switch"
:aria-checked="workshop"
:aria-label="WORKSHOP_MODE"
@click="toggleWorkshop"
>
<span class="switch__thumb" />
</button>
</div>
</section>
<section class="ld-card data-card">
<h2 class="data-card__title">
<UIcon name="i-lucide-shield-check" /> Niveaux de preuve
</h2>
<ul class="proof-list">
<li class="proof-row">
<UIcon name="i-lucide-fingerprint" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name cap-word">{{ PROOF_LOCAL }}</span>
<span class="proof-row__desc">
Chaque gravure calcule l'empreinte du texte décidé, vérifiable sur ta machine.
</span>
</span>
<span class="status-pill status-adopted">disponible</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-globe" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">IPFS</span>
<span class="proof-row__desc">Une copie de l'empreinte répliquée entre pairs.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
<li class="proof-row proof-row--off">
<UIcon name="i-lucide-link" class="proof-row__icon" />
<span class="proof-row__main">
<span class="proof-row__name">Chaîne Duniter</span>
<span class="proof-row__desc">L'empreinte ancrée dans la chaîne, pour de bon.</span>
</span>
<span class="status-pill status-closed">à venir</span>
</li>
</ul>
</section>
<p class="donnees__sync">{{ SYNC_NOTE }}</p>
</div>
</template>
<style scoped>
.donnees {
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
width: min(100%, 46rem);
margin-inline: auto;
padding: clamp(1rem, 3vw, 2rem) clamp(0.875rem, 3vw, 1.5rem) 4.5rem;
}
.donnees__head {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.donnees__title {
margin: 0;
font-size: clamp(1.4rem, 4vw, 1.8rem);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--mood-text);
}
.donnees__sub {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.data-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.data-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
.data-card__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--mood-text-muted);
}
.cap-word {
display: inline-block;
}
.cap-word::first-letter {
text-transform: uppercase;
}
.seed-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.seed-issues {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0.75rem 1rem;
list-style: none;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: var(--mood-error);
font-size: 0.875rem;
font-weight: 600;
}
.seed-issues li {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.workshop {
display: flex;
align-items: center;
gap: 1rem;
}
.switch {
position: relative;
width: 3.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
border-radius: var(--r-pill);
cursor: pointer;
background: var(--mood-input-border);
transition: background 0.15s ease;
}
.switch--on {
background: var(--mood-accent);
}
.switch__thumb {
position: absolute;
top: 50%;
left: 0.3rem;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
background: var(--mood-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transform: translateY(-50%);
transition: left 0.15s ease;
}
.switch--on .switch__thumb {
left: calc(100% - 1.45rem);
}
.proof-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.proof-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
border-radius: var(--r-input);
background: var(--mood-bg);
}
.proof-row--off {
opacity: 0.55;
}
.proof-row__icon {
font-size: 1.25rem;
flex-shrink: 0;
color: var(--mood-accent);
}
.proof-row--off .proof-row__icon {
color: var(--mood-text-muted);
}
.proof-row__main {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.proof-row__name {
font-weight: 700;
color: var(--mood-text);
}
.proof-row__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.donnees__sync {
margin: 0;
text-align: center;
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
@media (max-width: 480px) {
.proof-row {
flex-wrap: wrap;
}
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
/**
* « / — Aujourd'hui » : capture + Fil personnel — la boîte de réception de la
* démocratie. Tout est sélecteur Pinia pur (useFeed) ; les sections vides sont
* masquées ; le compteur est nu — jamais de rouge.
*/
import type { Decision } from '~/types/domain'
import {
BOUNDARY_SUSPENDED,
CRYSTALLIZE_CARD,
DOSSIER_COMPLETE_CARD,
FEED_HEADER,
FIRST_DECISION,
ICONS,
MATURATION_CARD,
REVIEW_TITLE,
SCOPE_KEEP,
SCOPE_WIDEN,
} from '~/lexicon'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { useFeed } from '~/composables/useFeed'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const {
objectionWindows, adviceRequests, openVotes, toCrystallize, dossiersComplete,
tiesToBreak, reviewsDue, mandateReportsDue, boundaryObjections,
overflowingScopes, prioritiesAsked, suggestions, collectiveActivity, myCount,
} = useFeed()
const dossierError = ref('')
const allEmpty = computed(
() => myCount.value === 0 && suggestions.value.length === 0
&& collectiveActivity.value.length === 0,
)
/** The proposed founding decision, still in preparation. */
const pactPending = computed(() =>
col.decisions.some(d => d.title === FIRST_DECISION && d.status === 'draft'),
)
const decisionById = (id: string): Decision | undefined =>
col.decisions.find(d => d.id === id)
const personName = (id: string): string =>
col.people.find(p => p.id === id)?.displayName ?? 'Quelqu\'un'
const dateFr = (iso: string): string =>
new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
/** Steward gesture: framing → closed once every element is terminal. */
function closeDossier(decision: Decision) {
const res = decisions.transition(decision.id, 'closed')
dossierError.value = res.ok ? '' : res.reason
}
</script>
<template>
<!-- ld-v2 -->
<div class="feed-page">
<FeedCapture />
<header class="feed-header">
<h1 class="feed-header__title">{{ FEED_HEADER }}</h1>
<span v-if="myCount > 0" class="feed-header__count">{{ myCount }}</span>
</header>
<!-- État vide global — invitation chaleureuse -->
<div v-if="allEmpty" class="ld-card feed-empty">
<UIcon name="i-lucide-sun-medium" class="feed-empty__icon" />
<p class="feed-empty__line">
Rien ne t'attend — c'est le bon moment pour capturer ta première décision.
</p>
<p class="feed-empty__hint">
Une phrase suffit, là-haut. Le chemin s'occupe du reste.
</p>
<NuxtLink
v-if="pactPending"
class="ld-btn"
:to="{ path: '/decider', query: { titre: FIRST_DECISION } }"
>
<UIcon name="i-lucide-feather" />
<span>{{ FIRST_DECISION }}</span>
</NuxtLink>
</div>
<template v-else>
<!-- Frontières contestées — priorité haute, en tête -->
<FeedSection
v-if="boundaryObjections.length > 0"
title="Frontières contestées — à traiter d'abord"
icon="i-lucide-octagon-pause"
priority
>
<FeedItemCard
v-for="o in boundaryObjections"
:key="o.id"
:title="decisionById(o.decisionId)?.title ?? 'Décision'"
icon="i-lucide-octagon-pause"
tint="var(--mood-status-fenetre)"
:to="`/decisions/${o.decisionId}`"
>
<template #meta>
<span class="status-pill status-suspended">{{ BOUNDARY_SUSPENDED }}</span>
</template>
<p class="feed-quote">{{ personName(o.personId) }} : « {{ o.argument }} »</p>
</FeedItemCard>
</FeedSection>
<!-- Fenêtres d'objection -->
<FeedSection
v-if="objectionWindows.length > 0"
title="Fenêtres d'objection"
icon="i-lucide-timer"
>
<FeedWindowCard v-for="d in objectionWindows" :key="d.id" :decision="d" />
</FeedSection>
<!-- Avis demandés -->
<FeedSection
v-if="adviceRequests.length > 0"
title="Avis demandés"
icon="i-lucide-message-circle"
>
<FeedAdviceCard v-for="d in adviceRequests" :key="d.id" :decision="d" />
</FeedSection>
<!-- Votes ouverts -->
<FeedSection v-if="openVotes.length > 0" title="Votes ouverts" icon="i-lucide-users">
<FeedVoteCard
v-for="item in openVotes"
:key="item.session.id"
:session="item.session"
:decision="item.decision"
/>
</FeedSection>
<!-- À cristalliser — le geste du garant -->
<FeedSection v-if="toCrystallize.length > 0" title="À cristalliser" :icon="ICONS.crystallize">
<FeedItemCard
v-for="item in toCrystallize"
:key="item.session.id"
:title="item.decision?.title ?? 'Réglage collectif'"
:icon="ICONS.crystallize"
tint="var(--mood-accent)"
:to="item.decision ? `/decisions/${item.decision.id}/vote` : undefined"
>
<p class="feed-line">{{ CRYSTALLIZE_CARD }}</p>
</FeedItemCard>
</FeedSection>
<!-- Dossiers complets — clore est un geste -->
<FeedSection v-if="dossiersComplete.length > 0" title="Dossiers complets" icon="i-lucide-folder-check">
<FeedItemCard
v-for="d in dossiersComplete"
:key="d.id"
:title="d.title"
icon="i-lucide-folder-check"
tint="var(--mood-status-vigueur)"
:to="`/decisions/${d.id}`"
>
<p class="feed-line">{{ DOSSIER_COMPLETE_CARD }}</p>
<p v-if="dossierError" class="feed-error">{{ dossierError }}</p>
<template #actions>
<button class="ld-btn feed-btn" type="button" @click="closeDossier(d)">
<UIcon name="i-lucide-stamp" />
<span>Clore et publier</span>
</button>
</template>
</FeedItemCard>
</FeedSection>
<!-- Égalités à départager — jamais par l'outil -->
<FeedSection v-if="tiesToBreak.length > 0" title="Égalités à départager" icon="i-lucide-scale">
<FeedItemCard
v-for="item in tiesToBreak"
:key="item.session.id"
:title="item.decision?.title ?? 'Élection'"
icon="i-lucide-scale"
tint="var(--mood-status-vote)"
:to="item.decision ? `/decisions/${item.decision.id}` : undefined"
>
<p class="feed-line">Égalité constatée — vous départagez, jamais l'outil.</p>
</FeedItemCard>
</FeedSection>
<!-- L'épreuve du réel -->
<FeedSection v-if="reviewsDue.length > 0" :title="REVIEW_TITLE" icon="i-lucide-flask-conical">
<FeedReviewCard v-for="d in reviewsDue" :key="d.id" :decision="d" />
</FeedSection>
<!-- Rapports de mandat -->
<FeedSection v-if="mandateReportsDue.length > 0" title="Rapports de mandat" icon="i-lucide-key-round">
<FeedItemCard
v-for="item in mandateReportsDue"
:key="item.mandate.id"
:title="item.mandate.title"
icon="i-lucide-key-round"
tint="var(--route-mandate)"
:to="`/mandats/${item.mandate.id}`"
>
<p class="feed-line">Un rapport est attendu depuis le {{ dateFr(item.dueAt) }}.</p>
</FeedItemCard>
</FeedSection>
<!-- Périmètres qui débordent -->
<FeedSection v-if="overflowingScopes.length > 0" title="Périmètres qui débordent" icon="i-lucide-expand">
<FeedItemCard
v-for="d in overflowingScopes"
:key="d.id"
:title="d.title"
icon="i-lucide-expand"
tint="var(--mood-status-fenetre)"
>
<p class="feed-line">Plus de monde se déclare concerné que prévu — décide du périmètre.</p>
<template #actions>
<NuxtLink class="ld-btn feed-btn" :to="`/decisions/${d.id}?geste=elargir`">
{{ SCOPE_WIDEN }}
</NuxtLink>
<NuxtLink class="ld-btn ld-btn--ghost feed-btn" :to="`/decisions/${d.id}?geste=maintenir`">
{{ SCOPE_KEEP }}
</NuxtLink>
</template>
</FeedItemCard>
</FeedSection>
<!-- Pondère tes enjeux -->
<FeedSection v-if="prioritiesAsked.length > 0" title="Pondère tes enjeux" icon="i-lucide-sliders-horizontal">
<FeedItemCard
v-for="c in prioritiesAsked"
:key="c.id"
:title="decisionById(c.decisionId)?.title ?? 'Élément de dossier'"
icon="i-lucide-sliders-horizontal"
tint="var(--mood-tertiary)"
:to="`/decisions/${c.decisionId}`"
>
<p class="feed-line">Dis au dossier combien cet élément compte pour toi (0–3).</p>
</FeedItemCard>
</FeedSection>
<!-- Boucle apprenante — cartes discrètes -->
<FeedSection v-if="suggestions.length > 0" title="Boucle apprenante" icon="i-lucide-repeat" discrete>
<template v-for="(s, i) in suggestions" :key="i">
<FeedItemCard
v-if="s.kind === 'claim-mandate'"
:title="`${s.count}ᵉ fois sur « ${s.tags[0]} » — réclame un mandat`"
:icon="ICONS.recurrence"
to="/mandats/nouveau"
/>
<FeedItemCard
v-else-if="s.kind === 'protocolize'"
:title="MATURATION_CARD"
icon="i-lucide-sprout"
to="/textes"
>
<p class="feed-line">{{ s.count }} consignations proches sur « {{ s.tags[0] }} ».</p>
</FeedItemCard>
<FeedItemCard
v-else
:title="`Le protocole « ${s.protocol.name} » n'a jamais servi — élague ?`"
icon="i-lucide-scissors"
to="/textes/formules"
/>
</template>
</FeedSection>
<!-- Activité du collectif -->
<FeedSection v-if="collectiveActivity.length > 0" title="Activité du collectif" icon="i-lucide-activity">
<FeedActivityCard v-for="d in collectiveActivity" :key="d.id" :decision="d" />
</FeedSection>
</template>
</div>
</template>
<style scoped>
.feed-page {
width: 100%;
max-width: 44rem;
margin: 0 auto;
}
.feed-header {
display: flex;
align-items: baseline;
gap: 0.625rem;
margin: 0.75rem 0 1.25rem;
}
.feed-header__title {
margin: 0;
font-size: clamp(1.375rem, 4vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.feed-header__count {
font-size: 1.0625rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.feed-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: clamp(2rem, 6vw, 3.5rem) 1.5rem;
text-align: center;
}
.feed-empty__icon { font-size: 2rem; color: var(--mood-accent); }
.feed-empty__line {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
max-width: 26rem;
}
.feed-empty__hint {
margin: 0;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
.feed-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.feed-quote { margin: 0; font-size: 0.875rem; font-style: italic; }
.feed-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
.feed-btn { font-size: 0.875rem; padding: 0.4rem 1rem; text-decoration: none; }
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/[id] — le pouvoir confié, visible et reprenable.
// Origine cliquable (le mandat EST né d'une décision), faits comptés (Δ7),
// feux de la rampe, révocation en un geste au protocole d'origine.
import { useCollectiveStore } from '~/stores/collective'
import { MANDATE_REVOKE, NOMINATION_LABELS } from '~/lexicon'
import {
MANDATE_STATUS_LABELS,
MANDATE_STATUS_PILL,
} from '~/components/mandates/mandateUi'
const route = useRoute()
const col = useCollectiveStore()
const mandate = computed(() =>
col.mandates.find(m => m.id === String(route.params.id)),
)
const holder = computed(() =>
col.people.find(p => p.id === mandate.value?.holderId),
)
const originDecision = computed(() =>
col.decisions.find(d => d.id === mandate.value?.originDecisionId),
)
const domainCircles = computed(() =>
(mandate.value?.domain.circleIds ?? [])
.map(id => col.circles.find(c => c.id === id))
.filter(c => c !== undefined),
)
const electorCircle = computed(() =>
col.circles.find(c => c.id === mandate.value?.electorCircleId),
)
const revokeTo = computed(() =>
mandate.value ? `/decider?mandat=${mandate.value.id}&chain=revocation` : '/decider',
)
</script>
<template>
<!-- ld-v2 -->
<div v-if="mandate" class="mfiche">
<NuxtLink to="/mandats" class="mfiche__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<!-- ── En-tête : le pouvoir, explicite ── -->
<header class="mfiche__head ld-card">
<div class="mfiche__title-row">
<h1 class="mfiche__title">{{ mandate.title }}</h1>
<span class="status-pill" :class="MANDATE_STATUS_PILL[mandate.status]">
{{ MANDATE_STATUS_LABELS[mandate.status] }}
</span>
</div>
<div v-if="holder" class="mfiche__holder">
<LdAvatarStack :people="[{ person: holder }]" :size="38" />
<div>
<p class="mfiche__holder-name">{{ holder.displayName }}</p>
<p class="mfiche__holder-role">titulaire</p>
</div>
</div>
<dl class="mfiche__meta">
<div class="mfiche__meta-row">
<dt>Origine</dt>
<dd>
<NuxtLink
v-if="originDecision"
:to="`/decisions/${originDecision.id}`"
class="mfiche__origin"
>
<UIcon name="i-lucide-scale" />
{{ originDecision.title }}
</NuxtLink>
<span v-else class="mfiche__muted">décision fondatrice hors de ce fichier</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Domaine</dt>
<dd class="mfiche__chips">
<span v-for="c in domainCircles" :key="c.id" class="mfiche__chip">
<UIcon name="i-lucide-circle-dashed" />
{{ c.name }}
</span>
<span v-for="tag in mandate.domain.tags" :key="tag" class="mfiche__tag">
#{{ tag }}
</span>
<span v-if="!domainCircles.length && !mandate.domain.tags.length" class="mfiche__muted">
domaine non précisé
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Cercle électeur</dt>
<dd>
<span class="mfiche__chip">
<UIcon name="i-lucide-users-round" />
{{ electorCircle?.name ?? '—' }}
</span>
</dd>
</div>
<div class="mfiche__meta-row">
<dt>Nomination</dt>
<dd>{{ NOMINATION_LABELS[mandate.nominationMethod] }}</dd>
</div>
<div class="mfiche__meta-row mfiche__meta-row--period">
<dt>Période</dt>
<dd>
<MandatePeriodBar :starts-at="mandate.startsAt" :ends-at="mandate.endsAt" />
</dd>
</div>
</dl>
</header>
<!-- ── Exercice du mandat — faits comptés ── -->
<MandateExercise :mandate="mandate" />
<!-- ── Feux de la rampe ── -->
<MandateSpotlights :mandate="mandate" />
<!-- ── Révocation en un geste — jamais improvisée ── -->
<section v-if="mandate.status === 'active'" class="mfiche__revoke ld-card">
<NuxtLink :to="revokeTo" class="ld-btn ld-btn--ghost mfiche__revoke-btn">
<UIcon name="i-lucide-key-square" />
{{ MANDATE_REVOKE }}
</NuxtLink>
<p class="mfiche__revoke-note">
La révocation suit le protocole de nomination d'origine
({{ NOMINATION_LABELS[mandate.nominationMethod] }}), avec le cercle électeur
« {{ electorCircle?.name ?? '—' }} » comme périmètre — jamais plus coûteuse
que la nomination.
</p>
</section>
</div>
<div v-else class="mfiche mfiche--missing">
<p class="ld-card mfiche__missing-card">
Ce mandat est introuvable dans ce collectif.
<NuxtLink to="/mandats" class="mfiche__origin">Revenir aux mandats</NuxtLink>
</p>
</div>
</template>
<style scoped>
.mfiche {
max-width: 46rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.mfiche__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
align-self: flex-start;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
}
.mfiche__back:hover { color: var(--mood-text); }
.mfiche__head {
padding: 1.375rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mfiche__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mfiche__title {
margin: 0;
font-size: clamp(1.25rem, 3.5vw, 1.625rem);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.01em;
}
.mfiche__holder { display: flex; align-items: center; gap: 0.75rem; }
.mfiche__holder-name { margin: 0; font-size: 1rem; font-weight: 700; }
.mfiche__holder-role { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__meta { margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.mfiche__meta-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.75rem;
align-items: baseline;
}
.mfiche__meta-row dt {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.mfiche__meta-row dd { margin: 0; font-size: 0.9375rem; min-width: 0; }
.mfiche__meta-row--period dd { padding-top: 0.25rem; }
@media (max-width: 479px) {
.mfiche__meta-row { grid-template-columns: 1fr; gap: 0.125rem; }
}
.mfiche__origin {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.mfiche__origin:hover { text-decoration: underline; }
.mfiche__chips { display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: center; }
.mfiche__chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 10px;
border-radius: var(--r-pill);
}
.mfiche__tag { font-size: 0.8125rem; color: var(--mood-text-muted); }
.mfiche__muted { color: var(--mood-text-muted); font-style: italic; font-size: 0.875rem; }
.mfiche__revoke {
padding: 1.125rem 1.375rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.mfiche__revoke-btn { align-self: flex-start; color: var(--mood-status-revoque); }
.mfiche__revoke-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
}
.mfiche--missing { padding-top: 2rem; }
.mfiche__missing-card {
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats — la carte des pouvoirs confiés : qui peut décider
// quoi, jusqu'à quand. Cartes par statut, compteurs bruts (Δ7 : aucune jauge).
import type { Mandate } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { MANDATES_TITLE, MANDATE_CLAIM } from '~/lexicon'
import { MANDATE_STATUS_LABELS } from '~/components/mandates/mandateUi'
const col = useCollectiveStore()
const ORDER: Mandate['status'][] = ['active', 'proposed', 'expired', 'revoked']
const groups = computed(() =>
ORDER
.map(status => ({
status,
label: MANDATE_STATUS_LABELS[status],
items: col.mandates
.filter(m => m.status === status)
.sort((a, b) => (a.endsAt < b.endsAt ? -1 : 1)),
}))
.filter(g => g.items.length > 0),
)
</script>
<template>
<!-- ld-v2 -->
<div class="mpage">
<header class="mpage__header">
<div>
<h1 class="mpage__title">{{ MANDATES_TITLE }}</h1>
<p class="mpage__sub">qui peut décider quoi, jusqu'à quand</p>
</div>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</header>
<template v-if="groups.length">
<section v-for="group in groups" :key="group.status" class="mpage__group">
<h2 class="mpage__group-title">
{{ group.label }}
<span class="mpage__count">{{ group.items.length }}</span>
</h2>
<div class="mpage__grid">
<MandateCard v-for="m in group.items" :key="m.id" :mandate="m" />
</div>
</section>
</template>
<div v-else class="mpage__empty ld-card">
<UIcon name="i-lucide-key-round" class="mpage__empty-icon" />
<p class="mpage__empty-title">Aucun pouvoir confié pour l'instant.</p>
<p class="mpage__empty-text">
Un mandat naît d'une décision : un rôle, un domaine, une durée bornée —
et des comptes à rendre.
</p>
<NuxtLink to="/mandats/nouveau" class="ld-btn">
<UIcon name="i-lucide-key-round" />
{{ MANDATE_CLAIM }}
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.mpage {
max-width: 64rem;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.mpage__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.mpage__title {
margin: 0;
font-size: clamp(1.375rem, 3.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.01em;
}
.mpage__sub {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
font-style: italic;
color: var(--mood-text-muted);
}
.mpage__group { display: flex; flex-direction: column; gap: 0.875rem; }
.mpage__group-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.mpage__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
border-radius: var(--r-pill);
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.mpage__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.mpage__grid { grid-template-columns: 1fr 1fr; }
}
.mpage__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 3rem 1.5rem;
text-align: center;
}
.mpage__empty-icon { font-size: 2rem; color: var(--mood-accent); }
.mpage__empty-title { margin: 0; font-size: 1.0625rem; font-weight: 700; }
.mpage__empty-text {
margin: 0 0 0.625rem;
max-width: 26rem;
font-size: 0.9375rem;
color: var(--mood-text-muted);
line-height: 1.55;
}
</style>
@@ -0,0 +1,389 @@
<script setup lang="ts">
// <!-- ld-v2 --> /mandats/nouveau — wizard 3 étapes porté du v1 (la structure
// à pépite : pills de progression, cartes cliquables, transitions), branché sur
// le pivot : la nomination EST une décision (createsMandate, route collective).
// Pré-remplissage par query ?titre=&tags= (suggestion R6 du chemin).
import type { Id, MandateDraft, NominationMethod, TriageInput } from '~/types/domain'
import type { ApplyPathEdits } from '~/stores/decisions'
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { MANDATE_CLAIM, NOMINATION_LABELS } from '~/lexicon'
const col = useCollectiveStore()
const decisions = useDecisionsStore()
const route = useRoute()
const STEPS = ['Rôle et domaine', 'Nomination', 'Durée et comptes'] as const
const step = ref(0)
// ── Étape 1 : rôle et domaine ──
const title = ref(typeof route.query.titre === 'string' ? route.query.titre : '')
const mission = ref('')
const circleIds = ref<Id[]>([])
const tags = ref<string[]>(
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: [],
)
const tagInput = ref('')
function toggleCircle(id: Id): void {
circleIds.value = circleIds.value.includes(id)
? circleIds.value.filter(c => c !== id)
: [...circleIds.value, id]
}
function addTag(): void {
const tag = tagInput.value.trim().replace(/^#/, '').toLowerCase()
if (tag && !tags.value.includes(tag)) tags.value.push(tag)
tagInput.value = ''
}
function removeTag(tag: string): void {
tags.value = tags.value.filter(t => t !== tag)
}
// ── Étape 2 : modalité de nomination (6) ──
const method = ref<NominationMethod | null>(null)
// ── Étape 3 : durée bornée, cadence, cercle électeur ──
const durationDays = ref(180)
const withReports = ref(true)
const reportEveryDays = ref(90)
const electorCircleId = ref<Id>(col.current?.collective.rootCircleId ?? '')
const canNext = computed(() => {
if (step.value === 0) return title.value.trim().length > 0
if (step.value === 1) return method.value !== null
return durationDays.value >= 1 && electorCircleId.value !== ''
})
const electorCircle = computed(() => col.circles.find(c => c.id === electorCircleId.value))
const domainNames = computed(() =>
circleIds.value
.map(id => col.circles.find(c => c.id === id)?.name)
.filter(n => n !== undefined),
)
/** La modalité choisie résout le protocole via le Pacte (repli : consentement). */
function protocolFor(m: NominationMethod): Id | undefined {
const byRange = col.settings?.protocolByRange
if (!byRange) return undefined
if (m === 'election-no-candidate') return byRange.election ?? byRange.consent
if (m === 'nuanced-vote') return byRange.nuanced ?? byRange.consent
return byRange.consent // ratification par consentement (auto-désignation, tirage, rotation)
}
// ── Sortie : une Decision createsMandate, route collective ──
const submitting = ref(false)
const error = ref<string | null>(null)
function submit(): void {
if (!method.value || submitting.value) return
submitting.value = true
error.value = null
const captured = decisions.capture(`Confier le mandat « ${title.value.trim()} »`)
if ('ok' in captured) { error.value = captured.reason; submitting.value = false; return }
const scope = {
selfOnly: false,
// Le cercle électeur EN PREMIER : il devient le périmètre d'élection et de révocation.
circleIds: [electorCircleId.value, ...circleIds.value.filter(id => id !== electorCircleId.value)],
personIds: [] as Id[],
}
const input: TriageInput = {
title: captured.title,
tags: tags.value,
scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}
const path = decisions.runTriage(input)
if ('ok' in path) { error.value = path.reason; submitting.value = false; return }
const draft: MandateDraft = {
title: title.value.trim(),
domainCircleIds: [...circleIds.value],
domainTags: [...tags.value],
durationDays: durationDays.value,
...(withReports.value ? { reportEveryDays: reportEveryDays.value } : {}),
}
const protocolId = protocolFor(method.value)
const edits: ApplyPathEdits = {
route: 'collective',
scope,
reversibility: 'costly',
weight: 'binding',
tags: [...tags.value],
visibility: 'collective',
createsMandate: draft,
...(mission.value.trim() ? { body: mission.value.trim() } : {}),
...(protocolId !== undefined ? { protocolId } : {}),
}
const applied = decisions.applyPath(captured, path, edits)
if ('ok' in applied) { error.value = applied.reason; submitting.value = false; return }
navigateTo(`/decisions/${applied.id}`)
}
</script>
<template>
<!-- ld-v2 -->
<div class="mwiz">
<nav class="mwiz__nav">
<button v-if="step > 0" type="button" class="mwiz__back" @click="step--">
<UIcon name="i-lucide-arrow-left" />
Retour
</button>
<NuxtLink v-else to="/mandats" class="mwiz__back">
<UIcon name="i-lucide-arrow-left" />
Mandats
</NuxtLink>
<div class="mwiz__progress">
<template v-for="(label, i) in STEPS" :key="label">
<span v-if="i > 0" class="mwiz__sep">›</span>
<span
class="mwiz__pill"
:class="{ 'mwiz__pill--active': step === i, 'mwiz__pill--done': step > i }"
>
{{ i + 1 }} · {{ label }}
</span>
</template>
</div>
</nav>
<Transition name="slide-fade" mode="out-in">
<!-- ── ÉTAPE 1 : rôle et domaine ── -->
<div v-if="step === 0" key="role" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">{{ MANDATE_CLAIM }}</h1>
<p class="mwiz__sub">Un rôle nommé, un domaine explicite — le pouvoir se voit.</p>
</header>
<label class="mwiz__label" for="mwiz-title">Titre du rôle</label>
<input
id="mwiz-title"
v-model="title"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Trésorerie, Animation du jardin…"
>
<label class="mwiz__label" for="mwiz-mission">La mission, en une phrase</label>
<input
id="mwiz-mission"
v-model="mission"
type="text"
class="mwiz__input"
lang="fr"
spellcheck="true"
placeholder="ex : Tenir les comptes et rendre la caisse lisible par tous."
>
<p class="mwiz__label">Cercles du domaine</p>
<div class="mwiz__chips">
<button
v-for="c in col.circles"
:key="c.id"
type="button"
class="mwiz__chip"
:class="{ 'mwiz__chip--on': circleIds.includes(c.id) }"
@click="toggleCircle(c.id)"
>
{{ c.name }}
</button>
</div>
<label class="mwiz__label" for="mwiz-tag">Tags du domaine</label>
<div class="mwiz__chips">
<span v-for="tag in tags" :key="tag" class="mwiz__chip mwiz__chip--on">
#{{ tag }}
<button type="button" class="mwiz__chip-x" :aria-label="`Retirer ${tag}`" @click="removeTag(tag)">
<UIcon name="i-lucide-x" />
</button>
</span>
<input
id="mwiz-tag"
v-model="tagInput"
type="text"
class="mwiz__input mwiz__input--tag"
placeholder="ajouter un tag ⏎"
@keydown.enter.prevent="addTag"
@blur="addTag"
>
</div>
</div>
<!-- ── ÉTAPE 2 : modalité de nomination ── -->
<div v-else-if="step === 1" key="nomination" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Comment nommer ?</h1>
<p class="mwiz__sub">Six modalités, chacune avec sa pédagogie — le cercle électeur tranchera.</p>
</header>
<MandateNominationPicker v-model="method" />
</div>
<!-- ── ÉTAPE 3 : durée bornée, cadence, cercle électeur ── -->
<div v-else key="terms" class="mwiz__step">
<header class="mwiz__header">
<h1 class="mwiz__title">Borné, redevable</h1>
<p class="mwiz__sub">Un mandat a toujours une fin — et des comptes à rendre avant.</p>
</header>
<label class="mwiz__label" for="mwiz-duration">Durée du mandat (jours)</label>
<div class="mwiz__row">
<input
id="mwiz-duration"
v-model.number="durationDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
max="730"
>
<input
v-model.number="durationDays"
type="range"
class="mwiz__slider"
min="7"
max="730"
step="1"
aria-label="Durée du mandat en jours"
>
</div>
<label class="mwiz__check">
<input v-model="withReports" type="checkbox" class="mwiz__checkbox">
<span>Avec rapports réguliers</span>
</label>
<div v-if="withReports" class="mwiz__row">
<label class="mwiz__label mwiz__label--inline" for="mwiz-cadence">tous les</label>
<input
id="mwiz-cadence"
v-model.number="reportEveryDays"
type="number"
class="mwiz__input mwiz__input--num"
min="7"
:max="durationDays"
>
<span class="mwiz__unit">jours</span>
</div>
<label class="mwiz__label" for="mwiz-elector">Cercle électeur — il nomme, il révoque</label>
<select id="mwiz-elector" v-model="electorCircleId" class="mwiz__input">
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div class="mwiz__recap">
<p class="mwiz__recap-title">{{ title }}</p>
<p v-if="mission" class="mwiz__recap-line">{{ mission }}</p>
<p class="mwiz__recap-line">
{{ method ? NOMINATION_LABELS[method] : '—' }} ·
{{ durationDays }} jours<template v-if="withReports"> · rapport tous les {{ reportEveryDays }} jours</template>
</p>
<p v-if="domainNames.length || tags.length" class="mwiz__recap-line">
Domaine : {{ [...domainNames, ...tags.map(t => `#${t}`)].join(' · ') }}
</p>
<p class="mwiz__recap-note">
La nomination est une décision — elle part au vote du cercle
« {{ electorCircle?.name ?? '—' }} ».
</p>
</div>
<p v-if="error" class="mwiz__error">{{ error }}</p>
</div>
</Transition>
<div class="mwiz__actions">
<button
v-if="step < 2"
type="button"
class="ld-btn"
:disabled="!canNext"
@click="step++"
>
Continuer
<UIcon name="i-lucide-arrow-right" />
</button>
<button
v-else
type="button"
class="ld-btn"
:disabled="!canNext || !method || submitting"
@click="submit"
>
<UIcon name="i-lucide-scale" />
{{ submitting ? 'Ouverture…' : 'Soumettre au cercle électeur' }}
</button>
</div>
</div>
</template>
<style scoped>
.mwiz { max-width: 46rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
.mwiz__nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.mwiz__back {
display: inline-flex; align-items: center; gap: 0.375rem;
font-size: 0.875rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-decoration: none;
}
.mwiz__back:hover { color: var(--mood-text); }
.mwiz__progress { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; flex-wrap: wrap; }
.mwiz__sep { color: var(--mood-text-muted); font-size: 0.75rem; }
.mwiz__pill {
font-size: 0.75rem; font-weight: 600; padding: 0.25rem 0.625rem;
border-radius: var(--r-pill); color: var(--mood-text-muted);
background: var(--mood-surface); white-space: nowrap;
}
.mwiz__pill--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__pill--done { background: var(--mood-accent-soft); color: var(--mood-accent); }
.mwiz__step { display: flex; flex-direction: column; gap: 0.75rem; }
.mwiz__header { margin-bottom: 0.5rem; }
.mwiz__title { margin: 0 0 0.25rem; font-size: clamp(1.25rem, 3vw, 1.625rem); font-weight: 800; }
.mwiz__sub { margin: 0; font-size: 0.9375rem; color: var(--mood-text-muted); }
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
.mwiz__label--inline { margin-top: 0; }
.mwiz__input {
width: 100%; box-sizing: border-box; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
color: var(--mood-text); background: var(--mood-input-bg); border: none;
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
}
.mwiz__input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
.mwiz__input--num { width: 6rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.mwiz__input--tag { width: 11rem; padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.mwiz__row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.mwiz__slider { flex: 1; min-width: 10rem; accent-color: var(--mood-accent); }
.mwiz__unit { font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__chips { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.mwiz__chip {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.875rem; font-size: 0.875rem; font-weight: 600;
color: var(--mood-text-muted); background: var(--mood-surface);
border-radius: var(--r-pill); box-shadow: var(--shadow-card);
cursor: pointer; transition: transform 0.1s ease;
}
.mwiz__chip:hover { transform: translateY(-1px); }
.mwiz__chip--on { background: var(--mood-accent); color: var(--mood-accent-text); }
.mwiz__chip-x { display: inline-flex; background: none; color: inherit; cursor: pointer; padding: 0; }
.mwiz__check {
display: flex; align-items: center; gap: 0.625rem; margin-top: 0.375rem;
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
}
.mwiz__checkbox { width: 1.125rem; height: 1.125rem; accent-color: var(--mood-accent); }
.mwiz__recap {
margin-top: 0.75rem; padding: 1.125rem 1.25rem; border-radius: var(--r-card);
background: var(--mood-surface); box-shadow: var(--shadow-card);
display: flex; flex-direction: column; gap: 0.375rem;
}
.mwiz__recap-title { margin: 0; font-size: 1.0625rem; font-weight: 800; }
.mwiz__recap-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
.mwiz__recap-note { margin: 0.375rem 0 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-accent); }
.mwiz__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.mwiz__actions { display: flex; justify-content: flex-end; }
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
.slide-fade-leave-to { opacity: 0; transform: translateX(-20px); }
</style>

Some files were not shown because too many files have changed in this diff Show More