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:
@@ -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)]',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 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"> {{ 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 }
|
||||
}
|
||||
@@ -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
@@ -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: [],
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user