v2 : couche données complète + shell + primitives communes

- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 10:30:35 +02:00
co-authored by Claude Fable 5
parent 6f2bf62295
commit d886302b59
18 changed files with 4107 additions and 957 deletions
+10 -729
View File
@@ -1,740 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
const auth = useAuthStore() // Shell v2 — app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
const orgsStore = useOrganizationsStore() // Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
const documentsStore = useDocumentsStore() import { useCollectiveStore } from '~/stores/collective'
const decisionsStore = useDecisionsStore()
const protocolsStore = useProtocolsStore()
const mandatesStore = useMandatesStore()
const route = useRoute()
const { initMood } = useLibreMood() const { initMood } = useLibreMood()
const navigationItems = [ onMounted(() => {
{
label: 'Décisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{
label: 'Documents',
icon: 'i-lucide-book-open',
to: '/documents',
},
{
label: 'Mandats',
icon: 'i-lucide-user-check',
to: '/mandates',
},
{
label: 'Protocoles',
icon: 'i-lucide-settings',
to: '/protocols',
},
{
label: 'Outils',
icon: 'i-lucide-wrench',
to: '/tools',
},
{
label: 'Sanctuaire',
icon: 'i-lucide-archive',
to: '/sanctuary',
},
]
/** Mobile drawer state. */
const mobileMenuOpen = ref(false)
/** Sidebar collapse state (icons-only mode). */
const sidebarCollapsed = ref(false)
/** Close mobile menu on route change. */
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
/** Refetch all content stores when the active workspace changes. */
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
documentsStore.fetchAll()
decisionsStore.fetchAll()
protocolsStore.fetchProtocols()
mandatesStore.fetchAll()
}
})
/** WebSocket connection and notifications. */
const ws = useWebSocket()
const { setupWsNotifications } = useNotifications()
watch(sidebarCollapsed, (val) => {
localStorage.setItem('libred-sidebar-collapsed', String(val))
})
onMounted(async () => {
initMood() initMood()
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed') useCollectiveStore().init?.()
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
auth.hydrateFromStorage()
if (auth.token) {
try {
await auth.fetchMe()
} catch (err: any) {
// Déconnexion seulement sur session réellement invalide (401/403)
// Erreur réseau ou backend temporairement indisponible → conserver la session
if (err?.status === 401 || err?.status === 403) {
auth.logout()
}
}
}
ws.connect()
setupWsNotifications(ws)
// Load organizations in parallel — non-blocking, no auth required
orgsStore.fetchOrganizations()
}) })
onUnmounted(() => {
ws.disconnect()
})
function isActive(to: string) {
return route.path === to || route.path.startsWith(to + '/')
}
</script> </script>
<template> <template>
<!-- ld-v2 -->
<UApp> <UApp>
<OfflineBanner /> <NuxtLayout>
<NuxtPage />
<div </NuxtLayout>
class="app-shell"
:style="{
backgroundColor: 'var(--mood-bg)',
color: 'var(--mood-text)',
}"
>
<!-- Header -->
<header class="app-header">
<div class="app-header__inner">
<!-- Left: Hamburger (mobile) + Logo -->
<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="text-xl" />
</button>
<NuxtLink to="/" class="app-header__logo">
<span class="app-header__logo-stamp">
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
</span>
<span class="app-header__logo-text">
<span class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span>
</span>
</NuxtLink>
</div>
<!-- Center: Workspace selector + Mood switcher (desktop) -->
<div class="app-header__center">
<WorkspaceSelector class="hidden sm:flex" />
<MoodSwitcher class="hidden sm:flex" />
</div>
<!-- Right: Auth -->
<div class="app-header__right">
<template v-if="auth.isAuthenticated">
<span class="app-header__identity">
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
</span>
<span
v-if="auth.identity?.is_smith"
class="app-header__role app-header__role--smith"
>
Forgeron
</span>
<span
v-if="auth.identity?.is_techcomm"
class="app-header__role app-header__role--tech"
>
CoTec
</span>
<button
class="app-header__icon-btn"
aria-label="Se deconnecter"
@click="auth.logout()"
>
<UIcon name="i-lucide-log-out" />
</button>
</template>
<template v-else>
<NuxtLink to="/login" class="app-header__connect-btn">
<UIcon name="i-lucide-log-in" />
<span>Connexion</span>
</NuxtLink>
</template>
</div>
</div>
</header>
<!-- Mobile navigation drawer -->
<USlideover
v-model:open="mobileMenuOpen"
side="left"
title="Navigation"
:ui="{ width: 'max-w-xs' }"
>
<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="text-lg" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<!-- Workspace + Mood in mobile drawer -->
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Espace</span>
<WorkspaceSelector />
</div>
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Ambiance</span>
<MoodSwitcher />
</div>
</template>
</USlideover>
<!-- Main content with sidebar -->
<div class="app-body">
<!-- Desktop sidebar -->
<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="text-lg flex-shrink-0" />
<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="text-base flex-shrink-0"
/>
<span class="app-sidebar__link-label">Replier</span>
</button>
</nav>
</aside>
<!-- Page content -->
<main class="app-main">
<ErrorBoundary>
<NuxtPage />
</ErrorBoundary>
<!-- Tsing sceau (traits épais, coins nets, ~carré) -->
<svg class="app-seal" viewBox="0 0 112 105" fill="currentColor" aria-hidden="true">
<!-- Line 6 (top) yin -->
<rect x="6" y="6" width="42" height="8"/>
<rect x="64" y="6" width="42" height="8"/>
<!-- Line 5 yang -->
<rect x="6" y="23" width="100" height="8"/>
<!-- Line 4 yin -->
<rect x="6" y="40" width="42" height="8"/>
<rect x="64" y="40" width="42" height="8"/>
<!-- Line 3 yang -->
<rect x="6" y="57" width="100" height="8"/>
<!-- Line 2 yang -->
<rect x="6" y="74" width="100" height="8"/>
<!-- Line 1 (bottom) yin -->
<rect x="6" y="91" width="42" height="8"/>
<rect x="64" y="91" width="42" height="8"/>
</svg>
</main>
</div>
<!-- WebSocket error banner -->
<Transition name="slide-up">
<div
v-if="ws.error.value"
class="app-ws-banner"
role="alert"
>
<UIcon name="i-lucide-plug-zap" class="text-lg" />
<span>{{ ws.error.value }}</span>
<button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
Reconnecter
</button>
</div>
</Transition>
<!-- Footer -->
<footer class="app-footer">
<span>libreDecision v0.1.0</span>
<span class="app-footer__sep">·</span>
<span>Licence libre</span>
</footer>
</div>
</UApp> </UApp>
</template> </template>
<style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
}
.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__center {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1;
justify-content: center;
}
.app-header__left {
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: 12px;
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
@media (min-width: 768px) {
.app-header__menu-btn {
display: none;
}
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__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 .app-header__logo-stamp {
transform: rotate(-16deg) scale(1.08);
}
.app-header__logo-icon {
font-size: 1.125rem;
transform: scaleX(-1);
}
.app-header__logo-text {
display: flex;
align-items: baseline;
gap: 0;
letter-spacing: -0.01em;
}
.app-header__logo-libre {
font-size: 1.0625rem;
font-weight: 400;
font-style: italic;
color: var(--mood-text-muted);
}
.app-header__logo-decision {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.app-header__right {
display: flex;
align-items: center;
gap: 0.625rem;
}
.app-header__identity {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
max-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 640px) {
.app-header__identity {
max-width: 12rem;
}
}
.app-header__role {
font-size: 0.75rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
display: none;
}
@media (min-width: 640px) {
.app-header__role {
display: inline;
}
}
.app-header__role--smith {
background: rgba(24, 132, 59, 0.15);
color: var(--mood-success);
}
.app-header__role--tech {
background: rgba(24, 86, 168, 0.15);
color: var(--mood-status-vote, #1856a8);
}
.app-header__icon-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: 12px;
font-size: 1rem;
}
.app-header__icon-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__connect-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 24px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.app-header__connect-btn {
gap: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.9375rem;
}
}
.app-header__connect-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* === Sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 14rem;
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: 12px;
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__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: 12px;
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;
}
/* === Mobile nav === */
.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: 14px;
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-mood {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
margin-top: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.app-mobile-mood__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
}
/* === Main === */
.app-main {
flex: 1;
min-width: 0;
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;
}
}
/* === WS Banner === */
.app-ws-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: var(--mood-error);
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.app-ws-banner__btn {
margin-left: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(255,255,255,0.2);
border-radius: 20px;
color: white;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.app-ws-banner__btn:hover {
background: rgba(255,255,255,0.3);
}
/* === 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;
}
/* === Seal — 井 Tsing === */
.app-seal {
display: block;
width: 44px;
margin: 1.5rem 0 0.5rem auto;
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));
}
/* === Transitions === */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</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>
+37
View File
@@ -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>
+307
View File
@@ -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,
}
}
+8 -8
View File
@@ -1,7 +1,7 @@
// Ambiances libreDecision — délègue le mécanisme au layer @yvv/nuxt-base. // 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-*). // Les couleurs (identité du projet) restent dans assets/css/moods.css (classes .mood-*).
// Le layer fournit l'auto-import `useMood(moods, options)` ; on l'enveloppe ici // Enveloppé sous le nom `useLibreMood` pour éviter la collision avec le composable du layer.
// sous le nom `useLibreMood` pour éviter la collision avec ce composable auto-importé.
export interface Mood { export interface Mood {
id: string id: string
@@ -13,12 +13,12 @@ export interface Mood {
} }
const moods: Mood[] = [ const moods: Mood[] = [
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false }, { id: 'source', label: 'Source', description: 'Eau claire', icon: 'i-lucide-droplets', color: '#0f7fa8', isDark: false },
{ id: 'zen', label: 'Zen', description: 'Nature vivante', icon: 'i-lucide-leaf', color: '#2e8b48', isDark: false }, { id: 'margelle', label: 'Margelle', description: 'Pierre chaude', icon: 'i-lucide-landmark', color: '#96682a', isDark: false },
{ id: 'chagrine', label: 'Chagrine', description: 'Nuit profonde', icon: 'i-lucide-moon', color: '#6488d8', isDark: true }, { id: 'nappe', label: 'Nappe', description: 'Eau profonde', icon: 'i-lucide-waves', color: '#3fa9cc', isDark: true },
{ id: 'grave', label: 'Grave', description: 'Ambre mineral', icon: 'i-lucide-shield', color: '#d8a030', isDark: true }, { id: 'minuit', label: 'Minuit', description: 'Encre et lanterne', icon: 'i-lucide-lamp', color: '#cf9c3e', isDark: true },
] ]
export function useLibreMood() { export function useLibreMood() {
return useMood(moods, { storageKey: 'libredecision_mood', defaultId: 'peps' }) return useMood(moods, { storageKey: 'libredecision_mood_v2', defaultId: 'source' })
} }
+78
View File
@@ -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 }
}
+577
View File
@@ -0,0 +1,577 @@
/**
* The seven collective templates (BLUEPRINT-V2.md « Seeds » GABARITS + Δ10/Δ11/Δ20).
*
* buildTemplateBundle() produces a COMPLETE schemaVersion-2 Bundle that goes
* through the SAME import path as a user bundle (importBundle, asSeed:true).
*
* Doctrinal invariants honored here:
* - every template seeds a Consent protocol (protocolByRange.consent — Δ10);
* - every ClauseVersion is born of a Decision: one FOUNDING decision
* (route 'record', adopted) carries every seeded clause version — a single
* shared founding act, exactly what a real constitutive gesture is;
* - blank stays a blank page: preamble + A1 + the « consignation » clause +
* requireEffects='none' + the Consent protocol ONLY — every other setting
* falls back to SETTINGS_DEFAULTS through resolveSettings;
* - the five standard templates (informal/association/cooperative/commune/
* free-currency) seed the 10 triage setting clauses + Consent, Nuancé,
* Réglage collectif, Élection — 'protocols.large' = the NUANCED protocol
* everywhere EXCEPT free-currency, where large = the inertial binary
* (D30M50B.1G.2 — the Ğ1 heritage put back in its place);
* - free-currency: the default resources unit is 'DU' — v2 keeps it as a
* DESCRIPTIVE default suggested by the tunnel UI (Resources.unit), never a
* conversion: DU and € are oil and water, never summed (huile/eau);
* - symmetric: full transparency (isTransparent), a « Déclaration de
* symétrie » reference TextDoc, and the draft decision « Déclarer notre
* symétrie ». Two MandateDrafts are SUGGESTED by the onboarding UI (not
* seeded — a mandate is born of a decision, never of a template):
* { title: 'Ambassadeur', domainCircleIds: [root], domainTags: ['liaison'],
* durationDays: 180, reportEveryDays: 30 }
* { title: 'Négociateur', domainCircleIds: [root], domainTags: ['symétrie'],
* durationDays: 180, reportEveryDays: 30 }
* - every template proposes the first decision « Adopter notre Pacte »
* (route 'collective', status 'draft', consent protocol).
*/
import type {
Bundle,
Circle,
Clause,
ClauseVersion,
Collective,
CollectiveTemplate,
Decision,
FormulaParams,
Id,
InertiaPreset,
ISODate,
Json,
Person,
Protocol,
TextDoc,
VoteMethod,
} from '../types/domain'
import { formatModeParams } from '../engine/modeParams'
export type TemplateId = CollectiveTemplate
// ─────────────────────────────────────────────────────────────
// The seven cards (/creer, écran 2) — blank FIRST
// ─────────────────────────────────────────────────────────────
export interface TemplateCard {
id: TemplateId
title: string
subtitle: string
description: string
}
const STANDARD_SUBTITLE = 'point de départ — tout est amendable par décision'
export const TEMPLATE_CARDS: TemplateCard[] = [
{
id: 'blank',
title: 'Page blanche',
subtitle: "observatoire d'abord",
description:
"Une clause : nous consignons nos décisions ; le reste s'écrira au fil de l'eau. "
+ 'Consentement seul installé.',
},
{
id: 'informal',
title: 'Informel',
subtitle: STANDARD_SUBTITLE,
description:
"Un groupe d'amis, un projet naissant : des chemins légers, aucune matière exigée.",
},
{
id: 'association',
title: 'Association',
subtitle: STANDARD_SUBTITLE,
description:
'Un Bureau, le consentement et le vote nuancé — la vie associative sans les camps.',
},
{
id: 'cooperative',
title: 'Coopérative',
subtitle: STANDARD_SUBTITLE,
description:
'Décider ensemble du travail et des ressources — matière exigée sur ce qui engage.',
},
{
id: 'commune',
title: 'Commune',
subtitle: STANDARD_SUBTITLE,
description:
'Des quartiers, des périmètres emboîtés — la subsidiarité en actes.',
},
{
id: 'free-currency',
title: 'Communauté monnaie libre',
subtitle: STANDARD_SUBTITLE,
description:
"L'héritage Toile de Confiance : le pour/contre inertiel, outil de dernier recours "
+ 'des très grands corps.',
},
{
id: 'symmetric',
title: 'Institution symétrique',
subtitle: 'le geste fondateur',
description:
'Transparence totale, Déclaration de symétrie, et la première décision : '
+ 'déclarer notre symétrie.',
},
]
// ─────────────────────────────────────────────────────────────
// buildTemplateBundle
// ─────────────────────────────────────────────────────────────
export interface TemplateBuildOptions {
name: string
slug: string
color: string
icon: string
meName: string
memberNames: string[]
now: ISODate
newId: () => Id
}
/** Which protocols a template installs. */
const STANDARD_TEMPLATES: TemplateId[] = [
'informal',
'association',
'cooperative',
'commune',
'free-currency',
'symmetric',
]
export function buildTemplateBundle(id: TemplateId, opts: TemplateBuildOptions): Bundle {
const { name, slug, color, icon, meName, memberNames, now, newId } = opts
const collectiveId = newId()
const entity = () => ({
id: newId(),
collectiveId,
createdAt: now,
updatedAt: now,
})
// ── People — me + the initial members ──────────────────────
const me: Person = { ...entity(), displayName: meName, isMe: true }
const members: Person[] = memberNames.map(displayName => ({
...entity(),
displayName,
isMe: false,
}))
const people = [me, ...members]
// ── Circles — root « Tous » + one sober typed circle per template ──
const rootCircle: Circle = {
...entity(),
name: 'Tous',
purpose: 'Toutes les personnes du collectif',
memberIds: people.map(p => p.id),
domains: [],
}
const circles: Circle[] = [rootCircle]
const typed: Partial<Record<TemplateId, { name: string; purpose: string; kind: Circle['kind'] }>> = {
association: { name: 'Bureau', purpose: "L'équipe qui fait tourner l'association", kind: 'team' },
cooperative: { name: 'Conseil', purpose: 'Le conseil de la coopérative', kind: 'team' },
commune: { name: 'Quartiers', purpose: 'Les lieux de la commune', kind: 'place' },
'free-currency': { name: 'Forgerons', purpose: 'Celles et ceux qui forgent les blocs', kind: 'team' },
}
const typedDef = typed[id]
if (typedDef) {
circles.push({
...entity(),
name: typedDef.name,
purpose: typedDef.purpose,
kind: typedDef.kind,
memberIds: [],
parentCircleId: rootCircle.id,
domains: [],
})
}
// ── Protocols ──────────────────────────────────────────────
const protocols: Protocol[] = []
const mkProtocol = (
pName: string,
method: VoteMethod,
description: string,
durationDays: number,
formula: FormulaParams,
): Protocol => {
const protocol: Protocol = {
...entity(),
name: pName,
method,
description,
durationDays,
ballot: 'open',
formula,
modeParams: formatModeParams({
duration_days: durationDays,
majority_pct: formula.majorityPct,
base_exponent: formula.baseExponent,
gradient_exponent: formula.gradientExponent,
constant_base: formula.constantBase,
}),
}
protocols.push(protocol)
return protocol
}
const baseFormula = { majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2, constantBase: 0 }
const consent = mkProtocol(
'Consentement',
'consent',
'Ça me va / J\'objecte — zéro objection maintenue vaut adoption.',
7,
{ ...baseFormula },
)
let nuanced: Protocol | undefined
let parametric: Protocol | undefined
let election: Protocol | undefined
let binary: Protocol | undefined
if (STANDARD_TEMPLATES.includes(id)) {
nuanced = mkProtocol(
'Vote nuancé',
'nuanced',
'Chacun·e se prononce en nuances, pas en camps.',
14,
{ ...baseFormula, nuancedThresholdPct: 60, nuancedMinParticipants: 3 },
)
parametric = mkProtocol(
'Réglage collectif',
'parametric',
'Décider au curseur — le collectif retient la médiane de chaque curseur.',
14,
{ ...baseFormula, parametricMinParticipants: 3 },
)
election = mkProtocol(
'Élection',
'election',
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez.",
14,
{ ...baseFormula, electionMinParticipants: 3, tieBreak: 'runoff' },
)
}
if (id === 'free-currency') {
// D30M50B.1G.2 — the exact inherited inertia of the Ğ1 web of trust.
binary = mkProtocol(
'Vote WoT binaire inertiel',
'binary',
"l'outil de dernier recours des très grands corps — héritage Toile de Confiance",
30,
{ ...baseFormula },
)
}
// ── The Pact + clauses, each founded by ONE adopted decision ──
const pactDoc: TextDoc = {
...entity(),
slug: 'pacte',
title: 'Notre Pacte',
role: 'pact',
description: 'Notre contrat social — sacralisé, jamais immuable',
}
const docs: TextDoc[] = [pactDoc]
const founding: Decision = {
...entity(),
authorId: me.id,
title: 'Fonder notre Pacte',
tags: ['pacte'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'record',
triageRule: 'R0a',
routeOverridden: false,
decidedHow: 'posé à la création du collectif — chaque clause reste amendable',
status: 'adopted',
decidedAt: now,
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
}
const clauses: Clause[] = []
const versions: ClauseVersion[] = []
let position = 0
const mkClause = (
section: string,
code: string,
title: string,
inertia: InertiaPreset,
content: string,
settingKey?: string,
settingValue?: Json,
): Clause => {
const clause: Clause = {
...entity(),
docId: pactDoc.id,
section,
position: position++,
code,
title,
inertia,
...(settingKey !== undefined ? { settingKey } : {}),
}
const version: ClauseVersion = {
...entity(),
clauseId: clause.id,
decisionId: founding.id,
versionLabel: 'v1',
content,
...(settingValue !== undefined ? { settingValue } : {}),
status: 'current',
adoptedAt: now,
}
clause.currentVersionId = version.id
clauses.push(clause)
versions.push(version)
return clause
}
// Preamble P0 — Autonomie–ÉquilibreLiaison, 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: [],
}
}
+13 -8
View File
@@ -1,11 +1,16 @@
/** /**
* Pure voting-formula engine — TypeScript port of backend/app/engine/. * Pure engine barrel — the SINGLE import surface of app/engine/.
* Python is the reference implementation; the vitest suite in *
* frontend/tests/engine/ mirrors the backend pytest suite. * 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 { wotThreshold, smithThreshold, techcommThreshold } from './threshold' export * from './threshold'
export { nuancedResult, LEVEL_LABELS, NUM_LEVELS } from './nuanced' export * from './nuanced'
export type { NuancedResult } from './nuanced' export * from './modeParams'
export { parseModeParams, formatModeParams } from './modeParams' export * from './parametric'
export type { ModeParams } from './modeParams' export * from './settings'
export * from './state'
export * from './triage'
export * from './impact'
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
/** Layout minimal (onboarding /creer) — fond d'ambiance, contenu centré, sceau discret. */
</script>
<template>
<!-- ld-v2 -->
<div class="bare">
<main class="bare__main">
<slot />
</main>
<LdSeal class="app-seal bare__seal" />
</div>
</template>
<style scoped>
.bare {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--mood-bg);
color: var(--mood-text);
}
.bare__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(1.25rem, 4vw, 3rem);
}
.bare__seal {
position: fixed;
right: 1.25rem;
bottom: 1rem;
}
</style>
+608
View File
@@ -0,0 +1,608 @@
<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"
:ui="{ width: 'max-w-xs' }"
>
<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>
+280
View File
@@ -0,0 +1,280 @@
/**
* Collective store — the tenant: which collective is active, its full local
* state (CollectiveState = Bundle minus export metadata), and the bundle
* lifecycle (create from template, import, export, seeds, delete).
*
* EXPLICIT imports only (no Nuxt auto-imports): the store must run under
* plain vitest + createPinia, and the future FastAPI adapter swaps
* data/persistence without touching any screen.
*
* Getter shortcuts (people, decisions, …) filter soft-deleted rows
* (!archivedAt) — screens never see archived entities; sync keeps them.
*/
import { defineStore } from 'pinia'
import type {
Advice,
Assent,
Circle,
Clause,
ClauseVersion,
CollectiveSettings,
CollectiveTemplate,
Concern,
Decision,
Id,
ISODate,
Mandate,
Objection,
Person,
Protocol,
TextDoc,
VoteSession,
Vote,
} from '../types/domain'
import type { CollectiveState, ImportResult } from '../data/persistence'
import {
deleteCollective,
getActiveCollectiveId,
importBundle,
listCollectiveIds,
loadState,
saveStateDebounced,
saveStateNow,
setActiveCollectiveId,
toBundle,
} from '../data/persistence'
import { hasConsentProtocol, resolveSettings } from '../engine'
import { buildTemplateBundle } from '../data/templates'
import type { TemplateId } from '../data/templates'
/** Re-exported for screens: the shape of the active collective's state. */
export type { CollectiveState } from '../data/persistence'
export interface CollectiveIndexEntry {
id: Id
slug: string
name: string
color: string
icon: string
template: CollectiveTemplate
}
export interface CreateFromTemplateOptions {
name: string
slug: string
color: string
icon: string
meName: string
memberNames: string[]
}
export type SeedName = 'duniter-g1' | 'atelier-du-canal'
interface CollectiveStoreState {
current: CollectiveState | null
activeId: Id | null
index: CollectiveIndexEntry[]
ready: boolean
}
function live<T extends { archivedAt?: ISODate }>(rows: T[] | undefined): T[] {
return (rows ?? []).filter(row => !row.archivedAt)
}
export const useCollectiveStore = defineStore('collective', {
state: (): CollectiveStoreState => ({
current: null,
activeId: null,
index: [],
ready: false,
}),
getters: {
/** The local profile — v2 single-seat. */
me(state): Person | null {
return state.current?.people.find(p => p.isMe && !p.archivedAt) ?? null
},
/** Resolved Pact settings — the Pact IS the settings store. */
settings(state): CollectiveSettings | null {
if (!state.current) return null
const pactDocId = state.current.collective.pactDocId
// Cast: Vue's UnwrapRef over the recursive Json type (settingValue)
// explodes TS instantiation depth; the runtime shape is identical.
const pactClauses = state.current.clauses.filter(
c => c.docId === pactDocId && !c.archivedAt,
) as unknown as Clause[]
const versions = state.current.versions as unknown as ClauseVersion[]
return resolveSettings(pactClauses, versions)
},
hasConsent(): boolean {
const settings = this.settings
return settings !== null && hasConsentProtocol(settings)
},
pactDoc(state): TextDoc | null {
if (!state.current) return null
return (
state.current.docs.find(
d => d.id === state.current!.collective.pactDocId && !d.archivedAt,
) ?? null
)
},
// ── Non-archived shortcuts — screens never see archived rows ──
people(state): Person[] { return live(state.current?.people) },
circles(state): Circle[] { return live(state.current?.circles) },
decisions(state): Decision[] { return live(state.current?.decisions) },
mandates(state): Mandate[] { return live(state.current?.mandates) },
docs(state): TextDoc[] { return live(state.current?.docs) },
clauses(state): Clause[] { return live(state.current?.clauses) },
versions(state): ClauseVersion[] {
// Cast: same recursive-Json unwrap issue as the settings getter.
return live(state.current?.versions as unknown as ClauseVersion[] | undefined)
},
protocols(state): Protocol[] { return live(state.current?.protocols) },
sessions(state): VoteSession[] { return live(state.current?.sessions) },
votes(state): Vote[] { return live(state.current?.votes) },
concerns(state): Concern[] { return live(state.current?.concerns) },
objections(state): Objection[] { return live(state.current?.objections) },
advices(state): Advice[] { return live(state.current?.advices) },
assents(state): Assent[] { return live(state.current?.assents) },
},
actions: {
newId(): Id {
return crypto.randomUUID()
},
now(): ISODate {
return new Date().toISOString()
},
/** LWW clock: bump updatedAt, return the entity. */
stamp<T extends { updatedAt: ISODate }>(entity: T): T {
entity.updatedAt = this.now()
return entity
},
/** Debounced save of the active collective — every mutation ends here. */
persist(): void {
if (this.current) saveStateDebounced(this.current as unknown as CollectiveState)
},
/** Load the index of local collectives + the active one. */
async init(): Promise<void> {
const ids = await listCollectiveIds()
const index: CollectiveIndexEntry[] = []
for (const id of ids) {
const state = await loadState(id)
if (!state) continue
const c = state.collective
index.push({
id: c.id, slug: c.slug, name: c.name,
color: c.color, icon: c.icon, template: c.template,
})
}
this.index = index
const activeId = await getActiveCollectiveId()
if (activeId && ids.includes(activeId)) {
await this.switchTo(activeId)
}
this.ready = true
},
async switchTo(id: Id): Promise<void> {
const state = await loadState(id)
if (!state) return
// Cast: assigning into the reactive slot re-triggers the recursive-Json
// UnwrapRef explosion (see the settings getter) — same shape at runtime.
this.current = state as unknown as typeof this.current
this.activeId = id
await setActiveCollectiveId(id)
},
/** Create a collective from one of the seven templates — SAME path as an import. */
async createFromTemplate(id: TemplateId, opts: CreateFromTemplateOptions): Promise<ImportResult> {
const bundle = buildTemplateBundle(id, {
...opts,
now: this.now(),
newId: () => this.newId(),
})
return await this.importJson(JSON.stringify(bundle), true)
},
/**
* Import a bundle (user file or seed). Collision on the collective id is
* REFUSED with a French issue — never a silent overwrite, never an id
* suffix (the id is the sync identity of the collective).
*/
async importJson(json: string, asSeed = false): Promise<ImportResult> {
const result = await importBundle(json, { asSeed })
if (!result.state) return result
if (result.collided) {
return {
issues: [
...result.issues,
{
level: 'error',
message:
'Ce collectif existe déjà sur cette machine — supprime-le d\'abord si tu veux le réimporter.',
},
],
collided: true,
}
}
await saveStateNow(result.state)
const c = result.state.collective
this.index.push({
id: c.id, slug: c.slug, name: c.name,
color: c.color, icon: c.icon, template: c.template,
})
await this.switchTo(c.id)
return result
},
/** Export the active collective as an indented schemaVersion-2 bundle. */
exportJson(): string | null {
if (!this.current) return null
return JSON.stringify(toBundle(this.current as unknown as CollectiveState, this.now()), null, 2)
},
async removeCollective(id: Id): Promise<void> {
await deleteCollective(id)
this.index = this.index.filter(entry => entry.id !== id)
if (this.activeId === id) {
this.activeId = null
this.current = null
}
},
/**
* Import a packaged seed (Duniter Ğ1 / Atelier du Canal) through the SAME
* path as a user import. The JSON files are produced by separate tooling:
* a missing file fails SOFTLY with a French issue, never a crash.
*/
async loadSeed(name: SeedName): Promise<ImportResult> {
let bundle: unknown
try {
bundle
= name === 'duniter-g1'
? (await import('../data/seeds/duniter-g1.bundle.json')).default
: (await import('../data/seeds/atelier-du-canal.bundle.json')).default
} catch {
return {
issues: [
{
level: 'error',
message: `Le jeu de démonstration « ${name} » n'est pas disponible dans cette version.`,
},
],
collided: false,
}
}
return await this.importJson(JSON.stringify(bundle), true)
},
},
})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -265,7 +265,7 @@ export const useDocumentsStore = defineStore('documents', {
const map: Record<string, ItemVersion[]> = {} const map: Record<string, ItemVersion[]> = {}
itemIds.forEach((id, i) => { itemIds.forEach((id, i) => {
const r = results[i] const r = results[i]
map[id] = r.status === 'fulfilled' ? r.value : [] map[id] = r?.status === 'fulfilled' ? r.value : []
}) })
this.allItemVersions = map this.allItemVersions = map
} finally { } finally {
+1 -1
View File
@@ -49,7 +49,7 @@ export const useOrganizationsStore = defineStore('organizations', {
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
if (stored && this.organizations.some(o => o.slug === stored)) { if (stored && this.organizations.some(o => o.slug === stored)) {
this.activeSlug = stored this.activeSlug = stored
} else if (this.organizations.length > 0) { } else if (this.organizations[0]) {
this.activeSlug = this.organizations[0].slug this.activeSlug = this.organizations[0].slug
} }
} catch (err: any) { } catch (err: any) {
+607
View File
@@ -0,0 +1,607 @@
/**
* Store-layer specs — the decisions façade + the seven templates.
*
* Plain vitest + createPinia (no Nuxt runtime): the stores import defineStore
* from 'pinia' and the engines/persistence relatively, so they run here as-is.
* idb-keyval is mocked with an in-memory Map.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('idb-keyval', () => {
const store = new Map<string, unknown>()
return {
get: async (key: string) => store.get(key),
set: async (key: string, value: unknown) => void store.set(key, value),
del: async (key: string) => void store.delete(key),
keys: async () => [...store.keys()],
}
})
import { useCollectiveStore } from '../../app/stores/collective'
import { useDecisionsStore } from '../../app/stores/decisions'
import type { Refusal } from '../../app/stores/decisions'
import { buildTemplateBundle, TEMPLATE_CARDS } from '../../app/data/templates'
import type { TemplateId } from '../../app/data/templates'
import { validateBundle } from '../../app/data/persistence'
import { hasConsentProtocol, resolveSettings, windowOutcome } from '../../app/engine'
import type {
Decision,
Mandate,
ParamSpec,
Verdict,
VoteSession,
Vote,
} from '../../app/types/domain'
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
const MEMBERS = ['Alice', 'Bakir', 'Chloé', 'Dara']
function isRefusal(value: unknown): value is Refusal {
return (
typeof value === 'object'
&& value !== null
&& 'ok' in value
&& (value as { ok: unknown }).ok === false
)
}
/** Narrow an `X | Refusal` return — throws the French reason on refusal. */
function ok<T>(value: T | Refusal): T {
if (isRefusal(value)) throw new Error(value.reason)
return value
}
async function setup(template: TemplateId = 'association') {
setActivePinia(createPinia())
const col = useCollectiveStore()
const store = useDecisionsStore()
const result = await col.createFromTemplate(template, {
name: 'Le Test',
slug: `test-${crypto.randomUUID().slice(0, 8)}`,
color: '#3b82f6',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
})
expect(result.state).toBeDefined()
const me = col.me!
const root = col.circles.find(c => c.id === col.current!.collective.rootCircleId)!
const person = (name: string) => col.people.find(p => p.displayName === name)!
const protocolByMethod = (method: string) =>
col.protocols.find(p => p.method === method)!
return { col, store, me, root, person, protocolByMethod }
}
const buildOpts = (slug: string) => ({
name: 'Gabarit',
slug,
color: '#0ea5e9',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
now: '2026-08-11T10:00:00.000Z',
newId: () => crypto.randomUUID(),
})
// ─────────────────────────────────────────────────────────────
// Solo — 2 gestures, adopted
// ─────────────────────────────────────────────────────────────
describe('solo path', () => {
it('capture → triage → applyPath adopts in two mutations', async () => {
const { store } = await setup()
const decision = ok(store.capture('Acheter une bouilloire pour le local'))
expect(decision.status).toBe('draft')
expect(decision.scope.selfOnly).toBe(true)
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'easy',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('solo')
expect(verdict.rule).toBe('R2')
const adopted = ok(store.applyPath(decision, verdict))
expect(adopted.status).toBe('adopted')
expect(adopted.decidedAt).toBeDefined()
expect(adopted.windowEndsAt).toBeUndefined()
})
it('extracts #tags at capture — simple', async () => {
const { store } = await setup()
const decision = ok(store.capture('Réparer le vidéoprojecteur #matériel #salle'))
expect(decision.tags).toEqual(['matériel', 'salle'])
})
})
// ─────────────────────────────────────────────────────────────
// Mandate — window, Assent, windowOutcome
// ─────────────────────────────────────────────────────────────
describe('mandate path', () => {
it('routes under my covering mandate, opens the window, adopts on explicit assent', async () => {
const { col, store, me, root, person } = await setup()
const now = col.now()
const mandate: Mandate = {
id: col.newId(),
collectiveId: col.current!.collective.id,
createdAt: now,
updatedAt: now,
title: 'Intendance du local',
holderId: me.id,
originDecisionId: col.newId(),
domain: { circleIds: [root.id], tags: [] },
startsAt: now,
endsAt: '2100-01-01T00:00:00.000Z',
electorCircleId: root.id,
nominationMethod: 'consent',
reports: [],
status: 'active',
}
col.current!.mandates.push(mandate)
const decision = ok(store.capture('Remplacer la serrure du local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('mandate')
expect(verdict.rule).toBe('R0b')
expect(verdict.windowHours).toBe(48)
// The tunnel's chips travel through edits (the verdict carries none).
ok(store.applyPath(decision, verdict, { reversibility: 'costly', weight: 'binding' }))
expect(decision.status).toBe('objection')
expect(decision.underMandateId).toBe(mandate.id)
expect(decision.windowEndsAt).toBeDefined()
// Computed concerns: the four members (the author is excluded).
const concerns = col.concerns.filter(c => c.decisionId === decision.id)
expect(concerns).toHaveLength(4)
expect(concerns.every(c => c.origin === 'computed' && c.beforeSnapshot)).toBe(true)
// Costly ⇒ never adopted by pure silence: extend, until a third-party assent.
const ctx = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctx)).toBe('extend')
ok(store.assentTo(decision.id, person('Alice').id))
const ctxAfter = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctxAfter)).toBe('adopt')
const moved = store.transition(decision.id, 'adopted')
expect(moved.ok).toBe(true)
expect(decision.status).toBe('adopted')
})
})
// ─────────────────────────────────────────────────────────────
// Collective consent — objection blocks adoption
// ─────────────────────────────────────────────────────────────
describe('collective consent path', () => {
it('opens a session, and a maintained objection prevents adoption', async () => {
const { col, store, root, person } = await setup()
const decision = ok(store.capture('Organiser une fête de quartier au local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('collective')
expect(verdict.rule).toBe('R5')
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// Arrested list: 4 computed concerned + the author.
expect(session.corpusSize).toBe(5)
expect(session.status).toBe('open')
ok(store.objectTo(decision.id, 'content', 'La cour est trop petite.', person('Bakir').id))
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('rejected')
expect(decision.status).not.toBe('adopted')
})
it('boundary objection suspends the window countdown', async () => {
const { store, person } = await setup()
const decision = ok(store.capture('Changer le planning du ménage'))
decision.status = 'objection'
ok(store.objectTo(decision.id, 'boundary', 'Le cercle Cuisine a été oublié.', person('Chloé').id))
expect(decision.windowSuspendedAt).toBeDefined()
const refused = store.transition(decision.id, 'adopted')
expect(refused.ok).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// Nuanced — full loop, clause application
// ─────────────────────────────────────────────────────────────
describe('nuanced session', () => {
it('votes, tallies, closes and applies the amended clause automatically', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const nuanced = protocolByMethod('nuanced')
const clauseA1 = col.clauses.find(c => c.code === 'A1')!
const oldVersionId = clauseA1.currentVersionId!
const decision = ok(store.capture('Donner une nouvelle boussole au collectif'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: nuanced.id,
amendsClauseId: clauseA1.id,
body: 'La convivialité rejoint nos deux boussoles.',
}),
)
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// A negative nuance without comment is refused.
const refused = store.castVote(session.id, { value: 1 })
expect(isRefusal(refused)).toBe(true)
ok(store.castVote(session.id, { value: 4 }))
ok(store.castVote(session.id, { value: 5, asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { value: 3, asPersonId: person('Bakir').id }))
const tallied = ok(store.tally(session))
if (tallied.method !== 'nuanced') throw new Error('bad method')
expect(tallied.result.total).toBe(3)
expect(tallied.result.adopted).toBe(true)
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('adopted')
expect(decision.status).toBe('adopted')
// Automatic application: new current version, old one superseded.
const newVersion = col.versions.find(
v => v.clauseId === clauseA1.id && v.status === 'current',
)!
expect(newVersion.decisionId).toBe(decision.id)
expect(newVersion.content).toBe('La convivialité rejoint nos deux boussoles.')
expect(clauseA1.currentVersionId).toBe(newVersion.id)
expect(col.versions.find(v => v.id === oldVersionId)!.status).toBe('superseded')
})
it('re-vote supersedes — only the last active vote counts', async () => {
const { col, store, root, protocolByMethod } = await setup()
const decision = ok(store.capture('Peindre la façade en ocre'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('nuanced').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
const first = ok(store.castVote(session.id, { value: 2 })) as Vote
const second = ok(store.castVote(session.id, { value: 5 })) as Vote
expect(second.supersedesVoteId).toBe(first.id)
const active = store.activeVotes(session.id)
expect(active).toHaveLength(1)
expect(active[0]!.value).toBe(5)
})
})
// ─────────────────────────────────────────────────────────────
// Parametric — frozen, then the steward's gesture
// ─────────────────────────────────────────────────────────────
describe('parametric session', () => {
const spec: ParamSpec = {
params: [
{ key: 'ateliers', label: 'Ateliers', kind: 'share', min: 0, max: 100, step: 5, baseline: 40 },
{ key: 'materiel', label: 'Matériel', kind: 'share', min: 0, max: 100, step: 5, baseline: 30 },
{ key: 'reserve', label: 'Réserve', kind: 'share', min: 0, max: 100, step: 5, derived: true },
],
constraint: 'sum100',
}
async function openParametric() {
const context = await setup()
const { col, store, root, protocolByMethod, person } = context
const decision = ok(store.capture('Répartir le budget des ateliers'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: protocolByMethod('parametric').id,
paramSpec: spec,
stewardIds: [person('Alice').id],
}),
)
const session = col.sessions.find(s => s.decisionId === decision.id)!
return { ...context, decision, session }
}
it('validates votes, freezes at closure, refuses a non-steward, crystallizes by gesture', async () => {
const { store, person, me, decision, session } = await openParametric()
// Off-grid vote refused (step 5).
const bad = store.castVote(session.id, { values: [3, 30] })
expect(isRefusal(bad)).toBe(true)
ok(store.castVote(session.id, { values: [50, 30] }))
ok(store.castVote(session.id, { values: [40, 40], asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { values: [60, 20], asPersonId: person('Bakir').id }))
// Parametric NEVER closes automatically: it freezes and waits.
const frozen = ok(store.closeSession(session))
expect(frozen.status).toBe('frozen')
expect(decision.status).toBe('voting')
// I am not a steward (Alice is) — the gesture is refused.
const refused = store.crystallize(session.id)
expect(isRefusal(refused) && refused.reason.includes('garant')).toBe(true)
// As a steward, the dated human gesture crystallizes the LOW median.
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.status).toBe('closed')
expect(closed.outcome).toBe('adopted')
expect(closed.crystallizedById).toBe(me.id)
expect(closed.crystallizedAt).toBeDefined()
expect(decision.status).toBe('adopted')
// Low medians: Ateliers 50, Matériel 30, Réserve derived 20.
expect(decision.body).toContain('Position cristallisée')
expect(decision.body).toContain('Ateliers : 50')
expect(decision.body).toContain('Réserve : 20')
})
it('quorum not reached ⇒ rejected, observed at the same gesture', async () => {
const { store, me, decision, session } = await openParametric()
ok(store.castVote(session.id, { values: [50, 30] })) // 1 < parametricMinParticipants 3
ok(store.closeSession(session))
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.outcome).toBe('rejected')
expect(decision.status).toBe('rejected')
})
})
// ─────────────────────────────────────────────────────────────
// Election — blank votes, tie left to humans
// ─────────────────────────────────────────────────────────────
describe('election session', () => {
it('accepts blanks, and a tie is NEVER broken by the tool', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const decision = ok(store.capture('Confier la trésorerie'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('election').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
ok(store.castVote(session.id, { choicePersonId: person('Alice').id }))
ok(store.castVote(session.id, {
choicePersonId: person('Chloé').id, asPersonId: person('Bakir').id,
}))
// A deposited vote WITHOUT designation is a BLANK — participation only.
const blank = ok(store.castVote(session.id, { asPersonId: person('Dara').id })) as Vote
expect(blank.choicePersonId).toBeUndefined()
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('tie')
// The decision waits for the HUMAN runoff — never adopted by the engine.
expect(decision.status).toBe('voting')
const tallied = ok(store.tally(session))
if (tallied.method !== 'election') throw new Error('bad method')
if (tallied.result.outcome !== 'tie') throw new Error('expected tie')
expect(tallied.result.blanks).toBe(1)
expect(tallied.result.participants).toBe(3)
})
})
// ─────────────────────────────────────────────────────────────
// L'épreuve du réel — revise chains a child
// ─────────────────────────────────────────────────────────────
describe('review', () => {
it("'revise' chains a pre-filled child under the original protocol", async () => {
const { col, store } = await setup()
const decision = ok(store.capture('Vendre la vieille imprimante'))
const verdict = ok(
store.runTriage({
title: decision.title, tags: decision.tags, scope: decision.scope,
reversibility: 'irreversible', weight: 'light', urgent: false,
}),
) as Verdict
expect(verdict.reviewRequired).toBe(true)
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('adopted')
expect(decision.review?.dueAt).toBeDefined()
const child = ok(store.reviewVerdict(decision.id, 'revise', 'Elle sert encore.')) as Decision
expect(decision.review?.verdict).toBe('revise')
expect(child.parentDecisionId).toBe(decision.id)
expect(child.chainKind).toBe('revision')
expect(child.status).toBe('draft')
expect(child.title).toBe(`Réviser — ${decision.title}`)
expect(col.decisions.some(d => d.id === child.id)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Templates — the seven founding bundles
// ─────────────────────────────────────────────────────────────
describe('templates', () => {
it('exposes the seven cards, blank first, standards re-labelled', () => {
expect(TEMPLATE_CARDS).toHaveLength(7)
expect(TEMPLATE_CARDS[0]!.id).toBe('blank')
expect(TEMPLATE_CARDS[0]!.subtitle).toContain('observatoire')
for (const id of ['informal', 'association', 'cooperative', 'commune', 'free-currency']) {
const card = TEMPLATE_CARDS.find(c => c.id === id)!
expect(card.subtitle).toBe('point de départ — tout est amendable par décision')
}
expect(TEMPLATE_CARDS.find(c => c.id === 'symmetric')!.title).toBe('Institution symétrique')
})
it('blank passes validateBundle and satisfies the consent invariant', () => {
const bundle = buildTemplateBundle('blank', buildOpts('page-blanche'))
const { bundle: validated, issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(validated).toBeDefined()
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Consent ONLY — the invariant satisfied, everything else falls back.
expect(bundle.protocols).toHaveLength(1)
expect(bundle.protocols[0]!.method).toBe('consent')
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
expect(settings.triage.requireEffects).toBe('none')
expect(settings.protocolByRange.large).toBeUndefined()
})
it('builds the seven templates with every clause founded by an adopted decision', () => {
const cards: TemplateId[] = TEMPLATE_CARDS.map(c => c.id)
for (const id of cards) {
const bundle = buildTemplateBundle(id, buildOpts(`t-${id}`))
const { issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Every clause: a 'current' version born of an ADOPTED founding decision.
for (const clause of bundle.clauses) {
expect(clause.currentVersionId).toBeDefined()
const version = bundle.versions.find(v => v.id === clause.currentVersionId)!
expect(version.status).toBe('current')
const foundingDecision = bundle.decisions.find(d => d.id === version.decisionId)!
expect(foundingDecision.status).toBe('adopted')
}
// The first proposed decision, in the infinitive.
expect(bundle.decisions.some(
d => d.title === 'Adopter notre Pacte' && d.status === 'draft' && d.route === 'collective',
)).toBe(true)
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
}
})
it('wires protocolByRange.large: nuanced for standards, inertial binary for free-currency', () => {
const assoc = buildTemplateBundle('association', buildOpts('assoc'))
const assocSettings = resolveSettings(assoc.clauses, assoc.versions)
const assocLarge = assoc.protocols.find(p => p.id === assocSettings.protocolByRange.large)!
expect(assocLarge.method).toBe('nuanced')
expect(assocSettings.triage.requireEffects).toBe('binding')
const g1 = buildTemplateBundle('free-currency', buildOpts('g1'))
const g1Settings = resolveSettings(g1.clauses, g1.versions)
const g1Large = g1.protocols.find(p => p.id === g1Settings.protocolByRange.large)!
expect(g1Large.method).toBe('binary')
expect(g1Large.modeParams).toBe('D30M50B.1G.2')
expect(g1Large.description).toContain('héritage Toile de Confiance')
const informal = buildTemplateBundle('informal', buildOpts('informel'))
expect(resolveSettings(informal.clauses, informal.versions).triage.requireEffects).toBe('none')
})
it('symmetric seeds the declaration and the founding draft decision', () => {
const bundle = buildTemplateBundle('symmetric', buildOpts('sym'))
expect(bundle.collective.isTransparent).toBe(true)
expect(bundle.docs.some(d => d.role === 'reference' && d.title === 'Déclaration de symétrie')).toBe(true)
expect(bundle.decisions.some(
d => d.title === 'Déclarer notre symétrie' && d.status === 'draft',
)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Collective store — import path & refusals
// ─────────────────────────────────────────────────────────────
describe('collective store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('refuses an import colliding with an existing collective id', async () => {
const col = useCollectiveStore()
const bundle = buildTemplateBundle('blank', buildOpts('collision'))
const first = await col.importJson(JSON.stringify(bundle), true)
expect(first.state).toBeDefined()
const second = await col.importJson(JSON.stringify(bundle), true)
expect(second.collided).toBe(true)
expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true)
})
it('resolves settings and the me profile from the active state', async () => {
const col = useCollectiveStore()
await col.createFromTemplate('commune', {
name: 'La Commune', slug: `commune-${crypto.randomUUID().slice(0, 8)}`,
color: '#16a34a', icon: 'i-lucide-landmark',
meName: 'Yvv', memberNames: MEMBERS,
})
expect(col.me?.displayName).toBe('Yvv')
expect(col.hasConsent).toBe(true)
expect(col.settings?.triage.requireEffects).toBe('binding')
expect(col.pactDoc?.description).toBe('Notre contrat social — sacralisé, jamais immuable')
expect(col.exportJson()).toContain('"schemaVersion": 2')
})
it('actions refuse politely without an active collective', () => {
const store = useDecisionsStore()
const result = store.capture('Sans collectif')
expect(isRefusal(result)).toBe(true)
})
it('loads the Atelier du Canal seed through the same import path', async () => {
const col = useCollectiveStore()
const result = await col.loadSeed('atelier-du-canal')
// Idempotence guard: an earlier test may have already imported it — a
// collision is then the expected polite refusal, never a crash.
if (result.state) {
expect(col.current?.collective.slug).toBe('atelier-du-canal')
expect(col.hasConsent).toBe(true)
} else {
expect(result.collided).toBe(true)
}
})
})