v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
@@ -0,0 +1,192 @@
<script setup lang="ts">
// <!-- ld-v2 --> Nuancé 6 niveaux — segments en gradient sémantique dérivé des
// custom properties du mood (color-mix error→success, jamais de couleurs crues),
// commentaire obligatoire sous 0-1, histogramme de la distribution en cours.
import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions'
import { NUANCED_LABELS } from '~/lexicon'
import type { Decision, Id, NuancedValue, Protocol, VoteSession } from '~/types/domain'
const props = defineProps<{
decision: Decision
session: VoteSession
protocol: Protocol
secret?: boolean
canAct?: boolean
asPersonId?: Id
}>()
const col = useCollectiveStore()
const store = useDecisionsStore()
const LEVELS: NuancedValue[] = [0, 1, 2, 3, 4, 5]
/** % de success dans le mélange sémantique error→success, par niveau. */
const MIX = [6, 24, 44, 62, 80, 94] as const
function levelColor(level: number): string {
return `color-mix(in oklab, var(--mood-success) ${MIX[level] ?? 50}%, var(--mood-error))`
}
const active = computed(() => store.activeVotes(props.session.id))
const voterId = computed(() => props.asPersonId ?? col.me?.id)
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
const pending = ref<NuancedValue | null>(null)
watch(myVote, (vote) => {
if (pending.value === null && typeof vote?.value === 'number') pending.value = vote.value as NuancedValue
}, { immediate: true })
const comment = ref('')
const error = ref('')
const needsComment = computed(() => pending.value === 0 || pending.value === 1)
const canDeposit = computed(() =>
props.canAct && pending.value !== null && (!needsComment.value || comment.value.trim().length > 0),
)
function deposit() {
if (pending.value === null) return
error.value = ''
const result = store.castVote(props.session.id, {
value: pending.value,
...(comment.value.trim() ? { comment: comment.value.trim() } : {}),
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
})
if ('ok' in result) { error.value = result.reason; return }
comment.value = ''
}
// ── Distribution (recalculée depuis les derniers votes actifs) ──
const counts = computed(() => {
const perLevel = [0, 0, 0, 0, 0, 0]
for (const vote of active.value) {
if (typeof vote.value === 'number') perLevel[vote.value] = (perLevel[vote.value] ?? 0) + 1
}
return perLevel
})
const maxCount = computed(() => Math.max(1, ...counts.value))
const total = computed(() => active.value.length)
const result = computed(() => {
const tallied = store.tally(props.session)
return !('ok' in tallied) && tallied.method === 'nuanced' ? tallied.result : null
})
const thresholdPct = computed(() => props.protocol.formula.nuancedThresholdPct ?? 80)
const comments = computed(() => active.value.filter(v => v.comment?.trim()))
function name(id: Id): string {
return col.people.find(p => p.id === id)?.displayName ?? '—'
}
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card vn">
<!-- La jauge : 6 segments -->
<div class="vn__scale" role="radiogroup" aria-label="Nuance">
<button
v-for="level in LEVELS"
:key="level"
type="button"
class="vn__segment"
:class="{ 'vn__segment--picked': pending === level, 'vn__segment--mine': typeof myVote?.value === 'number' && myVote.value === level }"
:style="{ '--seg': levelColor(level) }"
:disabled="!canAct"
@click="pending = level"
>
<span class="vn__segment-value">{{ level }}</span>
<span class="vn__segment-label">{{ NUANCED_LABELS[level] }}</span>
</button>
</div>
<!-- Commentaire obligatoire sous 0-1 -->
<div v-if="needsComment && session.status === 'open'" class="vn__comment">
<label>Dis pourquoi — un commentaire accompagne toute position négative.</label>
<textarea v-model="comment" rows="2" placeholder="Ton argument…" />
</div>
<div v-if="session.status === 'open'" class="vn__actions">
<button class="ld-btn" type="button" :disabled="!canDeposit" @click="deposit()">
{{ myVote ? 'Remplacer mon vote' : 'Déposer mon vote' }}
</button>
<span v-if="myVote && typeof myVote.value === 'number'" class="vn__mine">
Ta nuance actuelle : {{ NUANCED_LABELS[myVote.value as NuancedValue] }}
</span>
</div>
<p v-if="error" class="vn__error">{{ error }}</p>
<!-- Histogramme des 6 niveaux — la distribution, pas seulement le résultat -->
<div class="vn__histogram" aria-label="Distribution des nuances">
<div v-for="level in LEVELS" :key="level" class="vn__bar-col">
<span class="vn__bar-count">{{ counts[level] }}</span>
<div
class="vn__bar"
:style="{ height: `${4 + ((counts[level] ?? 0) / maxCount) * 64}px`, background: levelColor(level) }"
/>
<span class="vn__bar-label">{{ level }}</span>
</div>
<span class="vn__total">{{ total }} vote{{ total > 1 ? 's' : '' }}</span>
</div>
<!-- Résultat -->
<p v-if="result && total > 0" class="vn__result" :class="{ 'vn__result--closed': session.status === 'closed' }">
{{ result.positive_count }} nuance{{ result.positive_count > 1 ? 's' : '' }} positive{{ result.positive_count > 1 ? 's' : '' }}
sur {{ result.total }} — {{ result.positive_pct.toLocaleString('fr-FR') }} %
(seuil {{ thresholdPct.toLocaleString('fr-FR') }} %).
<template v-if="session.status === 'closed'">
{{ session.outcome === 'adopted' ? 'Le collectif adopte.' : 'Le collectif n’adopte pas.' }}
</template>
</p>
<!-- Les arguments, toujours en liste -->
<ul v-if="comments.length" class="vn__comments">
<li v-for="vote in comments" :key="vote.id">
<span v-if="!secret" class="vn__comment-who">{{ name(vote.voterId) }}</span>
<span class="vn__comment-level" :style="{ color: levelColor(Number(vote.value)) }">
{{ typeof vote.value === 'number' ? NUANCED_LABELS[vote.value as NuancedValue] : '' }}
</span>
<p>{{ vote.comment }}</p>
</li>
</ul>
</section>
</template>
<style scoped>
.vn { padding: 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.vn__scale { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.vn__segment {
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
padding: 0.6rem 0.2rem; min-height: 3.5rem; border-radius: var(--r-input);
background: color-mix(in srgb, var(--seg) 14%, var(--mood-surface));
color: var(--seg); cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.vn__segment:hover:not(:disabled) { transform: translateY(-1px); }
.vn__segment:disabled { cursor: not-allowed; opacity: 0.6; }
.vn__segment--picked { background: var(--seg); color: var(--mood-surface); box-shadow: 0 2px 8px var(--mood-shadow); }
.vn__segment--mine:not(.vn__segment--picked) { box-shadow: inset 0 0 0 2px var(--seg); }
.vn__segment-value { font-size: 1.05rem; font-weight: 800; }
.vn__segment-label { font-size: 0.66rem; font-weight: 700; text-align: center; line-height: 1.15; }
.vn__comment { display: flex; flex-direction: column; gap: 0.35rem; }
.vn__comment label { font-size: 0.8125rem; font-weight: 600; color: var(--mood-warning); }
.vn__comment textarea { padding: 0.55rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
.vn__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; }
.vn__mine { font-size: 0.8125rem; color: var(--mood-text-muted); }
.vn__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
.vn__histogram {
display: flex; align-items: flex-end; gap: 6px;
padding: 0.75rem 0.5rem 0.4rem; background: var(--mood-bg); border-radius: var(--r-input);
}
.vn__bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
.vn__bar { width: 100%; max-width: 44px; border-radius: 4px 4px 2px 2px; opacity: 0.85; transition: height 0.2s ease; }
.vn__bar-count { font-size: 0.75rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__bar-label { font-size: 0.7rem; font-weight: 700; color: var(--mood-text-muted); }
.vn__total { align-self: flex-end; font-size: 0.75rem; color: var(--mood-text-muted); padding: 0 0.25rem 0.35rem; white-space: nowrap; }
.vn__result { margin: 0; font-size: 0.9375rem; }
.vn__result--closed { font-weight: 700; }
.vn__comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.vn__comments li { background: var(--mood-bg); border-radius: var(--r-input); padding: 0.55rem 0.8rem; font-size: 0.875rem; }
.vn__comments p { margin: 0.2rem 0 0; }
.vn__comment-who { font-weight: 700; margin-right: 0.5rem; }
.vn__comment-level { font-weight: 700; font-size: 0.8125rem; }
@media (max-width: 480px) {
.vn__scale { grid-template-columns: repeat(3, 1fr); }
}
</style>