v2 : nettoyage des orphelins v1 + documentation livrable

- 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox,
  stores auth/documents/groups/mandates/organizations/protocols/votes,
  composables api/notifications/formula/websocket, utils doublons du moteur)
- nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2
- README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2
- Build zéro erreur, 342/342 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 14:09:05 +02:00
co-authored by Claude Fable 5
parent e164b5f6c1
commit 7cfc7ea352
49 changed files with 236 additions and 8885 deletions
-373
View File
@@ -1,373 +0,0 @@
<script setup lang="ts">
/**
* SectionLayout — Mise en page pour sections.
*
* Desktop (≥1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
*/
export interface StatusFilter {
id: string
label: string
count: number
cssClass?: string
}
export interface ToolboxItem {
title: string
description: string
actions: Array<{
label: string
to?: string
onClick?: () => void
}>
}
const props = withDefaults(
defineProps<{
title: string
subtitle?: string
statuses: StatusFilter[]
toolboxItems?: ToolboxItem[]
activeStatus?: string | null
toolboxTitle?: string
}>(),
{
subtitle: undefined,
toolboxItems: undefined,
activeStatus: null,
toolboxTitle: 'Boîte à outils',
},
)
const emit = defineEmits<{
'update:activeStatus': [status: string | null]
}>()
const toolboxOpen = ref(false)
const statusCssMap: Record<string, string> = {
draft: 'status-prepa',
qualification: 'status-prepa',
candidacy: 'status-prepa',
voting: 'status-vote',
review: 'status-vote',
active: 'status-vigueur',
executed: 'status-vigueur',
completed: 'status-vigueur',
closed: 'status-clos',
archived: 'status-clos',
revoked: 'status-clos',
reporting: 'status-vote',
}
function getStatusClass(status: StatusFilter): string {
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
}
function toggleStatus(statusId: string) {
if (props.activeStatus === statusId) {
emit('update:activeStatus', null)
}
else {
emit('update:activeStatus', statusId)
}
}
</script>
<template>
<div class="section">
<!-- Header -->
<div class="section__header">
<div class="section__header-left">
<h1 class="section__title">{{ title }}</h1>
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
</div>
<!-- Mobile toolbox trigger -->
<button
class="section__toolbox-fab lg:hidden"
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
@click="toolboxOpen = true"
>
<UIcon name="i-lucide-wrench" />
<span>Outils</span>
</button>
</div>
<!-- Body: content + toolbox -->
<div class="section__body">
<div class="section__main">
<!-- Status pills -->
<div v-if="statuses.length > 0" class="section__pills">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-pill"
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
@click="toggleStatus(status.id)"
>
{{ status.label }}
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
</button>
</div>
<div v-if="$slots.search" class="section__search">
<slot name="search" />
</div>
<div class="section__content">
<slot />
</div>
</div>
<!-- Desktop toolbox sidebar (1024px) -->
<aside class="section__toolbox">
<div class="section__toolbox-head">
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
<span>{{ toolboxTitle }}</span>
</div>
<div class="section__toolbox-body">
<div v-if="$slots.toolbox">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
/>
</div>
<div v-else class="section__toolbox-empty">
Aucun outil disponible
</div>
</div>
</aside>
</div>
<!-- Mobile toolbox: USlideover from right -->
<USlideover
v-model:open="toolboxOpen"
side="right"
:title="toolboxTitle"
:ui="{
width: 'max-w-sm',
header: { padding: 'p-4' },
body: { padding: 'p-4' },
}"
>
<template #body>
<div class="section__toolbox-slideover">
<div v-if="$slots.toolbox">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
/>
</div>
<div v-else class="section__toolbox-empty">
Aucun outil disponible
</div>
</div>
</template>
</USlideover>
</div>
</template>
<style scoped>
.section {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
@media (min-width: 640px) {
.section { gap: 1.5rem; }
}
/* Header */
.section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.section__header-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section__title {
font-size: 1.375rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
margin: 0;
}
@media (min-width: 640px) {
.section__title { font-size: 1.75rem; }
}
.section__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-weight: 500;
margin: 0;
line-height: 1.5;
}
@media (min-width: 640px) {
.section__subtitle { font-size: 1rem; }
}
/* Mobile toolbox trigger */
.section__toolbox-fab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 20px;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.section__toolbox-fab:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
.section__toolbox-fab--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
/* Body layout */
.section__body {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
align-items: start;
}
@media (min-width: 1024px) {
.section__body {
grid-template-columns: 1fr 30rem;
}
}
.section__main {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
/* Status pills */
.section__pills {
display: flex;
gap: 0.5rem;
align-items: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 2px;
}
.section__pills::-webkit-scrollbar { display: none; }
@media (min-width: 640px) {
.section__pills {
flex-wrap: wrap;
overflow-x: visible;
}
}
.section__pill-count {
margin-left: 0.25rem;
font-size: 0.6875rem;
font-weight: 800;
opacity: 0.7;
}
.section__search {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
}
@media (max-width: 639px) {
.section__search { flex-direction: column; }
}
.section__content { min-height: 12rem; }
/* Desktop toolbox sidebar */
.section__toolbox {
display: none;
position: sticky;
top: 4.5rem;
align-self: start;
flex-direction: column;
background: var(--mood-surface);
border-radius: 16px;
max-height: calc(100vh - 5.5rem);
box-shadow: 0 4px 24px var(--mood-shadow);
}
@media (min-width: 1024px) {
.section__toolbox { display: flex; }
}
.section__toolbox-head {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.875rem 1rem 0.625rem;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.section__toolbox-head-icon {
font-size: 0.875rem;
}
.section__toolbox-body {
padding: 0 0.75rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
}
.section__toolbox-empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
text-align: center;
padding: 1rem 0;
}
/* Slideover content */
.section__toolbox-slideover {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
@@ -1,252 +0,0 @@
<script setup lang="ts">
const orgsStore = useOrganizationsStore()
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const active = computed(() => orgsStore.active)
const organizations = computed(() => orgsStore.organizations)
function selectOrg(slug: string) {
orgsStore.setActive(slug)
isOpen.value = false
}
// Close on outside click
onMounted(() => {
document.addEventListener('click', (e) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false
}
})
})
</script>
<template>
<div ref="containerRef" class="ws">
<button
class="ws__trigger"
:class="{ 'ws__trigger--open': isOpen }"
:disabled="orgsStore.loading || !active"
@click="isOpen = !isOpen"
>
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
</div>
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
<UIcon :name="active.icon || 'i-lucide-building'" />
</div>
<span class="ws__name">{{ active?.name ?? '…' }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
</button>
<Transition name="dropdown">
<div v-if="isOpen && organizations.length" class="ws__dropdown">
<div class="ws__dropdown-header">
Espace de travail
</div>
<div class="ws__items">
<button
v-for="org in organizations"
:key="org.id"
class="ws__item"
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
@click="selectOrg(org.slug)"
>
<div
class="ws__item-icon"
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
>
<UIcon :name="org.icon || 'i-lucide-building'" />
</div>
<div class="ws__item-info">
<span class="ws__item-name">{{ org.name }}</span>
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
</div>
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
</button>
</div>
<div class="ws__dropdown-footer">
<button class="ws__new-btn" disabled>
<UIcon name="i-lucide-plus" />
Nouveau collectif
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.ws {
position: relative;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--mood-accent-soft);
border-radius: 10px;
cursor: pointer;
transition: all 0.12s ease;
min-height: 2rem;
max-width: 11rem;
}
.ws__trigger:hover:not(:disabled) {
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
}
.ws__trigger--open {
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
}
.ws__trigger:disabled {
opacity: 0.5;
cursor: default;
}
.ws__icon {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.75rem;
}
.ws__icon--muted {
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
}
.ws__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ws__caret {
font-size: 0.75rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
/* Dropdown */
.ws__dropdown {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 13rem;
background: var(--mood-surface);
border-radius: 14px;
box-shadow: 0 8px 32px var(--mood-shadow);
z-index: 100;
overflow: hidden;
}
.ws__dropdown-header {
padding: 0.625rem 0.875rem 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.ws__items {
padding: 0.25rem 0.5rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.625rem;
border-radius: 10px;
cursor: pointer;
transition: background 0.1s ease;
text-align: left;
width: 100%;
}
.ws__item:hover { background: var(--mood-accent-soft); }
.ws__item--active { background: var(--mood-accent-soft); }
.ws__item-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 0.875rem;
}
.ws__item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ws__item-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
.ws__item-role {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.ws__item-check {
color: var(--mood-accent);
font-size: 0.875rem;
flex-shrink: 0;
}
.ws__dropdown-footer {
padding: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.ws__new-btn {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: not-allowed;
opacity: 0.5;
}
/* Transition */
.dropdown-enter-active, .dropdown-leave-active {
transition: all 0.15s ease;
transform-origin: top left;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>
@@ -1,51 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
diff: string
}>()
interface DiffLine {
text: string
type: 'added' | 'removed' | 'header' | 'context'
}
const parsedLines = computed((): DiffLine[] => {
if (!props.diff) return []
return props.diff.split('\n').map((line) => {
if (line.startsWith('@@')) {
return { text: line, type: 'header' as const }
}
if (line.startsWith('+')) {
return { text: line, type: 'added' as const }
}
if (line.startsWith('-')) {
return { text: line, type: 'removed' as const }
}
return { text: line, type: 'context' as const }
})
})
function lineClass(type: DiffLine['type']): string {
switch (type) {
case 'added':
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
case 'removed':
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
case 'header':
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
default:
return 'text-gray-700 dark:text-gray-300'
}
}
</script>
<template>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
v-for="(line, index) in parsedLines"
:key="index"
><div
:class="['px-4 py-0.5', lineClass(line.type)]"
>{{ line.text }}</div></template></pre>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
/**
* Error boundary component.
*
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
* error message in French when child components crash.
* Logs error details to console and emits error event for monitoring.
*/
const emit = defineEmits<{
error: [error: any]
}>()
const hasError = ref(false)
const errorDetails = ref<string | null>(null)
function handleError(error: any) {
hasError.value = true
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
// Log to console for debugging
console.error('[ErrorBoundary] Erreur capturee:', error)
// Emit for external monitoring
emit('error', error)
}
function retry() {
hasError.value = false
errorDetails.value = null
}
</script>
<template>
<NuxtErrorBoundary @error="handleError">
<template v-if="!hasError">
<slot />
</template>
<template v-else>
<div class="flex flex-col items-center justify-center p-8 text-center">
<div class="max-w-md space-y-4">
<UIcon
name="i-lucide-alert-triangle"
class="text-5xl text-warning mx-auto"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Une erreur est survenue
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Un probleme inattendu s'est produit lors du chargement de ce contenu.
Vous pouvez essayer de recharger cette section.
</p>
<p
v-if="errorDetails"
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
>
{{ errorDetails }}
</p>
<UButton
icon="i-lucide-refresh-cw"
label="Reessayer"
color="primary"
variant="soft"
@click="retry"
/>
</div>
</div>
</template>
</NuxtErrorBoundary>
</template>
@@ -1,81 +0,0 @@
<script setup lang="ts">
/**
* Reusable skeleton loader component.
*
* Provides multiple skeleton variants for loading states:
* - Card: card layout with title and content lines
* - List: multiple rows with optional avatar
* - Detail: detailed view with mixed content
*
* Uses Nuxt UI USkeleton components.
*/
const props = withDefaults(
defineProps<{
/** Number of skeleton lines to display (default: 3). */
lines?: number
/** Show an avatar circle placeholder. */
avatar?: boolean
/** Render as a card skeleton with border and padding. */
card?: boolean
}>(),
{
lines: 3,
avatar: false,
card: false,
},
)
/** Generate varying line widths for a natural appearance. */
const lineWidths = computed(() => {
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
})
</script>
<template>
<!-- Card variant -->
<UCard v-if="card" class="animate-pulse">
<div class="space-y-4">
<!-- Optional avatar row -->
<div v-if="avatar" class="flex items-center gap-3">
<USkeleton class="h-10 w-10 rounded-full" />
<div class="flex-1 space-y-2">
<USkeleton class="h-4 w-1/3" />
<USkeleton class="h-3 w-1/4" />
</div>
</div>
<!-- Title line -->
<USkeleton class="h-5 w-2/3" />
<!-- Content lines -->
<div class="space-y-2">
<USkeleton
v-for="(width, i) in lineWidths"
:key="i"
class="h-3"
:class="width"
/>
</div>
</div>
</UCard>
<!-- List / default variant -->
<div v-else class="space-y-3 animate-pulse">
<div
v-for="(width, i) in lineWidths"
:key="i"
class="flex items-center gap-3"
>
<!-- Optional avatar per line -->
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
<!-- Line content -->
<div class="flex-1 space-y-1">
<USkeleton class="h-3" :class="width" />
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
</div>
</div>
</div>
</template>
@@ -1,53 +0,0 @@
<script setup lang="ts">
const { currentMood, moods, setMood } = useLibreMood()
</script>
<template>
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
<UTooltip
v-for="mood in moods"
:key="mood.id"
:text="mood.label"
>
<button
type="button"
role="radio"
:aria-checked="currentMood === mood.id"
:aria-label="`Ambiance ${mood.label}`"
class="mood-dot"
:class="{ 'mood-dot--active': currentMood === mood.id }"
:style="{ '--dot-color': mood.color }"
@click="setMood(mood.id)"
/>
</UTooltip>
</div>
</template>
<style scoped>
.mood-switcher {
display: flex;
align-items: center;
gap: 0.375rem;
}
.mood-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid transparent;
background: var(--dot-color, var(--mood-text-muted));
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.mood-dot:hover {
transform: scale(1.25);
}
.mood-dot--active {
border-color: var(--mood-text);
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
transform: scale(1.15);
}
</style>
@@ -1,95 +0,0 @@
<script setup lang="ts">
/**
* Offline detection banner.
*
* Uses navigator.onLine and online/offline events to detect
* network connectivity changes. Shows a warning banner when
* offline and a brief success message when back online.
*/
const isOnline = ref(true)
const showReconnected = ref(false)
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
function handleOffline() {
isOnline.value = false
showReconnected.value = false
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
reconnectedTimer = null
}
}
function handleOnline() {
isOnline.value = true
showReconnected.value = true
// Show "reconnected" message briefly then hide
reconnectedTimer = setTimeout(() => {
showReconnected.value = false
reconnectedTimer = null
}, 3000)
}
onMounted(() => {
isOnline.value = navigator.onLine
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
}
})
</script>
<template>
<Transition name="slide-down">
<div
v-if="!isOnline"
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
role="alert"
>
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
</div>
</div>
</Transition>
<Transition name="slide-down">
<div
v-if="showReconnected && isOnline"
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
role="status"
>
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
<span>Connexion retablie</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
opacity: 0;
}
.slide-down-enter-to,
.slide-down-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>
@@ -1,73 +0,0 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
status: string
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
clickable?: boolean
active?: boolean
}>(), {
clickable: true,
active: false,
})
const emit = defineEmits<{
click: []
}>()
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
// Universal statuses
draft: { label: 'En prepa', cssClass: 'status-prepa' },
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
closed: { label: 'Clos', cssClass: 'status-clos' },
// Decision/vote specific
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
review: { label: 'En prepa', cssClass: 'status-prepa' },
voting: { label: 'En vote', cssClass: 'status-vote' },
open: { label: 'En vote', cssClass: 'status-vote' },
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
// Version specific
pending: { label: 'En prepa', cssClass: 'status-prepa' },
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
rejected: { label: 'Clos', cssClass: 'status-clos' },
// Mandate specific
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
investiture: { label: 'En vote', cssClass: 'status-vote' },
revoked: { label: 'Clos', cssClass: 'status-clos' },
completed: { label: 'Clos', cssClass: 'status-clos' },
archived: { label: 'Clos', cssClass: 'status-clos' },
reporting: { label: 'En vote', cssClass: 'status-vote' },
}
const resolved = computed(() => {
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
})
function handleClick() {
if (props.clickable) {
emit('click')
}
}
</script>
<template>
<button
v-if="clickable"
type="button"
class="status-pill"
:class="[resolved.cssClass, { active: active }]"
@click="handleClick"
>
{{ resolved.label }}
</button>
<span
v-else
class="status-pill"
:class="[resolved.cssClass]"
>
{{ resolved.label }}
</span>
</template>
@@ -1,183 +0,0 @@
<script setup lang="ts">
/**
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
*/
export interface ToolboxAction {
label: string
icon?: string
to?: string
emit?: string
primary?: boolean
}
const props = withDefaults(
defineProps<{
title: string
bullets?: string[]
actions?: ToolboxAction[]
defaultOpen?: boolean
}>(),
{
bullets: undefined,
actions: undefined,
defaultOpen: false,
},
)
const emit = defineEmits<{
action: [actionEmit: string]
}>()
const open = ref(props.defaultOpen)
const defaultActions: ToolboxAction[] = [
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
]
const resolvedActions = computed(() => props.actions ?? defaultActions)
function handleAction(action: ToolboxAction) {
if (action.to) {
navigateTo(action.to)
}
else if (action.emit) {
emit('action', action.emit)
}
}
</script>
<template>
<div class="vignette" :class="{ 'vignette--open': open }">
<button class="vignette__header" @click="open = !open">
<h4 class="vignette__title">{{ title }}</h4>
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
</button>
<div v-show="open" class="vignette__content">
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
</ul>
<div class="vignette__actions">
<button
v-for="action in resolvedActions"
:key="action.label"
class="vignette__btn"
:class="{ 'vignette__btn--primary': action.primary }"
@click="handleAction(action)"
>
<UIcon v-if="action.icon" :name="action.icon" />
<span>{{ action.label }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.vignette {
background: var(--mood-accent-soft);
border-radius: 12px;
overflow: hidden;
}
.vignette__header {
display: flex;
align-items: center;
width: 100%;
padding: 0.625rem 0.75rem;
cursor: pointer;
text-align: left;
gap: 0.375rem;
transition: background 0.12s ease;
}
.vignette__header:hover {
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
}
.vignette__title {
flex: 1;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
}
.vignette__chevron {
font-size: 0.875rem;
color: var(--mood-text-muted);
opacity: 0.5;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.vignette--open .vignette__chevron {
transform: rotate(180deg);
opacity: 1;
color: var(--mood-accent);
}
.vignette__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 0.75rem 0.625rem;
}
.vignette__bullets {
margin: 0;
padding: 0 0 0 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
list-style-type: disc;
}
.vignette__bullets li::marker {
color: var(--mood-accent);
}
.vignette__actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.125rem;
}
.vignette__btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-surface);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
min-height: 2.25rem;
}
@media (min-width: 640px) {
.vignette__btn {
padding: 0.375rem 0.75rem;
min-height: auto;
}
}
.vignette__btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.vignette__btn:active {
transform: translateY(0);
}
.vignette__btn--primary {
color: var(--mood-accent-text);
background: var(--mood-accent);
}
</style>
@@ -1,88 +0,0 @@
<script setup lang="ts">
import type { Document } from '~/stores/documents'
const props = withDefaults(defineProps<{
documents: Document[]
loading?: boolean
}>(), {
loading: false,
})
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<!-- Loading state -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
</div>
<!-- Empty state -->
<UCard v-else-if="documents.length === 0">
<div class="text-center py-8">
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun document de reference pour le moment</p>
</div>
</UCard>
<!-- Document grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<UCard
v-for="doc in documents"
:key="doc.id"
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="space-y-3">
<!-- Header -->
<div class="flex items-start justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<!-- Type + Version -->
<div class="flex items-center gap-2">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(doc.doc_type) }}
</UBadge>
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
</div>
<!-- Description -->
<p
v-if="doc.description"
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ doc.description }}
</p>
<!-- Footer -->
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1 text-xs text-gray-500">
<UIcon name="i-lucide-list" class="text-sm" />
<span>{{ doc.items_count }} items</span>
</div>
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
</div>
</div>
</UCard>
</div>
</template>
@@ -1,347 +0,0 @@
<script setup lang="ts">
/**
* DocumentPreview — clean "PDF-like" viewer for a reference document.
*
* Two modes:
* - current: shows current_text for every item (document en vigueur)
* - projected: applies latest "vote" or "proposed" version per item,
* highlighting changed clauses (document tel qu'il serait si les
* votes en cours passaient)
*/
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
const props = defineProps<{
document: Document
items: DocumentItem[]
mode: 'current' | 'projected'
versionMap: Record<string, ItemVersion | null>
}>()
const sortedItems = computed(() =>
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
)
const changedCount = computed(() =>
props.mode === 'projected'
? Object.values(props.versionMap).filter(Boolean).length
: 0,
)
function getDisplayText(item: DocumentItem): string {
if (props.mode === 'projected' && props.versionMap[item.id]) {
return props.versionMap[item.id]!.proposed_text
}
return item.current_text
}
function isChanged(item: DocumentItem): boolean {
return props.mode === 'projected' && !!props.versionMap[item.id]
}
function itemTypeLabel(type: string): string {
switch (type) {
case 'clause': return 'Engagement'
case 'rule': return 'Variable'
case 'verification': return 'Application'
case 'preamble': return 'Préambule'
default: return ''
}
}
const today = new Date().toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
</script>
<template>
<div class="doc-preview">
<!-- Document header -->
<div class="doc-preview__header">
<div class="doc-preview__watermark" aria-hidden="true">
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
</div>
<h1 class="doc-preview__title">{{ document.title }}</h1>
<div class="doc-preview__meta">
<span>Version {{ document.version }}</span>
<span class="doc-preview__sep">·</span>
<span>{{ document.items_count }} items</span>
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
<UIcon name="i-lucide-flask-conical" class="text-xs" />
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
</span>
<span v-else class="doc-preview__current-badge">
<UIcon name="i-lucide-circle-check" class="text-xs" />
Texte officiel
</span>
</div>
</div>
<!-- Items -->
<div class="doc-preview__body">
<template v-for="item in sortedItems" :key="item.id">
<!-- Section heading -->
<div v-if="item.item_type === 'section'" class="doc-preview__section">
<h2 class="doc-preview__section-title">
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
{{ item.title || item.current_text }}
</h2>
</div>
<!-- Preamble -->
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
<MarkdownRenderer :content="getDisplayText(item)" />
</div>
<!-- Regular clause / rule / verification -->
<div
v-else
class="doc-preview__item"
:class="{ 'doc-preview__item--changed': isChanged(item) }"
>
<div class="doc-preview__item-head">
<span class="doc-preview__item-pos">{{ item.position }}</span>
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
{{ itemTypeLabel(item.item_type) }}
</span>
<span v-if="isChanged(item)" class="doc-preview__change-chip">
<UIcon name="i-lucide-git-branch" class="text-xs" />
Vote en cours
</span>
</div>
<div class="doc-preview__item-text">
<MarkdownRenderer :content="getDisplayText(item)" />
</div>
</div>
</template>
</div>
<!-- Footer -->
<div class="doc-preview__footer">
<div class="doc-preview__footer-main">
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
</div>
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
Projection non officielle texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
</div>
</div>
</div>
</template>
<style scoped>
.doc-preview {
position: relative;
background: var(--mood-surface);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 3rem);
box-shadow: 0 4px 32px var(--mood-shadow);
line-height: 1.75;
overflow: hidden;
}
/* ── Watermark ── */
.doc-preview__watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: clamp(2rem, 8vw, 5rem);
font-weight: 900;
letter-spacing: 0.15em;
color: var(--mood-accent);
opacity: 0.03;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
/* ── Header ── */
.doc-preview__header {
text-align: center;
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
}
.doc-preview__title {
font-size: clamp(1.25rem, 3vw, 1.875rem);
font-weight: 900;
color: var(--mood-text);
letter-spacing: -0.03em;
margin-bottom: 0.625rem;
}
.doc-preview__meta {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
flex-wrap: wrap;
}
.doc-preview__sep { opacity: 0.3; }
.doc-preview__proj-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px 0.625rem;
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
color: var(--mood-warning, #d97706);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.doc-preview__current-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px 0.625rem;
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
color: var(--mood-success, #16a34a);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
/* ── Body ── */
.doc-preview__body {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
/* Section heading */
.doc-preview__section {
padding-top: 1.25rem;
padding-bottom: 0.25rem;
}
.doc-preview__section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
padding-bottom: 0.375rem;
}
/* Preamble */
.doc-preview__preamble {
padding: 1rem 1.25rem;
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
border-radius: 12px;
font-style: italic;
font-size: 0.9375rem;
color: var(--mood-text-muted);
}
/* Item */
.doc-preview__item {
padding: 0.875rem 1rem;
border-radius: 12px;
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
transition: background 0.15s;
}
.doc-preview__item--changed {
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
border-left: 3px solid var(--mood-warning, #f59e0b);
}
.doc-preview__item-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
flex-wrap: wrap;
}
.doc-preview__item-pos {
font-size: 0.6875rem;
font-weight: 800;
font-family: monospace;
color: var(--mood-accent);
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
padding: 1px 6px;
border-radius: 6px;
min-width: 2rem;
text-align: center;
}
.doc-preview__item-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.doc-preview__item-type {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
opacity: 0.65;
}
.doc-preview__change-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 1px 0.5rem;
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
color: var(--mood-warning, #d97706);
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 700;
margin-left: auto;
}
.doc-preview__item-text {
font-size: 0.9rem;
color: var(--mood-text);
}
/* ── Footer ── */
.doc-preview__footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.doc-preview__footer-main {
font-size: 0.75rem;
color: var(--mood-text-muted);
font-weight: 600;
}
.doc-preview__footer-note {
font-size: 0.6875rem;
color: var(--mood-text-muted);
font-style: italic;
opacity: 0.7;
}
/* ── Print ── */
@media print {
.doc-preview {
box-shadow: none;
padding: 2rem;
background: white;
color: black;
}
.doc-preview__watermark { opacity: 0.05; }
}
</style>
@@ -1,126 +0,0 @@
<script setup lang="ts">
/**
* DocumentTuto — Quick tutorial overlay explaining how the document works.
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
*/
const open = ref(false)
const steps = [
{
icon: 'i-lucide-infinity',
title: 'Vote permanent',
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
},
{
icon: 'i-lucide-sliders-horizontal',
title: 'Inertie variable',
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
},
{
icon: 'i-lucide-scale',
title: 'Seuil adaptatif',
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
},
{
icon: 'i-lucide-pen-line',
title: 'Contre-propositions',
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
},
{
icon: 'i-lucide-git-branch',
title: 'Dépôt automatique',
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
},
]
</script>
<template>
<div>
<UButton
icon="i-lucide-circle-help"
variant="ghost"
color="neutral"
size="sm"
@click="open = true"
/>
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
<template #content>
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
Comment ça marche ?
</h2>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="open = false"
/>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(step, idx) in steps"
:key="idx"
class="tuto-step"
>
<div class="tuto-step__icon">
<UIcon :name="step.icon" class="text-base" />
</div>
<div class="tuto-step__content">
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
{{ step.title }}
</h4>
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
{{ step.text }}
</p>
</div>
</div>
</div>
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
Référence : formule g1vote
<a
href="https://g1vote-view-237903.pages.duniter.org/"
target="_blank"
rel="noopener"
style="color: var(--mood-accent)"
>
g1vote-view
</a>
</p>
</div>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.tuto-step {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.tuto-step__icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
color: var(--mood-accent);
flex-shrink: 0;
}
.tuto-step__content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
</style>
@@ -1,303 +0,0 @@
<script setup lang="ts">
/**
* EngagementCard — Enhanced item card with inline mini vote board,
* inertia indicator, and action buttons.
*
* Replaces the basic ItemCard for the document detail view.
*/
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
showVoteBoard?: boolean
}>(), {
showActions: false,
showVoteBoard: true,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const isSection = computed(() => props.item.item_type === 'section')
const isPreamble = computed(() => props.item.item_type === 'preamble')
const itemTypeIcon = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'i-lucide-shield-check'
case 'rule': return 'i-lucide-scale'
case 'verification': return 'i-lucide-check-circle'
case 'preamble': return 'i-lucide-scroll-text'
case 'section': return 'i-lucide-layout-list'
default: return 'i-lucide-file-text'
}
})
const itemTypeLabel = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'Engagement'
case 'rule': return 'Variables'
case 'verification': return 'Application'
case 'preamble': return 'Préambule'
case 'section': return 'Titre'
default: return props.item.item_type
}
})
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
const mockVotes = computed(() => {
const order = props.item.sort_order
const pos = props.item.position
// Conseils et bonnes pratiques: varied votes, some non-adopted
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
// Default: well-adopted items
const base = ((order * 7 + 13) % 5) + 8 // 8-12
const against = (order % 3) // 0-2
return { votesFor: base, votesAgainst: against }
})
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<!-- Section header (visual separator, not a card) -->
<div v-if="isSection" class="engagement-section">
<div class="engagement-section__line" />
<div class="engagement-section__content">
<h3 class="engagement-section__title">
{{ item.title }}
</h3>
<p class="engagement-section__text">
{{ item.current_text }}
</p>
<InertiaSlider
:preset="item.inertia_preset"
compact
class="mt-2 max-w-xs"
/>
</div>
</div>
<!-- Regular item card -->
<div
v-else
class="engagement-card"
:class="{
'engagement-card--preamble': isPreamble,
}"
>
<!-- Card header -->
<div class="engagement-card__header" @click="navigateToItem">
<div class="flex items-center gap-2.5 min-w-0">
<div class="engagement-card__position">
{{ item.position }}
</div>
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
<span v-if="item.title" class="engagement-card__title">
{{ item.title }}
</span>
<span class="engagement-card__type-label">
{{ itemTypeLabel }}
</span>
</div>
</div>
<!-- Item text -->
<div class="engagement-card__body" @click="navigateToItem">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Mini vote board -->
<div v-if="showVoteBoard" class="engagement-card__vote">
<MiniVoteBoard
:votes-for="mockVotes.votesFor"
:votes-against="mockVotes.votesAgainst"
:wot-size="7224"
:is-permanent="item.is_permanent_vote"
:inertia-preset="item.inertia_preset"
/>
</div>
<!-- Inertia indicator -->
<div class="engagement-card__inertia">
<InertiaSlider
:preset="item.inertia_preset"
compact
/>
</div>
<!-- Actions -->
<div v-if="showActions" class="engagement-card__actions">
<UButton
label="Proposer une alternative"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
<UButton
label="Voter"
icon="i-lucide-vote"
variant="soft"
color="success"
size="xs"
@click.stop="navigateToItem"
/>
</div>
</div>
</template>
<style scoped>
/* Section separator */
.engagement-section {
display: flex;
gap: 1rem;
padding: 1.5rem 0 0.5rem;
}
.engagement-section__line {
width: 4px;
background: var(--mood-accent);
border-radius: 2px;
flex-shrink: 0;
}
.engagement-section__content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.engagement-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
.engagement-section__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
/* Card */
.engagement-card {
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
transition: box-shadow 0.15s, transform 0.15s;
}
.engagement-card:hover {
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
transform: translateY(-1px);
}
.engagement-card--preamble {
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
}
.engagement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem 0;
cursor: pointer;
}
.engagement-card__position {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.625rem;
padding: 0 0.5rem;
border-radius: 8px;
background: var(--mood-accent);
color: white;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.engagement-card__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.engagement-card__type-label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-accent);
opacity: 0.5;
flex-shrink: 0;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
}
.engagement-card__body {
padding: 0.5rem 1rem 0.625rem;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.5;
color: var(--mood-text);
}
@media (min-width: 640px) {
.engagement-card__body {
font-size: 0.9375rem;
line-height: 1.55;
}
}
.engagement-card__vote {
padding: 0 1rem;
opacity: 0.7;
transform: scale(0.92);
transform-origin: left center;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__vote {
opacity: 1;
}
.engagement-card__inertia {
padding: 0.25rem 1rem 0.5rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__inertia {
opacity: 1;
}
.engagement-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
}
</style>
@@ -1,489 +0,0 @@
<script setup lang="ts">
/**
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
* for a reference document. Main block collapsible, each sub-section independently collapsible.
*/
const props = defineProps<{
genesisJson: string
}>()
const expanded = ref(false)
// Individual section toggles
const sectionOpen = reactive<Record<string, boolean>>({
source: true,
tools: false,
forum: true,
process: false,
contributors: false,
})
function toggleSection(key: string) {
sectionOpen[key] = !sectionOpen[key]
}
interface GenesisData {
source_document: {
title: string
url: string
repo: string
}
reference_tools: Record<string, string>
forum_synthesis: Array<{
title: string
url: string
status: string
posts?: number
}>
formula_trigger: string
contributors: Array<{
name: string
role: string
}>
}
const genesis = computed((): GenesisData | null => {
try {
return JSON.parse(props.genesisJson)
} catch {
return null
}
})
const statusLabel = (status: string) => {
switch (status) {
case 'rejected': return 'Rejetée'
case 'in_progress': return 'En cours'
case 'reference': return 'Référence'
default: return status
}
}
const statusClass = (status: string) => {
switch (status) {
case 'rejected': return 'genesis-status--rejected'
case 'in_progress': return 'genesis-status--progress'
case 'reference': return 'genesis-status--reference'
default: return 'genesis-status--default'
}
}
</script>
<template>
<div v-if="genesis" class="genesis-block">
<!-- Header (always visible) -->
<button
class="genesis-block__header"
@click="expanded = !expanded"
>
<div class="flex items-center gap-3">
<div class="genesis-block__icon">
<UIcon name="i-lucide-file-archive" class="text-lg" />
</div>
<div class="text-left">
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
Bloc de genèse
</h3>
<p class="text-xs genesis-text-muted">
Sources, références et formule de dépôt
</p>
</div>
</div>
<UIcon
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-lg genesis-muted-icon"
/>
</button>
<!-- Expandable content -->
<Transition name="genesis-expand">
<div v-if="expanded" class="genesis-block__body">
<!-- Source document -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('source')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-file-text" />
Document source
</h4>
<UIcon
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.source" class="genesis-section__content">
<div class="genesis-card">
<p class="font-medium text-sm genesis-text">
{{ genesis.source_document.title }}
</p>
<div class="flex flex-col gap-1 mt-2">
<a
:href="genesis.source_document.url"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-external-link" class="text-xs" />
Texte officiel
</a>
<a
:href="genesis.source_document.repo"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
Dépôt git
</a>
</div>
</div>
</div>
</div>
<!-- Reference tools -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('tools')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de référence
</h4>
<UIcon
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.tools" class="genesis-section__content">
<div class="grid grid-cols-2 gap-2">
<a
v-for="(url, name) in genesis.reference_tools"
:key="name"
:href="url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--tool"
>
<span class="text-xs font-semibold capitalize genesis-text">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
</a>
</div>
</div>
</div>
<!-- Forum synthesis -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('forum')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthèse des discussions
</h4>
<UIcon
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.forum" class="genesis-section__content">
<div class="flex flex-col gap-2">
<a
v-for="topic in genesis.forum_synthesis"
:key="topic.url"
:href="topic.url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--forum"
>
<div class="flex items-start justify-between gap-2">
<span class="text-xs font-medium genesis-text">
{{ topic.title }}
</span>
<span
class="genesis-status shrink-0"
:class="statusClass(topic.status)"
>
{{ statusLabel(topic.status) }}
</span>
</div>
<span v-if="topic.posts" class="text-xs genesis-text-muted">
{{ topic.posts }} messages
</span>
</a>
</div>
</div>
</div>
<!-- Formula trigger -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('process')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de dépôt
</h4>
<UIcon
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.process" class="genesis-section__content">
<div class="genesis-card">
<p class="text-xs leading-relaxed genesis-text">
{{ genesis.formula_trigger }}
</p>
</div>
</div>
</div>
<!-- Contributors -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-users" />
Contributeurs
</h4>
<UIcon
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.contributors" class="genesis-section__content">
<div class="flex flex-wrap gap-2">
<div
v-for="c in genesis.contributors"
:key="c.name"
class="genesis-contributor"
>
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.genesis-block {
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
border-radius: 16px;
overflow: hidden;
}
.genesis-block__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.25rem;
cursor: pointer;
background: none;
transition: background 0.15s;
}
.genesis-block__header:hover {
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
}
.genesis-block__header h3 {
color: var(--mood-accent) !important;
}
.genesis-block__header p {
color: var(--mood-text-muted) !important;
}
.genesis-block__icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
color: var(--mood-accent);
}
.genesis-block__body {
padding: 0 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.genesis-section {
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
}
.genesis-section__toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
cursor: pointer;
background: none;
border: none;
transition: background 0.15s;
}
.genesis-section__toggle:hover {
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
}
.genesis-section__title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin: 0;
}
.genesis-section__toggle .text-sm {
color: var(--mood-text-muted) !important;
}
.genesis-section__content {
padding: 0 0.75rem 0.75rem;
}
.genesis-card {
padding: 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-card .font-medium,
.genesis-card .text-xs,
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-card--tool {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--tool .text-xs {
color: var(--mood-text) !important;
}
.genesis-card--tool:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-card--forum {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--forum:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-accent);
text-decoration: none;
font-weight: 500;
}
.genesis-link:hover {
text-decoration: underline;
}
.genesis-contributor {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-contributor .font-semibold {
color: var(--mood-text) !important;
}
.genesis-contributor .text-xs:not(.font-semibold) {
color: var(--mood-text-muted) !important;
}
/* Status badges — palette-aware */
.genesis-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.genesis-status--reference {
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
color: var(--mood-accent);
}
.genesis-status--progress {
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
color: var(--mood-warning);
}
.genesis-status--rejected {
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
color: var(--mood-error);
}
.genesis-status--default {
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
color: var(--mood-text-muted);
}
/* Genesis-context text utilities */
.genesis-accent {
color: var(--mood-accent) !important;
}
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-text-muted {
color: var(--mood-text-muted) !important;
}
.genesis-muted-icon {
color: var(--mood-text-muted) !important;
}
/* Expand/collapse transition */
.genesis-expand-enter-active,
.genesis-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.genesis-expand-enter-from,
.genesis-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -1,419 +0,0 @@
<script setup lang="ts">
/**
* Inertia slider — displays the inertia preset level for a section.
* Read-only indicator (voting on the preset uses the standard vote flow).
* In full mode: shows formula diagram with simplified curve visualization.
*/
const props = withDefaults(defineProps<{
preset: string
compact?: boolean
mini?: boolean
}>(), {
compact: false,
mini: false,
})
interface InertiaLevel {
label: string
gradient: number
majority: number
color: string
position: number // 0-100 for slider position
description: string
}
const LEVELS: Record<string, InertiaLevel> = {
low: {
label: 'Remplacement facile',
gradient: 0.1,
majority: 50,
color: '#22c55e',
position: 10,
description: 'Majorité simple suffit, même à faible participation',
},
standard: {
label: 'Inertie pour le remplacement',
gradient: 0.2,
majority: 50,
color: '#3b82f6',
position: 37,
description: 'Équilibre : consensus croissant avec la participation',
},
high: {
label: 'Remplacement difficile',
gradient: 0.4,
majority: 60,
color: '#f59e0b',
position: 63,
description: 'Forte mobilisation et super-majorité requises',
},
very_high: {
label: 'Remplacement très difficile',
gradient: 0.6,
majority: 66,
color: '#ef4444',
position: 90,
description: 'Quasi-unanimité requise à toute participation',
},
}
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
// Generate SVG curve points for the inertia function
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
const curvePath = computed(() => {
const G = level.value.gradient
const M = level.value.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps // T/W ratio 0..1
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return `M ${points.join(' L ')}`
})
// The 4 curve paths for the diagram overlay
const allCurves = computed(() => {
return Object.entries(LEVELS).map(([key, lvl]) => {
const G = lvl.gradient
const M = lvl.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return {
key,
color: lvl.color,
path: `M ${points.join(' L ')}`,
active: key === props.preset,
}
})
})
</script>
<template>
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
<!-- Slider track -->
<div class="inertia__track">
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
<div
class="inertia__thumb"
:style="{ left: `${level.position}%`, borderColor: level.color }"
/>
<!-- Level marks -->
<div
v-for="(lvl, key) in LEVELS"
:key="key"
class="inertia__mark"
:class="{ 'inertia__mark--active': key === preset }"
:style="{ left: `${lvl.position}%` }"
/>
</div>
<!-- Label row -->
<div v-if="mini" class="inertia__info">
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
Inertie
</span>
</div>
<div v-else class="inertia__info">
<span class="inertia__label" :style="{ color: level.color }">
{{ level.label }}
</span>
<span v-if="!compact" class="inertia__params">
G={{ level.gradient }} M={{ level.majority }}%
</span>
</div>
<!-- Description (not in compact mode) -->
<p v-if="!compact" class="inertia__desc">
{{ level.description }}
</p>
<!-- Formula diagram (not in compact mode) -->
<div v-if="!compact" class="inertia__diagram">
<svg viewBox="0 0 220 100" class="inertia__svg">
<!-- Grid -->
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
<!-- Grid lines -->
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
<!-- Majority line M -->
<line
x1="30"
:y1="10 + (1 - level.majority / 100) * 70"
x2="200"
:y2="10 + (1 - level.majority / 100) * 70"
class="inertia__majority-line"
/>
<text
x="203"
:y="13 + (1 - level.majority / 100) * 70"
class="inertia__axis-label"
style="fill: var(--mood-accent)"
>M={{ level.majority }}%</text>
<!-- Background curves (ghosted) -->
<path
v-for="curve in allCurves"
:key="curve.key"
:d="curve.path"
fill="none"
:stroke="curve.color"
:stroke-width="curve.active ? 0 : 1"
:opacity="curve.active ? 0 : 0.15"
stroke-dasharray="3 3"
/>
<!-- Active curve -->
<path
:d="curvePath"
fill="none"
:stroke="level.color"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- Axis labels -->
<text x="15" y="14" class="inertia__axis-label">100%</text>
<text x="15" y="49" class="inertia__axis-label">50%</text>
<text x="15" y="84" class="inertia__axis-label">0%</text>
<text x="28" y="95" class="inertia__axis-label">0%</text>
<text x="105" y="95" class="inertia__axis-label">50%</text>
<text x="185" y="95" class="inertia__axis-label">100%</text>
<!-- Axis titles -->
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
</svg>
<!-- Simplified formula -->
<div class="inertia__formula">
<span class="inertia__formula-label">Formule :</span>
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
</div>
<div class="inertia__formula-legend">
<span><strong>T</strong> = votes exprimés</span>
<span><strong>W</strong> = taille WoT</span>
<span><strong>M</strong> = majorité cible</span>
<span><strong>G</strong> = gradient d'inertie</span>
</div>
</div>
</div>
</template>
<style scoped>
.inertia {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.inertia--compact {
gap: 0.25rem;
width: fit-content;
}
.inertia--mini {
gap: 0.125rem;
width: fit-content;
min-width: 3rem;
}
.inertia--mini .inertia__track {
height: 3px;
}
.inertia--mini .inertia__thumb {
width: 8px;
height: 8px;
border-width: 2px;
}
.inertia__track {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
}
.inertia--compact .inertia__track {
height: 4px;
}
.inertia__fill {
position: absolute;
inset: 0;
right: auto;
border-radius: 3px;
transition: width 0.3s ease;
}
.inertia__thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--mood-bg);
border: 3px solid;
transition: left 0.3s ease;
z-index: 2;
}
.inertia--compact .inertia__thumb {
width: 10px;
height: 10px;
border-width: 2px;
}
.inertia__mark {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
z-index: 1;
}
.inertia__mark--active {
background: transparent;
}
.inertia__info {
display: flex;
align-items: center;
justify-content: space-between;
}
.inertia__label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia--compact .inertia__label {
font-size: 0.625rem;
}
.inertia__label--mini {
font-size: 0.5625rem;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.inertia__params {
font-size: 0.625rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.inertia__desc {
font-size: 0.6875rem;
color: var(--mood-text-muted);
line-height: 1.3;
}
/* Diagram */
.inertia__diagram {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inertia__svg {
width: 100%;
max-width: 320px;
height: auto;
}
.inertia__axis {
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
stroke-width: 1;
}
.inertia__grid {
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
stroke-width: 0.5;
stroke-dasharray: 2 4;
}
.inertia__majority-line {
stroke: var(--mood-accent);
stroke-width: 0.75;
stroke-dasharray: 4 3;
opacity: 0.5;
}
.inertia__axis-label {
font-size: 5px;
fill: var(--mood-text-muted);
font-family: monospace;
}
.inertia__axis-title {
font-size: 5px;
fill: var(--mood-text-muted);
font-weight: 600;
}
.inertia__formula {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.inertia__formula-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.inertia__formula-code {
font-size: 0.6875rem;
font-family: monospace;
color: var(--mood-text);
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
}
.inertia__formula-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.5625rem;
color: var(--mood-text-muted);
}
.inertia__formula-legend strong {
color: var(--mood-text);
font-weight: 700;
}
</style>
@@ -1,89 +0,0 @@
<script setup lang="ts">
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
}>(), {
showActions: false,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const itemTypeLabel = (itemType: string): string => {
switch (itemType) {
case 'clause': return 'Clause'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Section'
default: return itemType
}
}
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateToItem"
>
<div class="space-y-3">
<!-- Item header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UBadge variant="solid" color="primary" size="xs">
{{ item.position }}
</UBadge>
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(item.item_type) }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<UBadge
v-if="item.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
<UBadge
v-else
color="neutral"
variant="subtle"
size="xs"
>
Pas de vote
</UBadge>
</div>
</div>
<!-- Item text -->
<div class="pl-2">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Actions -->
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,95 +0,0 @@
<script setup lang="ts">
import type { ItemVersion } from '~/stores/documents'
const props = defineProps<{
version: ItemVersion
}>()
const emit = defineEmits<{
accept: [versionId: string]
reject: [versionId: string]
}>()
const auth = useAuthStore()
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateAddress(address: string | null): string {
if (!address) return 'Inconnu'
if (address.length <= 16) return address
return address.slice(0, 8) + '...' + address.slice(-6)
}
</script>
<template>
<UCard>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<StatusBadge :status="version.status" type="version" />
<span class="text-sm text-gray-500">
Propose par
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
{{ truncateAddress(version.proposed_by) }}
</span>
</span>
</div>
<span class="text-xs text-gray-400">
{{ formatDate(version.created_at) }}
</span>
</div>
<!-- Rationale -->
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
</div>
<!-- Diff view -->
<div v-if="version.diff">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
<DiffView :diff="version.diff" />
</div>
<!-- Proposed text (fallback if no diff) -->
<div v-else-if="version.proposed_text">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
<MarkdownRenderer :content="version.proposed_text" />
</div>
</div>
<!-- Actions for authenticated users -->
<div
v-if="auth.isAuthenticated && version.status === 'proposed'"
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
>
<UButton
label="Accepter"
icon="i-lucide-check"
color="success"
variant="soft"
size="xs"
@click="emit('accept', version.id)"
/>
<UButton
label="Rejeter"
icon="i-lucide-x"
color="error"
variant="soft"
size="xs"
@click="emit('reject', version.id)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,240 +0,0 @@
<script setup lang="ts">
/**
* MiniVoteBoard — compact inline vote status for an engagement item.
*
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
* Uses mock data when no vote session is linked (dev mode).
*/
import { useVoteFormula } from '~/composables/useVoteFormula'
const props = withDefaults(defineProps<{
votesFor?: number
votesAgainst?: number
wotSize?: number
isPermanent?: boolean
inertiaPreset?: string
startsAt?: string | null
endsAt?: string | null
}>(), {
votesFor: 0,
votesAgainst: 0,
wotSize: 7224,
isPermanent: true,
inertiaPreset: 'standard',
startsAt: null,
endsAt: null,
})
const { computeThreshold } = useVoteFormula()
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
}
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
const threshold = computed(() => {
if (totalVotes.value === 0) return 1
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
})
const isPassing = computed(() => props.votesFor >= threshold.value)
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
const thresholdPct = computed(() => {
if (totalVotes.value === 0) return 50
return Math.min((threshold.value / totalVotes.value) * 100, 100)
})
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
const remaining = computed(() => {
const diff = threshold.value - props.votesFor
return diff > 0 ? diff : 0
})
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<div class="mini-board">
<!-- Vote type + status on same line -->
<div class="mini-board__header">
<div class="flex items-center gap-2 flex-wrap">
<template v-if="isPermanent">
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
</template>
<template v-else>
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
</span>
</template>
<UBadge
:color="isPassing ? 'success' : 'warning'"
:variant="isPassing ? 'solid' : 'subtle'"
size="xs"
>
{{ isPassing ? 'Adopté' : 'En attente' }}
</UBadge>
</div>
</div>
<!-- Progress bar -->
<div class="mini-board__bar">
<div
class="mini-board__bar-for"
:style="{ width: `${forPct}%` }"
/>
<div
class="mini-board__bar-against"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="mini-board__bar-threshold"
:style="{ left: `${thresholdPct}%` }"
/>
</div>
<!-- Stats row -->
<div class="mini-board__stats">
<div class="flex items-center gap-3">
<span class="mini-board__stat mini-board__stat--for">
{{ votesFor }} pour
</span>
<span class="mini-board__stat mini-board__stat--against">
{{ votesAgainst }} contre
</span>
</div>
<div class="flex items-center gap-3">
<span class="mini-board__stat">
{{ votesFor }}/{{ threshold }} requis
</span>
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Participation -->
<div class="mini-board__footer">
<span class="text-xs" style="color: var(--mood-text-muted)">
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
</div>
</div>
</template>
<style scoped>
.mini-board {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
}
.mini-board__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mini-board__bar {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
overflow: visible;
}
.mini-board__bar-for {
position: absolute;
inset: 0;
right: auto;
background: #22c55e;
border-radius: 3px 0 0 3px;
transition: width 0.4s ease;
}
.mini-board__bar-against {
position: absolute;
inset: 0;
right: auto;
background: #ef4444;
transition: width 0.4s ease, left 0.4s ease;
}
.mini-board__bar-threshold {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
background: #facc15;
border-radius: 1px;
transform: translateX(-50%);
transition: left 0.4s ease;
z-index: 2;
}
.mini-board__stats {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.25rem;
}
.mini-board__stat {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.mini-board__stat--for {
color: #22c55e;
}
.mini-board__stat--against {
color: #ef4444;
}
.mini-board__stat--remaining {
color: #f59e0b;
}
.mini-board__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -1,243 +0,0 @@
<script setup lang="ts">
/**
* Interactive formula parameter editor.
*
* Provides sliders and inputs for adjusting all formula parameters,
* emitting the updated config on each change.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
modelValue: FormulaConfig
}>()
const emit = defineEmits<{
'update:modelValue': [value: FormulaConfig]
}>()
/** Local reactive copy to avoid direct prop mutation. */
const local = reactive({ ...props.modelValue })
/** Sync incoming prop changes. */
watch(() => props.modelValue, (newVal) => {
Object.assign(local, newVal)
}, { deep: true })
/** Emit on any local change. */
watch(local, () => {
emit('update:modelValue', { ...local })
}, { deep: true })
/** Track optional fields. */
const showSmith = ref(local.smith_exponent !== null)
const showTechcomm = ref(local.techcomm_exponent !== null)
const showNuancedMin = ref(local.nuanced_min_participants !== null)
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
watch(showSmith, (v) => {
local.smith_exponent = v ? 0.5 : null
})
watch(showTechcomm, (v) => {
local.techcomm_exponent = v ? 0.5 : null
})
watch(showNuancedMin, (v) => {
local.nuanced_min_participants = v ? 10 : null
})
watch(showNuancedThreshold, (v) => {
local.nuanced_threshold_pct = v ? 66 : null
})
interface ParamDef {
key: string
label: string
description: string
type: 'input' | 'slider'
min: number
max: number
step: number
}
const mainParams: ParamDef[] = [
{
key: 'duration_days',
label: 'Duree (jours)',
description: 'Duree du vote en jours',
type: 'input',
min: 1,
max: 365,
step: 1,
},
{
key: 'majority_pct',
label: 'Majorite (%)',
description: 'Ratio de majorite cible a haute participation',
type: 'slider',
min: 0,
max: 100,
step: 1,
},
{
key: 'base_exponent',
label: 'Exposant de base (B)',
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
type: 'slider',
min: 0.01,
max: 1.0,
step: 0.01,
},
{
key: 'gradient_exponent',
label: 'Gradient d\'inertie (G)',
description: 'Controle la vitesse de transition vers la majorite simple',
type: 'slider',
min: 0.01,
max: 2.0,
step: 0.01,
},
{
key: 'constant_base',
label: 'Constante de base (C)',
description: 'Plancher fixe de votes requis',
type: 'input',
min: 0,
max: 100,
step: 1,
},
]
</script>
<template>
<div class="space-y-6">
<!-- Main parameters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ param.label }}
</label>
<span class="text-sm font-mono font-bold text-primary">
{{ (local as any)[param.key] }}
</span>
</div>
<template v-if="param.type === 'slider'">
<URange
v-model="(local as any)[param.key]"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<template v-else>
<UInput
v-model.number="(local as any)[param.key]"
type="number"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<p class="text-xs text-gray-500">{{ param.description }}</p>
</div>
</div>
<!-- Optional parameters -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Parametres optionnels
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Smith exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showSmith" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere Smith (S)
</label>
</div>
<template v-if="showSmith && local.smith_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant Smith</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
</div>
<URange
v-model="local.smith_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
</template>
</div>
<!-- TechComm exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showTechcomm" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere TechComm (T)
</label>
</div>
<template v-if="showTechcomm && local.techcomm_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant TechComm</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
</div>
<URange
v-model="local.techcomm_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
</template>
</div>
<!-- Nuanced min participants -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedMin" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Participants minimum (nuance)
</label>
</div>
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
<UInput
v-model.number="local.nuanced_min_participants"
type="number"
:min="1"
:max="1000"
:step="1"
/>
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
</template>
</div>
<!-- Nuanced threshold pct -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedThreshold" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Seuil nuance (%)
</label>
</div>
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
</div>
<URange
v-model="local.nuanced_threshold_pct"
:min="50"
:max="100"
:step="1"
/>
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -1,61 +0,0 @@
<script setup lang="ts">
/**
* Display decoded mode params string as labeled badges/chips.
*
* Parses the compact mode params string and renders each parameter
* as a human-readable chip.
*/
const props = defineProps<{
modeParams: string
}>()
interface ParamChip {
code: string
label: string
value: string
color: string
}
const chips = computed((): ParamChip[] => {
if (!props.modeParams) return []
try {
const parsed = parseModeParams(props.modeParams)
const result: ParamChip[] = []
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
if (parsed.constant_base > 0) {
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
}
if (parsed.smith_exponent !== null) {
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
}
if (parsed.techcomm_exponent !== null) {
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
}
return result
} catch {
return []
}
})
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
<UBadge
v-for="chip in chips"
:key="chip.code"
:color="(chip.color as any)"
variant="subtle"
size="xs"
>
{{ chip.label }}: {{ chip.value }}
</UBadge>
</div>
</template>
@@ -1,64 +0,0 @@
<script setup lang="ts">
/**
* Dropdown/select to pick a voting protocol.
*
* Fetches protocols from the store and renders them in a USelect
* with protocol name, mode params, and vote type badge.
*/
import type { VotingProtocol } from '~/stores/protocols'
const props = defineProps<{
modelValue: string | null
voteType?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const protocols = useProtocolsStore()
onMounted(async () => {
if (protocols.protocols.length === 0) {
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
}
})
const filteredProtocols = computed(() => {
if (!props.voteType) return protocols.protocols
return protocols.protocols.filter(p => p.vote_type === props.voteType)
})
const options = computed(() => {
return filteredProtocols.value.map(p => ({
label: buildLabel(p),
value: p.id,
}))
})
function buildLabel(p: VotingProtocol): string {
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
const params = p.mode_params ? ` [${p.mode_params}]` : ''
return `${p.name} - ${typeLabel}${params}`
}
function onSelect(value: string) {
emit('update:modelValue', value || null)
}
</script>
<template>
<div>
<USelect
:model-value="modelValue ?? undefined"
:items="options"
placeholder="Selectionnez un protocole..."
:loading="protocols.loading"
value-key="value"
@update:model-value="onSelect"
/>
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
{{ protocols.error }}
</p>
</div>
</template>
@@ -1,33 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
txHash: string | null
block: number | null
}>()
const truncatedHash = computed(() => {
if (!props.txHash) return null
if (props.txHash.length <= 20) return props.txHash
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
})
</script>
<template>
<template v-if="txHash">
<div class="inline-flex items-center gap-2">
<div class="flex items-center gap-1.5">
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncatedHash }}
</span>
</div>
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
Bloc #{{ block.toLocaleString('fr-FR') }}
</UBadge>
</div>
</template>
<template v-else>
<UBadge color="warning" variant="subtle" size="xs">
Non ancre
</UBadge>
</template>
</template>
@@ -1,38 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
cid: string | null
}>()
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
const truncatedCid = computed(() => {
if (!props.cid) return null
if (props.cid.length <= 20) return props.cid
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
})
const gatewayUrl = computed(() => {
if (!props.cid) return null
return `${IPFS_GATEWAY}${props.cid}`
})
</script>
<template>
<template v-if="cid">
<a
:href="gatewayUrl!"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
>
<UIcon name="i-lucide-hard-drive" class="text-sm" />
<span>{{ truncatedCid }}</span>
<UIcon name="i-lucide-external-link" class="text-sm" />
</a>
</template>
<template v-else>
<UBadge color="neutral" variant="subtle" size="xs">
Non disponible
</UBadge>
</template>
</template>
@@ -1,171 +0,0 @@
<script setup lang="ts">
export interface SanctuaryEntryOut {
id: string
entry_type: string
reference_id: string
title: string | null
content_hash: string
ipfs_cid: string | null
chain_tx_hash: string | null
chain_block: number | null
metadata_json: string | null
created_at: string
}
const props = defineProps<{
entry: SanctuaryEntryOut
}>()
const emit = defineEmits<{
verify: [id: string]
}>()
const typeLabel = (entryType: string): string => {
switch (entryType) {
case 'document': return 'Document'
case 'decision': return 'Decision'
case 'vote_result': return 'Resultat de vote'
default: return entryType
}
}
const typeColor = (entryType: string): string => {
switch (entryType) {
case 'document': return 'primary'
case 'decision': return 'success'
case 'vote_result': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateHash(hash: string | null, length: number = 16): string {
if (!hash) return '-'
if (hash.length <= length * 2) return hash
return hash.slice(0, length) + '...' + hash.slice(-8)
}
const copied = ref(false)
async function copyHash() {
try {
await navigator.clipboard.writeText(props.entry.content_hash)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard API not available
}
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/sanctuary/${entry.id}`)"
>
<div class="space-y-4">
<!-- Entry header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h3>
<p class="text-xs text-gray-500">
{{ formatDate(entry.created_at) }}
</p>
</div>
</div>
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
{{ typeLabel(entry.entry_type) }}
</UBadge>
</div>
<!-- Hashes and anchors -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Content hash -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
</div>
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
variant="ghost"
color="neutral"
size="xs"
class="p-0"
@click.stop="copyHash"
/>
</div>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{{ truncateHash(entry.content_hash) }}
</p>
</div>
<!-- IPFS CID -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
</div>
<div @click.stop>
<IPFSLink :cid="entry.ipfs_cid" />
</div>
</div>
<!-- Chain anchor -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
</div>
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
</div>
</div>
<!-- Verification status indicators -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1">
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
</span>
</div>
<div class="flex items-center gap-1">
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
</span>
</div>
</div>
<UButton
label="Verifier"
icon="i-lucide-shield-check"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('verify', entry.id)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,659 +0,0 @@
<script setup lang="ts">
/**
* ContextMapper — Recommandeur de méthode de décision.
* 4 questions de contexte → méthode optimale + justification.
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
*/
interface Option { value: string; label: string; icon: string }
interface Question { id: string; question: string; hint?: string; options: Option[] }
interface MethodRec {
name: string
icon: string
tag: string
tagColor: string
description: string
formula?: string
when: string
pros: string[]
cons: string[]
}
const questions: Question[] = [
{
id: 'urgency',
question: 'Quelle est l\'urgence ?',
hint: 'Le délai disponible avant que la décision soit nécessaire',
options: [
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
],
},
{
id: 'stakes',
question: 'Quel est l\'enjeu ?',
hint: 'L\'impact et la réversibilité de la décision',
options: [
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
],
},
{
id: 'groupSize',
question: 'Taille du groupe ?',
hint: 'Nombre de personnes concernées ou habilitées à voter',
options: [
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
{ value: 'medium', label: '10 100', icon: 'i-lucide-users' },
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
],
},
{
id: 'nature',
question: 'Nature de la décision ?',
hint: 'Le type de compétence principalement sollicité',
options: [
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
],
},
]
const answers = ref<Record<string, string>>({})
const step = ref(0)
const animating = ref(false)
const currentQuestion = computed(() => questions[step.value])
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
const progress = computed(() => (step.value / questions.length) * 100)
function selectAnswer(questionId: string, value: string) {
answers.value = { ...answers.value, [questionId]: value }
if (step.value < questions.length - 1) {
animating.value = true
setTimeout(() => {
step.value++
animating.value = false
}, 160)
}
}
function goBack() {
if (step.value > 0) step.value--
}
function reset() {
answers.value = {}
step.value = 0
}
const recommendation = computed((): MethodRec | null => {
if (!isComplete.value) return null
const { urgency, stakes, groupSize, nature } = answers.value
// Immediate → Advice process (Laloux)
if (urgency === 'immediate') {
return {
name: 'Processus de sollicitation d\'avis',
icon: 'i-lucide-message-circle',
tag: 'Laloux / Teal',
tagColor: 'teal',
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
}
}
// Technical + medium/large → Smith WoT
if (nature === 'technical' && groupSize !== 'small') {
return {
name: 'Vote inertiel WoT + critère Smith',
icon: 'i-lucide-network',
tag: 'G1 standard',
tagColor: 'accent',
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)\nSeuil Smith : ⌈SmithWoT^S⌉',
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
}
}
// Irreversible + large → High threshold WoT
if (stakes === 'irreversible' && groupSize === 'large') {
return {
name: 'Vote inertiel WoT (inertie forte)',
icon: 'i-lucide-shield',
tag: 'G1 renforcé',
tagColor: 'secondary',
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)\nParamètres : M=67%, G=0.3 (inertie forte)',
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
}
}
// Small group → Sociocratic consent
if (groupSize === 'small') {
return {
name: 'Consentement sociocratique',
icon: 'i-lucide-check-circle-2',
tag: 'Sociocracie',
tagColor: 'tertiary',
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
}
}
// Political + medium → WoT majority
if (nature === 'political') {
return {
name: 'Vote majoritaire WoT',
icon: 'i-lucide-vote',
tag: 'G1 standard',
tagColor: 'accent',
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
formula: 'R = C + B^W + (M + (1M)·(1(T/W)^G))·max(0,TC)',
when: 'Décision politique communautaire, participation variable, groupe >10.',
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
cons: ['Durée 7-30j', 'Participation faible possible'],
}
}
// Default: minor/operational
return {
name: 'Advice process + validation légère',
icon: 'i-lucide-thumbs-up',
tag: 'Léger',
tagColor: 'teal',
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
formula: 'Consultation → Décision → Notification (sans vote formel)',
when: 'Décision opérationnelle de faible impact, facilement réversible.',
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
}
})
const emit = defineEmits<{ use: [name: string] }>()
</script>
<template>
<div class="cmap">
<!-- Header -->
<div class="cmap__head">
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
<div>
<h3 class="cmap__title">Choisir une méthode</h3>
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
</div>
</div>
<!-- Result -->
<Transition name="fade-up" mode="out-in">
<div v-if="isComplete" key="result" class="cmap__result">
<div class="cmap__result-header">
<div class="cmap__result-icon">
<UIcon :name="recommendation!.icon" />
</div>
<div class="cmap__result-info">
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
{{ recommendation!.tag }}
</span>
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
</div>
</div>
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
<div v-if="recommendation!.formula" class="cmap__formula">
<span class="cmap__formula-label">Formule</span>
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
</div>
<div class="cmap__pros-cons">
<div>
<span class="cmap__pros-label">Pour</span>
<ul class="cmap__list cmap__list--pro">
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
</ul>
</div>
<div>
<span class="cmap__cons-label">Contre</span>
<ul class="cmap__list cmap__list--con">
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
</ul>
</div>
</div>
<p class="cmap__when">
<UIcon name="i-lucide-lightbulb" />
{{ recommendation!.when }}
</p>
<div class="cmap__result-actions">
<button class="cmap__btn-reset" @click="reset">
<UIcon name="i-lucide-refresh-cw" />
Recommencer
</button>
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
<UIcon name="i-lucide-play" />
Utiliser cette méthode
</button>
</div>
</div>
<!-- Quiz -->
<div v-else key="quiz" class="cmap__quiz">
<!-- Progress -->
<div class="cmap__progress">
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
</div>
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
<!-- Question -->
<Transition name="slide-right" mode="out-in">
<div :key="step" class="cmap__question-block">
<p class="cmap__question">{{ currentQuestion.question }}</p>
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
<div class="cmap__options">
<button
v-for="opt in currentQuestion.options"
:key="opt.value"
class="cmap__option"
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
@click="selectAnswer(currentQuestion.id, opt.value)"
>
<div class="cmap__option-icon">
<UIcon :name="opt.icon" />
</div>
<span class="cmap__option-label">{{ opt.label }}</span>
</button>
</div>
</div>
</Transition>
<button v-if="step > 0" class="cmap__back" @click="goBack">
<UIcon name="i-lucide-chevron-left" />
Retour
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.cmap {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cmap__head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.cmap__head-icon {
font-size: 1.375rem;
color: var(--mood-accent);
flex-shrink: 0;
margin-top: 0.125rem;
}
.cmap__title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.cmap__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
}
/* Progress */
.cmap__progress {
height: 4px;
background: var(--mood-accent-soft);
border-radius: 4px;
overflow: hidden;
}
.cmap__progress-bar {
height: 100%;
background: var(--mood-accent);
border-radius: 4px;
transition: width 0.3s ease;
}
.cmap__step-label {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Question */
.cmap__question-block {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cmap__question {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
line-height: 1.4;
}
.cmap__hint {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.cmap__options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cmap__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
text-align: left;
min-height: 2.75rem;
}
.cmap__option:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px var(--mood-shadow);
}
.cmap__option:active { transform: translateY(0); }
.cmap__option--selected {
background: var(--mood-accent);
}
.cmap__option--selected .cmap__option-icon,
.cmap__option--selected .cmap__option-label {
color: var(--mood-accent-text);
}
.cmap__option-icon {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
flex-shrink: 0;
font-size: 0.875rem;
}
.cmap__option--selected .cmap__option-icon {
background: rgba(255,255,255,0.2);
color: var(--mood-accent-text);
}
.cmap__option-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
.cmap__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: pointer;
padding: 0.375rem 0;
transition: color 0.1s ease;
}
.cmap__back:hover { color: var(--mood-text); }
/* Result */
.cmap__result {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.cmap__result-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.cmap__result-icon {
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1.125rem;
}
.cmap__result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__result-tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 20px;
width: fit-content;
}
.cmap__result-tag--accent {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.cmap__result-tag--teal {
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.cmap__result-tag--secondary {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.cmap__result-tag--tertiary {
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
color: var(--mood-tertiary, var(--mood-accent));
}
.cmap__result-name {
font-size: 0.9375rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
line-height: 1.3;
}
.cmap__result-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.cmap__formula {
background: var(--mood-accent-soft);
border-radius: 10px;
padding: 0.625rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__formula-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
}
.cmap__formula-code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
color: var(--mood-text);
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}
.cmap__pros-cons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.cmap__pros-label,
.cmap__cons-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.25rem;
}
.cmap__pros-label { color: var(--mood-success); }
.cmap__cons-label { color: var(--mood-error); }
.cmap__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cmap__list li {
font-size: 0.6875rem;
color: var(--mood-text-muted);
padding-left: 0.875rem;
position: relative;
line-height: 1.4;
}
.cmap__list--pro li::before {
content: '✓';
position: absolute;
left: 0;
color: var(--mood-success);
font-weight: 700;
font-size: 0.5rem;
top: 0.2em;
}
.cmap__list--con li::before {
content: '·';
position: absolute;
left: 0;
color: var(--mood-error);
font-weight: 700;
}
.cmap__when {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
font-style: italic;
}
.cmap__result-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.cmap__btn-reset {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: var(--mood-accent-soft);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease;
}
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
.cmap__btn-use {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.125rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.cmap__btn-use:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* Transitions */
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
</style>
@@ -1,666 +0,0 @@
<script setup lang="ts">
/**
* SocioElection — Guide processus d'élection sociocratique.
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
*/
interface Step {
num: number
title: string
actor: string
duration: string
icon: string
description: string
tips: string[]
pitfall?: string
}
const steps: Step[] = [
{
num: 1,
title: 'Clarifier le rôle',
actor: 'Facilitateur + cercle',
duration: '10-15 min',
icon: 'i-lucide-clipboard-list',
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
tips: [
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
'Fixer une durée standard (ex: 1 an renouvelable)',
'Identifier les compétences nécessaires — pas souhaitables',
],
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
},
{
num: 2,
title: 'Nommer en silence',
actor: 'Tous les membres',
duration: '3-5 min',
icon: 'i-lucide-pencil',
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
tips: [
'Pas de discussion pendant cette étape',
'S\'auto-nommer est bienvenu et valorisé',
'Une seule nomination par personne',
],
},
{
num: 3,
title: 'Recueillir les nominations',
actor: 'Facilitateur',
duration: '5-10 min',
icon: 'i-lucide-list-checks',
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
tips: [
'Lire nom + raison tels qu\'écrits',
'Le facilitateur lit aussi sa propre nomination',
'Compter et afficher les nominations',
],
},
{
num: 4,
title: 'Argumenter',
actor: 'Chaque membre',
duration: '1-2 min / personne',
icon: 'i-lucide-message-square',
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
tips: [
'1 minute maximum par personne',
'Argumenter pour, pas contre',
'Les candidats s\'expriment aussi brièvement',
],
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
},
{
num: 5,
title: 'Lever les objections',
actor: 'Facilitateur + cercle',
duration: '5-15 min',
icon: 'i-lucide-shield-check',
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
tips: [
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
'Une objection peut mener à reconsidérer une candidature',
'L\'élu·e peut décliner — c\'est légitime',
],
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
},
{
num: 6,
title: 'Célébrer',
actor: 'Tous',
duration: '2-3 min',
icon: 'i-lucide-star',
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
tips: [
'Documenter l\'élection (date, durée, personnes présentes)',
'Annoncer à la communauté au sens large',
'Fixer la prochaine évaluation du rôle',
],
},
]
const expandedStep = ref<number | null>(null)
function toggleStep(num: number) {
expandedStep.value = expandedStep.value === num ? null : num
}
// Advice process (Laloux)
const adviceSteps = [
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
]
// Role clarity framework
interface RoleAxis {
label: string
icon: string
question: string
example: string
}
const roleAxes: RoleAxis[] = [
{
label: 'Mission',
icon: 'i-lucide-target',
question: 'Pourquoi ce rôle existe-t-il ?',
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
},
{
label: 'Domaine',
icon: 'i-lucide-shield',
question: 'Sur quoi a-t-il autorité exclusive ?',
example: 'Configuration des serveurs de forge, rotation des clés',
},
{
label: 'Redevabilités',
icon: 'i-lucide-check-square',
question: 'Quelles activités doit-il assurer ?',
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
},
{
label: 'Durée',
icon: 'i-lucide-calendar',
question: 'Pour combien de temps ?',
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
},
]
const activeTab = ref<'election' | 'advice' | 'role'>('election')
</script>
<template>
<div class="se">
<!-- Tabs -->
<div class="se__tabs">
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'election' }"
@click="activeTab = 'election'"
>
<UIcon name="i-lucide-users" />
Élection
</button>
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'advice' }"
@click="activeTab = 'advice'"
>
<UIcon name="i-lucide-message-circle" />
Conseil
</button>
<button
class="se__tab"
:class="{ 'se__tab--active': activeTab === 'role' }"
@click="activeTab = 'role'"
>
<UIcon name="i-lucide-clipboard-list" />
Rôle
</button>
</div>
<!-- Election sociocratique -->
<div v-if="activeTab === 'election'" class="se__panel">
<p class="se__intro">
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
et le consentement collectif — pas sur la popularité.
</p>
<div class="se__steps">
<div
v-for="s in steps"
:key="s.num"
class="se__step"
:class="{ 'se__step--open': expandedStep === s.num }"
>
<button class="se__step-head" @click="toggleStep(s.num)">
<div class="se__step-num">{{ s.num }}</div>
<div class="se__step-icon">
<UIcon :name="s.icon" />
</div>
<div class="se__step-info">
<span class="se__step-title">{{ s.title }}</span>
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
</div>
<UIcon
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="se__step-toggle"
/>
</button>
<Transition name="expand">
<div v-if="expandedStep === s.num" class="se__step-body">
<p class="se__step-desc">{{ s.description }}</p>
<ul class="se__step-tips">
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
</ul>
<div v-if="s.pitfall" class="se__step-pitfall">
<UIcon name="i-lucide-alert-triangle" />
{{ s.pitfall }}
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Advice process -->
<div v-if="activeTab === 'advice'" class="se__panel">
<div class="se__advice-header">
<span class="se__advice-tag">Laloux / Teal</span>
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
<p class="se__advice-subtitle">
Toute personne peut prendre une décision à condition d'avoir d'abord
consulté les experts et les impactés.
</p>
</div>
<div class="se__advice-steps">
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
<div class="se__advice-dot">
<UIcon :name="as.icon" />
</div>
<span class="se__advice-text">{{ as.text }}</span>
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
</div>
</div>
<div class="se__advice-rule">
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
<div>
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
consulter largement. Mais la décision finale appartient toujours à celui ou
celle qui l'a initiée.
</div>
</div>
<div class="se__advice-when">
<div class="se__advice-when-item se__advice-when-item--yes">
<span class="se__advice-when-label">Adapter pour</span>
<ul>
<li>Décisions urgentes</li>
<li>Rôles bien définis</li>
<li>Culture de confiance</li>
</ul>
</div>
<div class="se__advice-when-item se__advice-when-item--no">
<span class="se__advice-when-label">Éviter si</span>
<ul>
<li>Décision irréversible</li>
<li>Groupe > 100 personnes</li>
<li>Enjeu fondateur</li>
</ul>
</div>
</div>
</div>
<!-- Role clarity -->
<div v-if="activeTab === 'role'" class="se__panel">
<p class="se__intro">
Un rôle bien défini évite les zones grises, les conflits d'autorité
et les mandats flous. Quatre axes suffisent.
</p>
<div class="se__role-axes">
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
<div class="se__role-axis-icon">
<UIcon :name="axis.icon" />
</div>
<div class="se__role-axis-body">
<span class="se__role-axis-label">{{ axis.label }}</span>
<p class="se__role-axis-question">{{ axis.question }}</p>
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
</div>
</div>
</div>
<div class="se__role-tip">
<UIcon name="i-lucide-info" />
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
de gouvernance sans changer la personne qui le tient.</span>
</div>
</div>
</div>
</template>
<style scoped>
.se { display: flex; flex-direction: column; gap: 1rem; }
/* Tabs */
.se__tabs {
display: flex;
gap: 0.25rem;
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 3px;
}
.se__tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-text-muted);
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.se__tab--active {
background: var(--mood-surface);
color: var(--mood-accent);
box-shadow: 0 1px 4px var(--mood-shadow);
}
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
.se__intro {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
/* Steps */
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
.se__step {
background: var(--mood-accent-soft);
border-radius: 12px;
overflow: hidden;
}
.se__step--open { background: var(--mood-surface); }
.se__step-head {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.75rem 0.875rem;
cursor: pointer;
text-align: left;
background: none;
}
.se__step-num {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.6875rem;
font-weight: 800;
}
.se__step-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
font-size: 0.875rem;
}
.se__step-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.se__step-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.se__step-meta {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.se__step-toggle {
color: var(--mood-text-muted);
font-size: 0.875rem;
flex-shrink: 0;
}
.se__step-body {
padding: 0 0.875rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.se__step-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.se__step-tips {
margin: 0;
padding: 0 0 0 1rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
list-style-type: disc;
line-height: 1.6;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.se__step-tips li::marker { color: var(--mood-accent); }
.se__step-pitfall {
display: flex;
align-items: flex-start;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
border-radius: 8px;
font-size: 0.6875rem;
color: var(--mood-error);
line-height: 1.5;
}
/* Advice */
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
.se__advice-tag {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
width: fit-content;
}
.se__advice-title {
font-size: 0.9375rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.se__advice-subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
.se__advice-step {
display: flex;
align-items: flex-start;
gap: 0.625rem;
position: relative;
padding: 0.5rem 0;
}
.se__advice-dot {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
z-index: 1;
}
.se__advice-text {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
padding-top: 0.375rem;
line-height: 1.4;
}
.se__advice-line {
position: absolute;
left: calc(1rem - 1px);
top: calc(0.5rem + 2rem);
width: 2px;
height: calc(100% - 2rem + 0.5rem);
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
}
.se__advice-rule {
display: flex;
gap: 0.625rem;
align-items: flex-start;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
.se__advice-rule strong { color: var(--mood-text); }
.se__advice-when {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.se__advice-when-item {
padding: 0.625rem;
border-radius: 10px;
font-size: 0.75rem;
}
.se__advice-when-item--yes {
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
}
.se__advice-when-item--no {
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
}
.se__advice-when-label {
display: block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.25rem;
}
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
.se__advice-when-item ul {
margin: 0;
padding: 0 0 0 0.875rem;
color: var(--mood-text-muted);
list-style-type: disc;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* Role */
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
.se__role-axis {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 12px;
}
.se__role-axis-icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-accent);
font-size: 0.875rem;
}
.se__role-axis-body { flex: 1; min-width: 0; }
.se__role-axis-label {
display: block;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin-bottom: 0.125rem;
}
.se__role-axis-question {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
margin: 0;
}
.se__role-axis-example {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin: 0.125rem 0 0;
line-height: 1.4;
font-style: italic;
}
.se__role-tip {
display: flex;
gap: 0.5rem;
align-items: flex-start;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
}
/* Expand transition */
.expand-enter-active, .expand-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
max-height: 0;
opacity: 0;
}
.expand-enter-to, .expand-leave-from {
max-height: 500px;
opacity: 1;
}
</style>
@@ -1,90 +0,0 @@
<script setup lang="ts">
/**
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
* Toggle le contenu pour économiser la hauteur visible.
*/
const props = withDefaults(
defineProps<{
title: string
icon?: string
defaultOpen?: boolean
}>(),
{
icon: undefined,
defaultOpen: false,
},
)
const open = ref(props.defaultOpen)
</script>
<template>
<div class="tsection" :class="{ 'tsection--open': open }">
<button class="tsection__header" @click="open = !open">
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
<span class="tsection__title">{{ title }}</span>
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
</button>
<div v-show="open" class="tsection__body">
<slot />
</div>
</div>
</template>
<style scoped>
.tsection {
background: var(--mood-accent-soft);
border-radius: 14px;
overflow: hidden;
}
.tsection__header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 0.875rem;
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.tsection__header:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
}
.tsection__icon {
font-size: 0.9375rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.tsection__title {
flex: 1;
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tsection__chevron {
font-size: 0.875rem;
color: var(--mood-accent);
opacity: 0.6;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tsection--open .tsection__chevron {
transform: rotate(180deg);
}
.tsection__body {
padding: 0 0.875rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
</style>
@@ -1,551 +0,0 @@
<script setup lang="ts">
/**
* WorkflowMilestones — 11 jalons de protocole de fonctionnement.
* Sélectif et qualitatif : ce qui fait la différence entre un protocole
* qui tient et un qui dérive.
* Référence : g1vote, sociocracie, Laloux, Elinor Ostrom (gouvernance des communs).
*/
interface Milestone {
num: number
name: string
icon: string
actor: string
duration: { min: string; standard: string; major: string }
description: string
essential: boolean
tip?: string
ostrom?: string
}
const milestones: Milestone[] = [
{
num: 1,
name: 'Prise d\'initiative',
icon: 'i-lucide-lightbulb',
actor: 'Tout membre',
duration: { min: '—', standard: '1-2j', major: '1-2j' },
description: 'Formaliser l\'intention : quel problème, quel besoin, quelle cible visée. Nommer un·e porteur·euse responsable.',
essential: true,
tip: 'Une initiative sans porteur identifié ne décolle pas. La responsabilité individuelle est le premier jalon.',
ostrom: 'Principe 1 — Frontières claires : qui est concerné, pourquoi.',
},
{
num: 2,
name: 'Processus d\'avis (advice)',
icon: 'i-lucide-message-circle',
actor: 'Porteur + experts + impactés',
duration: { min: '1j', standard: '3-7j', major: '7-14j' },
description: 'Consulter les personnes qui ont l\'expertise ET celles qui seront impactées. Écouter vraiment, intégrer ou expliquer pourquoi on n\'intègre pas.',
essential: true,
tip: 'Ce jalon est souvent escamoté. C\'est la principale cause d\'échec ou de résistance en implémentation.',
ostrom: 'Principe 5 — Résolution des conflits accessible et peu coûteuse.',
},
{
num: 3,
name: 'Rédaction + amendements',
icon: 'i-lucide-file-edit',
actor: 'Porteur + communauté',
duration: { min: '1-2j', standard: '3-7j', major: '7-21j' },
description: 'Rédiger la proposition formelle. Ouvrir une période d\'amendements publics. Intégrer les modifications acceptées, rejeter les autres avec justification.',
essential: true,
tip: 'Distinguer amendements substantiels (re-vote possible) et de forme (porteur décide).',
},
{
num: 4,
name: 'Qualification technique',
icon: 'i-lucide-shield-check',
actor: 'Comité technique (si applicable)',
duration: { min: '—', standard: '2-5j', major: '5-10j' },
description: 'Pour les décisions techniques : revue par les experts désignés. Évaluation de faisabilité, risques, impact. Avis formel (non bloquant, sauf veto défini).',
essential: false,
tip: 'Optionnel selon la nature de la décision. Systématique pour les Runtime Upgrades.',
},
{
num: 5,
name: 'Ouverture du vote',
icon: 'i-lucide-vote',
actor: 'Porteur + plateforme',
duration: { min: '—', standard: '1j', major: '1j' },
description: 'Publier la proposition finale. Notifier la communauté. Ouvrir la session de vote avec les paramètres définis (protocole, formule, durée).',
essential: true,
tip: 'L\'ouverture doit être annoncée à l\'avance (délai de préavis selon règlement).',
},
{
num: 6,
name: 'Phase de vote',
icon: 'i-lucide-bar-chart-2',
actor: 'Membres habilités',
duration: { min: '3j', standard: '7-14j', major: '21-30j' },
description: 'Les membres habilités votent selon le protocole. Seuil de participation minimal surveillé. Résultats intermédiaires visibles (ou non, selon le protocole).',
essential: true,
ostrom: 'Principe 3 — Choix collectifs : ceux qui sont concernés participent aux décisions.',
},
{
num: 7,
name: 'Contrôle du quorum',
icon: 'i-lucide-check-circle',
actor: 'Plateforme + porteur',
duration: { min: '—', standard: '—', major: '—' },
description: 'Vérifier que le quorum minimum est atteint avant clôture. Si non atteint : prolonger, relancer, ou annuler selon les règles préétablies.',
essential: true,
tip: 'Définir à l\'avance le quorum et la procédure si non atteint — évite les ambiguïtés.',
ostrom: 'Principe 4 — Supervision des règles par les membres.',
},
{
num: 8,
name: 'Proclamation des résultats',
icon: 'i-lucide-megaphone',
actor: 'Plateforme + porteur',
duration: { min: '—', standard: '1j', major: '1j' },
description: 'Annoncer le résultat officiel avec les chiffres détaillés (votes pour, contre, abstentions, taux participation, seuil requis). Archiver on-chain si adopté.',
essential: true,
tip: 'La transparence des résultats est aussi importante que le résultat lui-même.',
ostrom: 'Principe 8 — Gouvernance emboîtée : résultats remontés aux niveaux supérieurs.',
},
{
num: 9,
name: 'Mise en application',
icon: 'i-lucide-play-circle',
actor: 'Porteur + implémenteurs',
duration: { min: '—', standard: 'Variable', major: 'Variable' },
description: 'Planifier l\'application effective de la décision. Désigner les responsables. Fixer des jalons d\'implémentation si complexe.',
essential: true,
tip: 'Une décision adoptée mais non implémentée érode la confiance dans le processus.',
},
{
num: 10,
name: 'Suivi et accountability',
icon: 'i-lucide-activity',
actor: 'Porteur + communauté',
duration: { min: '—', standard: 'Continu', major: 'Continu' },
description: 'Rapports réguliers sur l\'avancement. Signalement des écarts. Mécanisme de remontée si la décision produit des effets inattendus.',
essential: false,
tip: 'Intégrer dans le prochain cycle de gouvernance si des ajustements s\'imposent.',
ostrom: 'Principe 4 — Surveillance continue des comportements et résultats.',
},
{
num: 11,
name: 'Rétrospective',
icon: 'i-lucide-rotate-ccw',
actor: 'Cercle concerné',
duration: { min: '—', standard: '1-2h', major: '1-2j' },
description: 'Évaluer : le processus a-t-il bien fonctionné ? La décision produit-elle les effets attendus ? Quoi améliorer pour la prochaine fois ?',
essential: false,
tip: 'La rétrospective est le moteur d\'amélioration du protocole lui-même (méta-gouvernance).',
ostrom: 'Principe 7 — Reconnaissance externe de l\'organisation par des autorités supérieures.',
},
]
const showOstrom = ref(false)
const activeDecisionType = ref<'minor' | 'standard' | 'major'>('standard')
const decisionTypes = [
{ value: 'minor', label: 'Mineur', color: 'teal' },
{ value: 'standard', label: 'Standard', color: 'accent' },
{ value: 'major', label: 'Majeur', color: 'secondary' },
]
const essentialMilestones = computed(() =>
milestones.filter(m => m.essential),
)
const optionalMilestones = computed(() =>
milestones.filter(m => !m.essential),
)
const totalDuration = computed(() => {
const type = activeDecisionType.value
const durations = {
minor: '5-10 jours',
standard: '14-30 jours',
major: '45-90 jours',
}
return durations[type]
})
</script>
<template>
<div class="wm">
<!-- Header -->
<div class="wm__header">
<h3 class="wm__title">Jalons de protocole</h3>
<p class="wm__subtitle">
11 jalons, dont 7 indispensables. Durées recommandées selon le type de décision.
</p>
</div>
<!-- Decision type selector -->
<div class="wm__type-selector">
<button
v-for="dt in decisionTypes"
:key="dt.value"
class="wm__type-btn"
:class="[
`wm__type-btn--${dt.color}`,
{ 'wm__type-btn--active': activeDecisionType === dt.value },
]"
@click="activeDecisionType = dt.value as 'minor' | 'standard' | 'major'"
>
{{ dt.label }}
</button>
<span class="wm__total-duration"> {{ totalDuration }}</span>
</div>
<!-- Essential milestones -->
<div class="wm__section">
<div class="wm__section-label">
<span class="wm__section-badge wm__section-badge--essential">7 essentiels</span>
</div>
<div class="wm__milestones">
<div
v-for="m in essentialMilestones"
:key="m.num"
class="wm__milestone wm__milestone--essential"
>
<div class="wm__milestone-left">
<div class="wm__milestone-num">{{ m.num }}</div>
<div v-if="m.num < milestones.length" class="wm__milestone-line" />
</div>
<div class="wm__milestone-icon">
<UIcon :name="m.icon" />
</div>
<div class="wm__milestone-body">
<div class="wm__milestone-head">
<span class="wm__milestone-name">{{ m.name }}</span>
<span class="wm__milestone-duration">
{{ m.duration[activeDecisionType] || '—' }}
</span>
</div>
<p class="wm__milestone-desc">{{ m.description }}</p>
<div v-if="m.tip" class="wm__milestone-tip">
<UIcon name="i-lucide-lightbulb" />
{{ m.tip }}
</div>
</div>
</div>
</div>
</div>
<!-- Optional milestones -->
<div class="wm__section">
<div class="wm__section-label">
<span class="wm__section-badge wm__section-badge--optional">4 contextuels</span>
</div>
<div class="wm__milestones">
<div
v-for="m in optionalMilestones"
:key="m.num"
class="wm__milestone wm__milestone--optional"
>
<div class="wm__milestone-left">
<div class="wm__milestone-num wm__milestone-num--optional">{{ m.num }}</div>
</div>
<div class="wm__milestone-icon wm__milestone-icon--optional">
<UIcon :name="m.icon" />
</div>
<div class="wm__milestone-body">
<div class="wm__milestone-head">
<span class="wm__milestone-name">{{ m.name }}</span>
<span class="wm__milestone-duration">
{{ m.duration[activeDecisionType] || '—' }}
</span>
</div>
<p class="wm__milestone-desc">{{ m.description }}</p>
</div>
</div>
</div>
</div>
<!-- Ostrom toggle -->
<button class="wm__ostrom-toggle" @click="showOstrom = !showOstrom">
<UIcon name="i-lucide-book-open" />
<span>Principes Ostrom appliqués</span>
<UIcon :name="showOstrom ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
</button>
<Transition name="expand">
<div v-if="showOstrom" class="wm__ostrom">
<p class="wm__ostrom-intro">
Elinor Ostrom (Nobel 2009) a identifié 8 principes pour la gouvernance
durable des communs. Les jalons ci-dessus les incarnent.
</p>
<div class="wm__ostrom-items">
<div
v-for="m in milestones.filter(x => x.ostrom)"
:key="m.num"
class="wm__ostrom-item"
>
<span class="wm__ostrom-jalon">Jalon {{ m.num }}</span>
<span class="wm__ostrom-text">{{ m.ostrom }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.wm { display: flex; flex-direction: column; gap: 1rem; }
.wm__header { display: flex; flex-direction: column; gap: 0.25rem; }
.wm__title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.wm__subtitle {
font-size: 0.8125rem;
color: var(--mood-text-muted);
margin: 0;
line-height: 1.5;
}
/* Type selector */
.wm__type-selector {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.wm__type-btn {
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 700;
border-radius: 20px;
cursor: pointer;
background: var(--mood-accent-soft);
color: var(--mood-text-muted);
transition: all 0.12s ease;
}
.wm__type-btn--accent.wm__type-btn--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.wm__type-btn--teal.wm__type-btn--active {
background: color-mix(in srgb, var(--mood-success) 20%, transparent);
color: var(--mood-success);
}
.wm__type-btn--secondary.wm__type-btn--active {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.wm__total-duration {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
margin-left: auto;
}
/* Section */
.wm__section { display: flex; flex-direction: column; gap: 0.5rem; }
.wm__section-label { display: flex; align-items: center; gap: 0.5rem; }
.wm__section-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 10px;
border-radius: 20px;
}
.wm__section-badge--essential {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.wm__section-badge--optional {
background: color-mix(in srgb, var(--mood-text-muted) 12%, transparent);
color: var(--mood-text-muted);
}
/* Milestones */
.wm__milestones { display: flex; flex-direction: column; gap: 0; }
.wm__milestone {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0;
}
.wm__milestone-left {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 1.375rem;
}
.wm__milestone-num {
width: 1.375rem;
height: 1.375rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
font-size: 0.625rem;
font-weight: 800;
flex-shrink: 0;
z-index: 1;
}
.wm__milestone-num--optional {
background: color-mix(in srgb, var(--mood-text-muted) 20%, transparent);
color: var(--mood-text-muted);
}
.wm__milestone-line {
width: 2px;
flex: 1;
min-height: 1.25rem;
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
margin-top: 2px;
}
.wm__milestone-icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 0.875rem;
}
.wm__milestone-icon--optional {
background: color-mix(in srgb, var(--mood-text-muted) 10%, transparent);
color: var(--mood-text-muted);
}
.wm__milestone-body {
flex: 1;
min-width: 0;
padding-bottom: 0.625rem;
}
.wm__milestone-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.wm__milestone-name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.wm__milestone--optional .wm__milestone-name {
color: var(--mood-text-muted);
}
.wm__milestone-duration {
font-size: 0.625rem;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 1px 6px;
border-radius: 20px;
}
.wm__milestone-desc {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.5;
margin: 0.125rem 0 0;
}
.wm__milestone-tip {
display: flex;
align-items: flex-start;
gap: 0.375rem;
margin-top: 0.375rem;
padding: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
border-radius: 8px;
font-size: 0.6875rem;
color: var(--mood-accent);
line-height: 1.5;
}
/* Ostrom */
.wm__ostrom-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--mood-accent-soft);
border-radius: 10px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
transition: color 0.12s ease;
text-align: left;
}
.wm__ostrom-toggle:hover { color: var(--mood-text); }
.wm__ostrom-toggle .i-lucide-book-open { color: var(--mood-accent); }
.wm__ostrom {
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wm__ostrom-intro {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin: 0;
}
.wm__ostrom-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wm__ostrom-item {
display: flex;
gap: 0.625rem;
font-size: 0.75rem;
line-height: 1.5;
}
.wm__ostrom-jalon {
font-weight: 700;
color: var(--mood-accent);
white-space: nowrap;
flex-shrink: 0;
}
.wm__ostrom-text { color: var(--mood-text-muted); }
/* Expand transition */
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
.expand-enter-to, .expand-leave-from { max-height: 1000px; opacity: 1; }
</style>
@@ -1,110 +0,0 @@
<script setup lang="ts">
/**
* Display vote formula with KaTeX rendering.
*
* Renders the WoT threshold formula using KaTeX when available,
* falling back to a code display. Shows parameter values and
* optional Smith/TechComm criteria formulas.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
formulaConfig: FormulaConfig
showExplanation?: boolean
}>()
const showExplain = ref(props.showExplanation ?? false)
/**
* Render a LaTeX string to HTML using KaTeX, with code fallback.
*/
function renderFormula(tex: string): string {
if (typeof window !== 'undefined' && (window as any).katex) {
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="text-sm font-mono">${tex}</code>`
}
/** Main threshold formula in LaTeX. */
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
/** Smith criterion formula. */
const smithFormulaTeX = computed(() => {
if (props.formulaConfig.smith_exponent === null) return null
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
})
/** TechComm criterion formula. */
const techcommFormulaTeX = computed(() => {
if (props.formulaConfig.techcomm_exponent === null) return null
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
})
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
const parameters = computed(() => [
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
...(props.formulaConfig.smith_exponent !== null ? [{
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
}] : []),
...(props.formulaConfig.techcomm_exponent !== null ? [{
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
}] : []),
])
</script>
<template>
<div class="space-y-4">
<!-- Main formula -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
<div v-html="mainFormulaHtml" class="text-center" />
</div>
<!-- Smith criterion -->
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
<div v-html="smithFormulaHtml" class="text-center" />
</div>
<!-- TechComm criterion -->
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
<div v-html="techcommFormulaHtml" class="text-center" />
</div>
<!-- Parameters grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="param in parameters"
:key="param.code"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
</div>
</div>
<!-- Explanation toggle -->
<div class="flex justify-end">
<UButton
variant="ghost"
color="neutral"
size="xs"
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showExplain = !showExplain"
>
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
</UButton>
</div>
</div>
</template>
@@ -1,114 +0,0 @@
<script setup lang="ts">
/**
* Visual gauge showing votes vs threshold.
*
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
* a vertical threshold marker, participation statistics, and a pass/fail badge.
*/
const props = defineProps<{
votesFor: number
votesAgainst: number
threshold: number
wotSize: number
}>()
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
/** Percentage of "pour" votes relative to total votes. */
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
/** Percentage of "contre" votes relative to total votes. */
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
/** Position of the threshold marker as a percentage of total votes. */
const thresholdPosition = computed(() => {
if (totalVotes.value === 0) return 50
// Threshold as a percentage of total votes
const pct = (props.threshold / totalVotes.value) * 100
return Math.min(pct, 100)
})
/** Whether the vote passes (votes_for >= threshold). */
const isPassing = computed(() => props.votesFor >= props.threshold)
/** Participation rate. */
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
</script>
<template>
<div class="space-y-3">
<!-- Progress bar -->
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<!-- Green fill (pour) -->
<div
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
:style="{ width: `${forPct}%` }"
/>
<!-- Red fill (contre) -->
<div
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
:style="{ left: `${thresholdPosition}%` }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
Seuil
</div>
</div>
<!-- Percentage labels inside the bar -->
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
<span class="flex-1" />
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
</div>
</div>
<!-- Counts and threshold text -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-green-600 dark:text-green-400 font-medium">
{{ votesFor }} pour
</span>
<span class="text-red-600 dark:text-red-400 font-medium">
{{ votesAgainst }} contre
</span>
</div>
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
{{ votesFor }} / {{ threshold }} requis
</div>
</div>
<!-- Participation rate -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span>
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
<!-- Pass/fail badge -->
<UBadge
:color="isPassing ? 'success' : 'error'"
variant="subtle"
size="xs"
>
{{ isPassing ? 'Adopte' : 'Non adopte' }}
</UBadge>
</div>
</div>
</template>