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:
+9
-728
@@ -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>
|
||||||
|
|
||||||
<div
|
|
||||||
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 />
|
<NuxtPage />
|
||||||
</ErrorBoundary>
|
</NuxtLayout>
|
||||||
|
|
||||||
<!-- 井 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>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sceau hexagramme #48 Tsing 井 — géométrie SVG canonique.
|
||||||
|
* viewBox 112×105, 6 traits (trait 1 en bas = dernier rect), coins vifs.
|
||||||
|
* Réutilisable : stamp du logo (miniature), sceau de page (.app-seal), badges.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{ size?: number }>(), { size: 44 })
|
||||||
|
|
||||||
|
const height = computed(() => Math.round((props.size * 105) / 112))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="height"
|
||||||
|
viewBox="0 0 112 105"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- Trait 6 (haut) — yin -->
|
||||||
|
<rect x="6" y="6" width="42" height="8" />
|
||||||
|
<rect x="64" y="6" width="42" height="8" />
|
||||||
|
<!-- Trait 5 — yang -->
|
||||||
|
<rect x="6" y="23" width="100" height="8" />
|
||||||
|
<!-- Trait 4 — yin -->
|
||||||
|
<rect x="6" y="40" width="42" height="8" />
|
||||||
|
<rect x="64" y="40" width="42" height="8" />
|
||||||
|
<!-- Trait 3 — yang -->
|
||||||
|
<rect x="6" y="57" width="100" height="8" />
|
||||||
|
<!-- Trait 2 — yang -->
|
||||||
|
<rect x="6" y="74" width="100" height="8" />
|
||||||
|
<!-- Trait 1 (bas) — yin -->
|
||||||
|
<rect x="6" y="91" width="42" height="8" />
|
||||||
|
<rect x="64" y="91" width="42" height="8" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sélecteur de collectif — pastille couleur + nom + finalité (clause A1 du Pacte)
|
||||||
|
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
|
||||||
|
* Accès défensifs (?.) : le store peut ne pas être hydraté.
|
||||||
|
*/
|
||||||
|
import { useCollectiveStore } from '~/stores/collective'
|
||||||
|
|
||||||
|
const store = useCollectiveStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
|
onClickOutside(rootEl, () => { open.value = false })
|
||||||
|
watch(() => route.path, () => { open.value = false })
|
||||||
|
|
||||||
|
const current = computed(() => store.current?.collective ?? null)
|
||||||
|
const index = computed(() => store.index ?? [])
|
||||||
|
|
||||||
|
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
|
||||||
|
const purpose = computed(() => {
|
||||||
|
const state = store.current
|
||||||
|
if (!state) return ''
|
||||||
|
const clause = state.clauses?.find(c => c.code === 'A1')
|
||||||
|
if (!clause?.currentVersionId) return ''
|
||||||
|
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function pick(id: string) {
|
||||||
|
open.value = false
|
||||||
|
if (id !== store.activeId) await store.switchTo?.(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div ref="rootEl" class="ws">
|
||||||
|
<!-- Aucun collectif : inviter à créer -->
|
||||||
|
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
|
||||||
|
<UIcon name="i-lucide-sparkles" />
|
||||||
|
<span>Créer</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="ws__trigger"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<span class="ws__dot" :style="{ background: current.color }" />
|
||||||
|
<span class="ws__text">
|
||||||
|
<span class="ws__name">{{ current.name }}</span>
|
||||||
|
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
|
||||||
|
</span>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="ws__chevron"
|
||||||
|
:class="{ 'ws__chevron--open': open }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="ws-pop">
|
||||||
|
<div v-if="open" class="ws__menu ld-card" role="menu">
|
||||||
|
<button
|
||||||
|
v-for="entry in index"
|
||||||
|
:key="entry.id"
|
||||||
|
class="ws__item"
|
||||||
|
:class="{ 'ws__item--active': entry.id === store.activeId }"
|
||||||
|
role="menuitem"
|
||||||
|
@click="pick(entry.id)"
|
||||||
|
>
|
||||||
|
<span class="ws__dot" :style="{ background: entry.color }" />
|
||||||
|
<span class="ws__item-name">{{ entry.name }}</span>
|
||||||
|
<UIcon
|
||||||
|
v-if="entry.id === store.activeId"
|
||||||
|
name="i-lucide-check"
|
||||||
|
class="ws__item-check"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ws__sep" />
|
||||||
|
|
||||||
|
<NuxtLink to="/creer" class="ws__item" role="menuitem">
|
||||||
|
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
|
||||||
|
<span class="ws__item-name">Créer un collectif</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
|
||||||
|
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
|
||||||
|
<span class="ws__item-name">Données locales</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ws {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__create {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws__trigger:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__dot {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__purpose {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__chevron {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.ws__chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Menu --- */
|
||||||
|
.ws__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
min-width: 15rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws__item:hover {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
.ws__item--active {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-check {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.375rem 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Transition --- */
|
||||||
|
.ws-pop-enter-active,
|
||||||
|
.ws-pop-leave-active {
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.ws-pop-enter-from,
|
||||||
|
.ws-pop-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* The ONE search index — Cmd+K AND Q0 « déjà décidé ? » (same index, by
|
||||||
|
* doctrine): clauses (code + title), decisions (title), mandates (title).
|
||||||
|
* Case- and accent-insensitive. Pure selector over the collective store.
|
||||||
|
*
|
||||||
|
* EXPLICIT imports (no Nuxt auto-imports) — testable under plain vitest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Id } from '../types/domain'
|
||||||
|
import { STATUS_LABELS } from '../lexicon'
|
||||||
|
import { useCollectiveStore } from '../stores/collective'
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
kind: 'clause' | 'decision' | 'mandate'
|
||||||
|
id: Id
|
||||||
|
label: string
|
||||||
|
sublabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HITS = 20
|
||||||
|
|
||||||
|
/** Case/accent-insensitive folding (same folding as the stores). */
|
||||||
|
function fold(text: string): string {
|
||||||
|
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch() {
|
||||||
|
const col = useCollectiveStore()
|
||||||
|
|
||||||
|
/** Search the unique index. Empty (or blank) query ⇒ no hit. */
|
||||||
|
const search = (query: string): SearchHit[] => {
|
||||||
|
const needle = fold(query.trim())
|
||||||
|
if (needle.length === 0) return []
|
||||||
|
const hits: SearchHit[] = []
|
||||||
|
|
||||||
|
for (const clause of col.clauses) {
|
||||||
|
if (hits.length >= MAX_HITS) break
|
||||||
|
if (fold(clause.code).includes(needle) || fold(clause.title).includes(needle)) {
|
||||||
|
const doc = col.docs.find(d => d.id === clause.docId)
|
||||||
|
hits.push({
|
||||||
|
kind: 'clause',
|
||||||
|
id: clause.id,
|
||||||
|
label: `${clause.code} — ${clause.title}`,
|
||||||
|
sublabel: doc?.title ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decision of col.decisions) {
|
||||||
|
if (hits.length >= MAX_HITS) break
|
||||||
|
if (fold(decision.title).includes(needle)) {
|
||||||
|
hits.push({
|
||||||
|
kind: 'decision',
|
||||||
|
id: decision.id,
|
||||||
|
label: decision.title,
|
||||||
|
sublabel: STATUS_LABELS[decision.status],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mandate of col.mandates) {
|
||||||
|
if (hits.length >= MAX_HITS) break
|
||||||
|
if (fold(mandate.title).includes(needle)) {
|
||||||
|
const holder = col.people.find(p => p.id === mandate.holderId)
|
||||||
|
hits.push({
|
||||||
|
kind: 'mandate',
|
||||||
|
id: mandate.id,
|
||||||
|
label: mandate.title,
|
||||||
|
sublabel: holder?.displayName ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
return { search }
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* The seven collective templates (BLUEPRINT-V2.md « Seeds » GABARITS + Δ10/Δ11/Δ20).
|
||||||
|
*
|
||||||
|
* buildTemplateBundle() produces a COMPLETE schemaVersion-2 Bundle that goes
|
||||||
|
* through the SAME import path as a user bundle (importBundle, asSeed:true).
|
||||||
|
*
|
||||||
|
* Doctrinal invariants honored here:
|
||||||
|
* - every template seeds a Consent protocol (protocolByRange.consent — Δ10);
|
||||||
|
* - every ClauseVersion is born of a Decision: one FOUNDING decision
|
||||||
|
* (route 'record', adopted) carries every seeded clause version — a single
|
||||||
|
* shared founding act, exactly what a real constitutive gesture is;
|
||||||
|
* - blank stays a blank page: preamble + A1 + the « consignation » clause +
|
||||||
|
* requireEffects='none' + the Consent protocol ONLY — every other setting
|
||||||
|
* falls back to SETTINGS_DEFAULTS through resolveSettings;
|
||||||
|
* - the five standard templates (informal/association/cooperative/commune/
|
||||||
|
* free-currency) seed the 10 triage setting clauses + Consent, Nuancé,
|
||||||
|
* Réglage collectif, Élection — 'protocols.large' = the NUANCED protocol
|
||||||
|
* everywhere EXCEPT free-currency, where large = the inertial binary
|
||||||
|
* (D30M50B.1G.2 — the Ğ1 heritage put back in its place);
|
||||||
|
* - free-currency: the default resources unit is 'DU' — v2 keeps it as a
|
||||||
|
* DESCRIPTIVE default suggested by the tunnel UI (Resources.unit), never a
|
||||||
|
* conversion: DU and € are oil and water, never summed (huile/eau);
|
||||||
|
* - symmetric: full transparency (isTransparent), a « Déclaration de
|
||||||
|
* symétrie » reference TextDoc, and the draft decision « Déclarer notre
|
||||||
|
* symétrie ». Two MandateDrafts are SUGGESTED by the onboarding UI (not
|
||||||
|
* seeded — a mandate is born of a decision, never of a template):
|
||||||
|
* { title: 'Ambassadeur', domainCircleIds: [root], domainTags: ['liaison'],
|
||||||
|
* durationDays: 180, reportEveryDays: 30 }
|
||||||
|
* { title: 'Négociateur', domainCircleIds: [root], domainTags: ['symétrie'],
|
||||||
|
* durationDays: 180, reportEveryDays: 30 }
|
||||||
|
* - every template proposes the first decision « Adopter notre Pacte »
|
||||||
|
* (route 'collective', status 'draft', consent protocol).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Bundle,
|
||||||
|
Circle,
|
||||||
|
Clause,
|
||||||
|
ClauseVersion,
|
||||||
|
Collective,
|
||||||
|
CollectiveTemplate,
|
||||||
|
Decision,
|
||||||
|
FormulaParams,
|
||||||
|
Id,
|
||||||
|
InertiaPreset,
|
||||||
|
ISODate,
|
||||||
|
Json,
|
||||||
|
Person,
|
||||||
|
Protocol,
|
||||||
|
TextDoc,
|
||||||
|
VoteMethod,
|
||||||
|
} from '../types/domain'
|
||||||
|
import { formatModeParams } from '../engine/modeParams'
|
||||||
|
|
||||||
|
export type TemplateId = CollectiveTemplate
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// The seven cards (/creer, écran 2) — blank FIRST
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TemplateCard {
|
||||||
|
id: TemplateId
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARD_SUBTITLE = 'point de départ — tout est amendable par décision'
|
||||||
|
|
||||||
|
export const TEMPLATE_CARDS: TemplateCard[] = [
|
||||||
|
{
|
||||||
|
id: 'blank',
|
||||||
|
title: 'Page blanche',
|
||||||
|
subtitle: "observatoire d'abord",
|
||||||
|
description:
|
||||||
|
"Une clause : nous consignons nos décisions ; le reste s'écrira au fil de l'eau. "
|
||||||
|
+ 'Consentement seul installé.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'informal',
|
||||||
|
title: 'Informel',
|
||||||
|
subtitle: STANDARD_SUBTITLE,
|
||||||
|
description:
|
||||||
|
"Un groupe d'amis, un projet naissant : des chemins légers, aucune matière exigée.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'association',
|
||||||
|
title: 'Association',
|
||||||
|
subtitle: STANDARD_SUBTITLE,
|
||||||
|
description:
|
||||||
|
'Un Bureau, le consentement et le vote nuancé — la vie associative sans les camps.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cooperative',
|
||||||
|
title: 'Coopérative',
|
||||||
|
subtitle: STANDARD_SUBTITLE,
|
||||||
|
description:
|
||||||
|
'Décider ensemble du travail et des ressources — matière exigée sur ce qui engage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'commune',
|
||||||
|
title: 'Commune',
|
||||||
|
subtitle: STANDARD_SUBTITLE,
|
||||||
|
description:
|
||||||
|
'Des quartiers, des périmètres emboîtés — la subsidiarité en actes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'free-currency',
|
||||||
|
title: 'Communauté monnaie libre',
|
||||||
|
subtitle: STANDARD_SUBTITLE,
|
||||||
|
description:
|
||||||
|
"L'héritage Toile de Confiance : le pour/contre inertiel, outil de dernier recours "
|
||||||
|
+ 'des très grands corps.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'symmetric',
|
||||||
|
title: 'Institution symétrique',
|
||||||
|
subtitle: 'le geste fondateur',
|
||||||
|
description:
|
||||||
|
'Transparence totale, Déclaration de symétrie, et la première décision : '
|
||||||
|
+ 'déclarer notre symétrie.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// buildTemplateBundle
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TemplateBuildOptions {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
meName: string
|
||||||
|
memberNames: string[]
|
||||||
|
now: ISODate
|
||||||
|
newId: () => Id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which protocols a template installs. */
|
||||||
|
const STANDARD_TEMPLATES: TemplateId[] = [
|
||||||
|
'informal',
|
||||||
|
'association',
|
||||||
|
'cooperative',
|
||||||
|
'commune',
|
||||||
|
'free-currency',
|
||||||
|
'symmetric',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function buildTemplateBundle(id: TemplateId, opts: TemplateBuildOptions): Bundle {
|
||||||
|
const { name, slug, color, icon, meName, memberNames, now, newId } = opts
|
||||||
|
const collectiveId = newId()
|
||||||
|
|
||||||
|
const entity = () => ({
|
||||||
|
id: newId(),
|
||||||
|
collectiveId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── People — me + the initial members ──────────────────────
|
||||||
|
const me: Person = { ...entity(), displayName: meName, isMe: true }
|
||||||
|
const members: Person[] = memberNames.map(displayName => ({
|
||||||
|
...entity(),
|
||||||
|
displayName,
|
||||||
|
isMe: false,
|
||||||
|
}))
|
||||||
|
const people = [me, ...members]
|
||||||
|
|
||||||
|
// ── Circles — root « Tous » + one sober typed circle per template ──
|
||||||
|
const rootCircle: Circle = {
|
||||||
|
...entity(),
|
||||||
|
name: 'Tous',
|
||||||
|
purpose: 'Toutes les personnes du collectif',
|
||||||
|
memberIds: people.map(p => p.id),
|
||||||
|
domains: [],
|
||||||
|
}
|
||||||
|
const circles: Circle[] = [rootCircle]
|
||||||
|
|
||||||
|
const typed: Partial<Record<TemplateId, { name: string; purpose: string; kind: Circle['kind'] }>> = {
|
||||||
|
association: { name: 'Bureau', purpose: "L'équipe qui fait tourner l'association", kind: 'team' },
|
||||||
|
cooperative: { name: 'Conseil', purpose: 'Le conseil de la coopérative', kind: 'team' },
|
||||||
|
commune: { name: 'Quartiers', purpose: 'Les lieux de la commune', kind: 'place' },
|
||||||
|
'free-currency': { name: 'Forgerons', purpose: 'Celles et ceux qui forgent les blocs', kind: 'team' },
|
||||||
|
}
|
||||||
|
const typedDef = typed[id]
|
||||||
|
if (typedDef) {
|
||||||
|
circles.push({
|
||||||
|
...entity(),
|
||||||
|
name: typedDef.name,
|
||||||
|
purpose: typedDef.purpose,
|
||||||
|
kind: typedDef.kind,
|
||||||
|
memberIds: [],
|
||||||
|
parentCircleId: rootCircle.id,
|
||||||
|
domains: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protocols ──────────────────────────────────────────────
|
||||||
|
const protocols: Protocol[] = []
|
||||||
|
const mkProtocol = (
|
||||||
|
pName: string,
|
||||||
|
method: VoteMethod,
|
||||||
|
description: string,
|
||||||
|
durationDays: number,
|
||||||
|
formula: FormulaParams,
|
||||||
|
): Protocol => {
|
||||||
|
const protocol: Protocol = {
|
||||||
|
...entity(),
|
||||||
|
name: pName,
|
||||||
|
method,
|
||||||
|
description,
|
||||||
|
durationDays,
|
||||||
|
ballot: 'open',
|
||||||
|
formula,
|
||||||
|
modeParams: formatModeParams({
|
||||||
|
duration_days: durationDays,
|
||||||
|
majority_pct: formula.majorityPct,
|
||||||
|
base_exponent: formula.baseExponent,
|
||||||
|
gradient_exponent: formula.gradientExponent,
|
||||||
|
constant_base: formula.constantBase,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
protocols.push(protocol)
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFormula = { majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2, constantBase: 0 }
|
||||||
|
|
||||||
|
const consent = mkProtocol(
|
||||||
|
'Consentement',
|
||||||
|
'consent',
|
||||||
|
'Ça me va / J\'objecte — zéro objection maintenue vaut adoption.',
|
||||||
|
7,
|
||||||
|
{ ...baseFormula },
|
||||||
|
)
|
||||||
|
|
||||||
|
let nuanced: Protocol | undefined
|
||||||
|
let parametric: Protocol | undefined
|
||||||
|
let election: Protocol | undefined
|
||||||
|
let binary: Protocol | undefined
|
||||||
|
|
||||||
|
if (STANDARD_TEMPLATES.includes(id)) {
|
||||||
|
nuanced = mkProtocol(
|
||||||
|
'Vote nuancé',
|
||||||
|
'nuanced',
|
||||||
|
'Chacun·e se prononce en nuances, pas en camps.',
|
||||||
|
14,
|
||||||
|
{ ...baseFormula, nuancedThresholdPct: 60, nuancedMinParticipants: 3 },
|
||||||
|
)
|
||||||
|
parametric = mkProtocol(
|
||||||
|
'Réglage collectif',
|
||||||
|
'parametric',
|
||||||
|
'Décider au curseur — le collectif retient la médiane de chaque curseur.',
|
||||||
|
14,
|
||||||
|
{ ...baseFormula, parametricMinParticipants: 3 },
|
||||||
|
)
|
||||||
|
election = mkProtocol(
|
||||||
|
'Élection',
|
||||||
|
'election',
|
||||||
|
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez.",
|
||||||
|
14,
|
||||||
|
{ ...baseFormula, electionMinParticipants: 3, tieBreak: 'runoff' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'free-currency') {
|
||||||
|
// D30M50B.1G.2 — the exact inherited inertia of the Ğ1 web of trust.
|
||||||
|
binary = mkProtocol(
|
||||||
|
'Vote WoT binaire inertiel',
|
||||||
|
'binary',
|
||||||
|
"l'outil de dernier recours des très grands corps — héritage Toile de Confiance",
|
||||||
|
30,
|
||||||
|
{ ...baseFormula },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The Pact + clauses, each founded by ONE adopted decision ──
|
||||||
|
const pactDoc: TextDoc = {
|
||||||
|
...entity(),
|
||||||
|
slug: 'pacte',
|
||||||
|
title: 'Notre Pacte',
|
||||||
|
role: 'pact',
|
||||||
|
description: 'Notre contrat social — sacralisé, jamais immuable',
|
||||||
|
}
|
||||||
|
const docs: TextDoc[] = [pactDoc]
|
||||||
|
|
||||||
|
const founding: Decision = {
|
||||||
|
...entity(),
|
||||||
|
authorId: me.id,
|
||||||
|
title: 'Fonder notre Pacte',
|
||||||
|
tags: ['pacte'],
|
||||||
|
reversibility: 'costly',
|
||||||
|
weight: 'structural',
|
||||||
|
urgent: false,
|
||||||
|
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
|
||||||
|
route: 'record',
|
||||||
|
triageRule: 'R0a',
|
||||||
|
routeOverridden: false,
|
||||||
|
decidedHow: 'posé à la création du collectif — chaque clause reste amendable',
|
||||||
|
status: 'adopted',
|
||||||
|
decidedAt: now,
|
||||||
|
stewardIds: [me.id],
|
||||||
|
measurerIds: [],
|
||||||
|
visibility: 'collective',
|
||||||
|
}
|
||||||
|
|
||||||
|
const clauses: Clause[] = []
|
||||||
|
const versions: ClauseVersion[] = []
|
||||||
|
let position = 0
|
||||||
|
const mkClause = (
|
||||||
|
section: string,
|
||||||
|
code: string,
|
||||||
|
title: string,
|
||||||
|
inertia: InertiaPreset,
|
||||||
|
content: string,
|
||||||
|
settingKey?: string,
|
||||||
|
settingValue?: Json,
|
||||||
|
): Clause => {
|
||||||
|
const clause: Clause = {
|
||||||
|
...entity(),
|
||||||
|
docId: pactDoc.id,
|
||||||
|
section,
|
||||||
|
position: position++,
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
inertia,
|
||||||
|
...(settingKey !== undefined ? { settingKey } : {}),
|
||||||
|
}
|
||||||
|
const version: ClauseVersion = {
|
||||||
|
...entity(),
|
||||||
|
clauseId: clause.id,
|
||||||
|
decisionId: founding.id,
|
||||||
|
versionLabel: 'v1',
|
||||||
|
content,
|
||||||
|
...(settingValue !== undefined ? { settingValue } : {}),
|
||||||
|
status: 'current',
|
||||||
|
adoptedAt: now,
|
||||||
|
}
|
||||||
|
clause.currentVersionId = version.id
|
||||||
|
clauses.push(clause)
|
||||||
|
versions.push(version)
|
||||||
|
return clause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preamble P0 — Autonomie–Équilibre–Liaison, proposed and MODIFIABLE.
|
||||||
|
mkClause(
|
||||||
|
'Préambule',
|
||||||
|
'P0',
|
||||||
|
'Préambule',
|
||||||
|
'standard',
|
||||||
|
'Autonomie — chacun·e décide de ce qui ne concerne que lui ou elle. '
|
||||||
|
+ 'Équilibre — ce qui engage le collectif se décide ensemble, à la juste échelle. '
|
||||||
|
+ 'Liaison — chaque décision garde sa trace et reste révisable. '
|
||||||
|
+ 'Ce préambule est proposé, jamais imposé : modifiable comme tout le reste.',
|
||||||
|
)
|
||||||
|
|
||||||
|
// A1 — Notre finalité (default compasses: vitalité / émancipation).
|
||||||
|
mkClause(
|
||||||
|
'Finalité',
|
||||||
|
'A1',
|
||||||
|
'Notre finalité',
|
||||||
|
'high',
|
||||||
|
"La vitalité du collectif et l'émancipation de chacun·e sont nos deux boussoles.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (id === 'blank') {
|
||||||
|
// The one practice clause of the blank page.
|
||||||
|
mkClause(
|
||||||
|
'Pratique',
|
||||||
|
'C1',
|
||||||
|
'Nous consignons nos décisions',
|
||||||
|
'standard',
|
||||||
|
"Nous consignons nos décisions ; le reste s'écrira au fil de l'eau.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setting clauses ────────────────────────────────────────
|
||||||
|
const requireEffects = id === 'blank' || id === 'informal' ? 'none' : 'binding'
|
||||||
|
const requireEffectsContent =
|
||||||
|
requireEffects === 'none'
|
||||||
|
? "Aucune matière exigée — le collectif s'outillera quand la pratique le demandera."
|
||||||
|
: 'Matière exigée : décisions engageantes et structurantes — au moins un effet recherché avant tout vote collectif.'
|
||||||
|
|
||||||
|
if (id === 'blank') {
|
||||||
|
// Blank page: requireEffects='none' + the consent protocol, NOTHING else —
|
||||||
|
// every other setting falls back to SETTINGS_DEFAULTS via resolveSettings.
|
||||||
|
mkClause('Réglages', 'S1', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', 'none')
|
||||||
|
mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S2',
|
||||||
|
'Protocole de consentement',
|
||||||
|
'high',
|
||||||
|
'Notre protocole de consentement : Consentement (7 jours).',
|
||||||
|
'protocols.consent',
|
||||||
|
consent.id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// The 10 triage keys of SETTINGS_DEFAULTS, in clear French.
|
||||||
|
mkClause('Réglages', 'S1', 'Petit groupe', 'standard', "Jusqu'à 5 personnes, un avis suffit avant de décider.", 'triage.smallGroupMax', 5)
|
||||||
|
mkClause('Réglages', 'S2', 'Grand collectif', 'standard', 'Au-delà de 50 personnes, la modalité des grands corps s\'applique.', 'triage.collectiveMin', 50)
|
||||||
|
mkClause('Réglages', 'S3', 'Consentement', 'standard', "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection, c'est adopté.", 'triage.consentMax', 7)
|
||||||
|
mkClause('Réglages', 'S4', "Fenêtre d'objection", 'standard', "Quarante-huit heures pour dire « Ça me va » ou objecter.", 'triage.objectionWindowHours', 48)
|
||||||
|
mkClause('Réglages', 'S5', "Fenêtre d'avis", 'standard', 'Soixante-douze heures pour déposer un avis.', 'triage.adviceWindowHours', 72)
|
||||||
|
mkClause('Réglages', 'S6', 'Formulation', 'standard', "Quatorze jours pour s'instruire et formuler des contre-propositions.", 'triage.framingDays', 14)
|
||||||
|
mkClause('Réglages', 'S7', 'Débordement du périmètre', 'standard', 'À la moitié de déclarations spontanées, on élargit ou on motive publiquement.', 'triage.concernEscalateRatio', 0.5)
|
||||||
|
mkClause('Réglages', 'S8', 'Récurrence', 'standard', 'À la troisième décision semblable, réclamer un mandat ou créer une règle.', 'triage.recurrenceThreshold', 3)
|
||||||
|
mkClause('Réglages', 'S9', "Épreuve du réel", 'standard', "Quatre-vingt-dix jours après l'adoption, le réel a-t-il suivi ?", 'triage.reviewDelayDays', 90)
|
||||||
|
mkClause('Réglages', 'S10', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', requireEffects)
|
||||||
|
|
||||||
|
// Protocol clauses — the Pact references its protocols (pactClauseId).
|
||||||
|
const consentClause = mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S11',
|
||||||
|
'Protocole de consentement',
|
||||||
|
'high',
|
||||||
|
'Notre protocole de consentement : Consentement (7 jours).',
|
||||||
|
'protocols.consent',
|
||||||
|
consent.id,
|
||||||
|
)
|
||||||
|
consent.pactClauseId = consentClause.id
|
||||||
|
|
||||||
|
if (nuanced) {
|
||||||
|
const c = mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S12',
|
||||||
|
'Protocole nuancé',
|
||||||
|
'high',
|
||||||
|
'De 8 à 50 personnes : le vote nuancé, six nuances, jamais deux camps.',
|
||||||
|
'protocols.nuanced',
|
||||||
|
nuanced.id,
|
||||||
|
)
|
||||||
|
nuanced.pactClauseId = c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'protocols.large' — nuanced everywhere, inertial binary ONLY here.
|
||||||
|
const large = id === 'free-currency' ? binary : nuanced
|
||||||
|
if (large) {
|
||||||
|
const c = mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S13',
|
||||||
|
'Modalité des grands corps',
|
||||||
|
'max',
|
||||||
|
id === 'free-currency'
|
||||||
|
? 'Au-delà de 50 : le pour/contre inertiel hérité de la Toile de Confiance (D30M50B.1G.2).'
|
||||||
|
: 'Au-delà de 50 : le vote nuancé, la modalité que notre Pacte a choisie.',
|
||||||
|
'protocols.large',
|
||||||
|
large.id,
|
||||||
|
)
|
||||||
|
if (id === 'free-currency' && binary) binary.pactClauseId = c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parametric) {
|
||||||
|
const c = mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S14',
|
||||||
|
'Réglage collectif',
|
||||||
|
'high',
|
||||||
|
'Montant, taux, répartition : décider au curseur — le collectif retient la médiane.',
|
||||||
|
'protocols.parametric',
|
||||||
|
parametric.id,
|
||||||
|
)
|
||||||
|
parametric.pactClauseId = c.id
|
||||||
|
}
|
||||||
|
if (election) {
|
||||||
|
const c = mkClause(
|
||||||
|
'Réglages',
|
||||||
|
'S15',
|
||||||
|
"Protocole d'élection",
|
||||||
|
'high',
|
||||||
|
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez, jamais l'outil.",
|
||||||
|
'protocols.election',
|
||||||
|
election.id,
|
||||||
|
)
|
||||||
|
election.pactClauseId = c.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'symmetric') {
|
||||||
|
// Full transparency, declared in the Pact itself.
|
||||||
|
mkClause(
|
||||||
|
'Symétrie',
|
||||||
|
'T1',
|
||||||
|
'Transparence totale',
|
||||||
|
'max',
|
||||||
|
'Tout ce que nous décidons est public — la symétrie commence par la transparence.',
|
||||||
|
)
|
||||||
|
docs.push({
|
||||||
|
...entity(),
|
||||||
|
slug: 'declaration-de-symetrie',
|
||||||
|
title: 'Déclaration de symétrie',
|
||||||
|
role: 'reference',
|
||||||
|
description:
|
||||||
|
'Le geste fondateur : notre institution se déclare symétrique de celles qui nous gouvernent.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decisions — founding (adopted) + first proposals (draft) ──
|
||||||
|
const decisions: Decision[] = [founding]
|
||||||
|
|
||||||
|
decisions.push({
|
||||||
|
...entity(),
|
||||||
|
authorId: me.id,
|
||||||
|
title: 'Adopter notre Pacte',
|
||||||
|
body: 'Relire le Pacte proposé par le gabarit, l\'amender si besoin, et l\'adopter ensemble.',
|
||||||
|
tags: ['pacte'],
|
||||||
|
reversibility: 'costly',
|
||||||
|
weight: 'structural',
|
||||||
|
urgent: false,
|
||||||
|
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
|
||||||
|
route: 'collective',
|
||||||
|
triageRule: 'R5',
|
||||||
|
routeOverridden: false,
|
||||||
|
protocolId: consent.id,
|
||||||
|
status: 'draft',
|
||||||
|
stewardIds: [me.id],
|
||||||
|
measurerIds: [],
|
||||||
|
visibility: 'collective',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (id === 'symmetric') {
|
||||||
|
decisions.push({
|
||||||
|
...entity(),
|
||||||
|
authorId: me.id,
|
||||||
|
title: 'Déclarer notre symétrie',
|
||||||
|
body: 'Adopter la Déclaration de symétrie et la rendre publique.',
|
||||||
|
tags: ['symétrie'],
|
||||||
|
reversibility: 'costly',
|
||||||
|
weight: 'structural',
|
||||||
|
urgent: false,
|
||||||
|
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
|
||||||
|
route: 'collective',
|
||||||
|
triageRule: 'R5',
|
||||||
|
routeOverridden: false,
|
||||||
|
protocolId: consent.id,
|
||||||
|
status: 'draft',
|
||||||
|
stewardIds: [me.id],
|
||||||
|
measurerIds: [],
|
||||||
|
visibility: 'collective',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The tenant ─────────────────────────────────────────────
|
||||||
|
const collective: Collective = {
|
||||||
|
id: collectiveId,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
template: id,
|
||||||
|
isTransparent: id === 'symmetric' || id === 'free-currency',
|
||||||
|
pactDocId: pactDoc.id,
|
||||||
|
rootCircleId: rootCircle.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
exportedAt: now,
|
||||||
|
collective,
|
||||||
|
people,
|
||||||
|
circles,
|
||||||
|
decisions,
|
||||||
|
concerns: [],
|
||||||
|
objections: [],
|
||||||
|
advices: [],
|
||||||
|
assents: [],
|
||||||
|
mandates: [],
|
||||||
|
docs,
|
||||||
|
clauses,
|
||||||
|
versions,
|
||||||
|
protocols,
|
||||||
|
sessions: [],
|
||||||
|
votes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** Layout minimal (onboarding /creer) — fond d'ambiance, contenu centré, sceau discret. */
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ld-v2 -->
|
||||||
|
<div class="bare">
|
||||||
|
<main class="bare__main">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<LdSeal class="app-seal bare__seal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bare {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bare__main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(1.25rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bare__seal {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+1091
-205
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user