UX: texte valorise, vote discret, inertie visuelle, genese repliable

- EngagementCard: texte agrandi (15-16px), vote board discret (opacity, scale)
- MiniVoteBoard: badge Adopte/En attente apres "Vote permanent :", board compact
- InertiaSlider: labels descriptifs (inertie pour le remplacement), schema SVG
  avec courbe de seuil, formule simplifiee et legende parametres
- GenesisBlock: toggle repliement individuel par section (source, outils,
  forum, processus, contributeurs)
- Votes varies dans Conseils et bonnes pratiques (non-adoptes inclus)
- Seed: Certification responsable → Reciprocite, ordonnancement inertie standard,
  notes variables K1/K2 (vote porte sur l'inclusion, pas les valeurs),
  init_db() dans seed.py pour DB vierge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-03-02 08:52:56 +01:00
parent 62808b974d
commit 0b230483d9
5 changed files with 469 additions and 149 deletions

View File

@@ -46,6 +46,23 @@ const itemTypeLabel = computed(() => {
}
})
// 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}`)
}
@@ -102,8 +119,8 @@ function navigateToItem() {
<!-- Mini vote board -->
<div v-if="showVoteBoard" class="engagement-card__vote">
<MiniVoteBoard
:votes-for="10"
:votes-against="1"
:votes-for="mockVotes.votesFor"
:votes-against="mockVotes.votesAgainst"
:wot-size="7224"
:is-permanent="item.is_permanent_vote"
:inertia-preset="item.inertia_preset"
@@ -227,19 +244,40 @@ function navigateToItem() {
}
.engagement-card__body {
padding: 0.5rem 1rem 0.75rem;
padding: 0.75rem 1rem 1rem;
cursor: pointer;
font-size: 0.8125rem;
line-height: 1.6;
font-size: 0.9375rem;
line-height: 1.7;
color: var(--mood-text);
}
@media (min-width: 640px) {
.engagement-card__body {
font-size: 1rem;
line-height: 1.75;
}
}
.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.5rem 1rem;
padding: 0.375rem 1rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__inertia {
opacity: 1;
}
.engagement-card__actions {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
* for a reference document. Collapsible by default.
* for a reference document. Main block collapsible, each sub-section independently collapsible.
*/
const props = defineProps<{
genesisJson: string
@@ -9,6 +9,19 @@ const props = defineProps<{
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
@@ -88,122 +101,167 @@ const statusLabel = (status: string) => {
<div v-if="expanded" class="genesis-block__body">
<!-- Source document -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-file-text" />
Document source
</h4>
<div class="genesis-card">
<p class="font-medium text-sm" style="color: var(--mood-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" />
Depot git
</a>
<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"
style="color: var(--mood-text-muted)"
/>
</button>
<div v-if="sectionOpen.source" class="genesis-section__content">
<div class="genesis-card">
<p class="font-medium text-sm" style="color: var(--mood-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" />
Depot git
</a>
</div>
</div>
</div>
</div>
<!-- Reference tools -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de reference
</h4>
<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" style="color: var(--mood-text)">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
</a>
<button class="genesis-section__toggle" @click="toggleSection('tools')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de reference
</h4>
<UIcon
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm"
style="color: var(--mood-text-muted)"
/>
</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" style="color: var(--mood-text)">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
</a>
</div>
</div>
</div>
<!-- Forum synthesis -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthese des discussions
</h4>
<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" style="color: var(--mood-text)">
{{ topic.title }}
<button class="genesis-section__toggle" @click="toggleSection('forum')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthese des discussions
</h4>
<UIcon
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm"
style="color: var(--mood-text-muted)"
/>
</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" style="color: var(--mood-text)">
{{ topic.title }}
</span>
<UBadge
:color="statusColor(topic.status)"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ statusLabel(topic.status) }}
</UBadge>
</div>
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
{{ topic.posts }} messages
</span>
<UBadge
:color="statusColor(topic.status)"
variant="subtle"
size="xs"
class="shrink-0"
>
{{ statusLabel(topic.status) }}
</UBadge>
</div>
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
{{ topic.posts }} messages
</span>
</a>
</a>
</div>
</div>
</div>
<!-- Formula trigger -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de depot
</h4>
<div class="genesis-card">
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
{{ genesis.formula_trigger }}
</p>
<button class="genesis-section__toggle" @click="toggleSection('process')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de depot
</h4>
<UIcon
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm"
style="color: var(--mood-text-muted)"
/>
</button>
<div v-if="sectionOpen.process" class="genesis-section__content">
<div class="genesis-card">
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
{{ genesis.formula_trigger }}
</p>
</div>
</div>
</div>
<!-- Contributors -->
<div class="genesis-section">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-users" />
Contributeurs
</h4>
<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" style="color: var(--mood-text)">{{ c.name }}</span>
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
<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"
style="color: var(--mood-text-muted)"
/>
</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" style="color: var(--mood-text)">{{ c.name }}</span>
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
</div>
</div>
</div>
</div>
@@ -250,7 +308,29 @@ const statusLabel = (status: string) => {
padding: 0 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 0.5rem;
}
.genesis-section {
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--mood-accent) 2%, transparent);
}
.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) 5%, transparent);
}
.genesis-section__title {
@@ -262,7 +342,11 @@ const statusLabel = (status: string) => {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin-bottom: 0.5rem;
margin: 0;
}
.genesis-section__content {
padding: 0 0.75rem 0.75rem;
}
.genesis-card {

View File

@@ -2,7 +2,7 @@
/**
* Inertia slider — displays the inertia preset level for a section.
* Read-only indicator (voting on the preset uses the standard vote flow).
* Shows the formula parameters underneath.
* In full mode: shows formula diagram with simplified curve visualization.
*/
const props = withDefaults(defineProps<{
preset: string
@@ -22,40 +22,86 @@ interface InertiaLevel {
const LEVELS: Record<string, InertiaLevel> = {
low: {
label: 'Basse',
label: 'Remplacement facile',
gradient: 0.1,
majority: 50,
color: '#22c55e',
position: 10,
description: 'Facile a remplacer',
description: 'Majorite simple suffit, meme a faible participation',
},
standard: {
label: 'Standard',
label: 'Inertie pour le remplacement',
gradient: 0.2,
majority: 50,
color: '#3b82f6',
position: 37,
description: 'Equilibre participation/consensus',
description: 'Equilibre : consensus croissant avec la participation',
},
high: {
label: 'Haute',
label: 'Remplacement difficile',
gradient: 0.4,
majority: 60,
color: '#f59e0b',
position: 63,
description: 'Forte mobilisation requise',
description: 'Forte mobilisation et super-majorite requises',
},
very_high: {
label: 'Tres haute',
label: 'Remplacement tres difficile',
gradient: 0.6,
majority: 66,
color: '#ef4444',
position: 90,
description: 'Quasi-unanimite requise',
description: 'Quasi-unanimite requise a 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>
@@ -91,6 +137,80 @@ const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standa
<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 exprimes</span>
<span><strong>W</strong> = taille WoT</span>
<span><strong>M</strong> = majorite cible</span>
<span><strong>G</strong> = gradient d'inertie</span>
</div>
</div>
</div>
</template>
@@ -119,14 +239,11 @@ const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standa
.inertia__fill {
position: absolute;
inset: 0;
right: auto;
border-radius: 3px;
transition: width 0.3s ease;
}
.inertia__fill {
right: auto;
}
.inertia__thumb {
position: absolute;
top: 50%;
@@ -189,4 +306,83 @@ const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standa
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>

View File

@@ -81,36 +81,28 @@ function formatDate(d: string): string {
<template>
<div class="mini-board">
<!-- Vote type badge -->
<!-- Vote type + status on same line -->
<div class="mini-board__header">
<div class="flex items-center gap-2">
<UBadge
v-if="isPermanent"
color="primary"
variant="subtle"
size="xs"
>
<UIcon name="i-lucide-infinity" class="mr-1" />
Vote permanent
</UBadge>
<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>
<UBadge color="info" variant="subtle" size="xs">
<UIcon name="i-lucide-clock" class="mr-1" />
Vote temporaire
</UBadge>
<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 ? 'Adopte' : 'En attente' }}
</UBadge>
</div>
<UBadge
:color="isPassing ? 'success' : 'neutral'"
:variant="isPassing ? 'solid' : 'subtle'"
size="xs"
>
{{ isPassing ? 'Adopte' : 'En attente' }}
</UBadge>
</div>
<!-- Progress bar -->
@@ -165,9 +157,9 @@ function formatDate(d: string): string {
.mini-board {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 10px;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
}
@@ -223,7 +215,7 @@ function formatDate(d: string): string {
}
.mini-board__stat {
font-size: 0.6875rem;
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}