v2 : les 14 écrans — le tour complet de la démocratie d'exercice

- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes,
  Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation)
- Registre + fiche décision (timeline, périmètre premier/second lieu auditable,
  affluence non-ignorable, S'instruire condensé, éléments + cartographie de
  clôture, épreuve du réel, Remettre en question, PV A4, gravure)
- Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité
  avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse,
  Pour moi, Explorer, cristallisation-geste —, élection à départage humain)
- Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des
  formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux
  de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser)
- Onboarding 7 gabarits + Données locales (export/import, attributs, atelier)
- voting→framing gardé (Reformuler d'un réglage figé non cristallisé)
- Pages v1 supprimées (login, documents, mandates, protocols, sanctuary,
  tools, decisions/new) — 342 tests verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 13:47:20 +02:00
co-authored by Claude Fable 5
parent d886302b59
commit e164b5f6c1
96 changed files with 14097 additions and 11777 deletions
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
/**
* /textes/[slug] — le document vivant : sections repliables + scroll-spy
* (sommaire latéral desktop, dropdown mobile), clauses (pastille d'inertie
* câblée, mini-jauge de session réelle, statut), détail dépliable, vue
* projetée, provenance. Spécifique Pacte : valeurs en clair + effet immédiat,
* clause d'inertie protégée, A1 en tête, préambule boussoles.
*/
import type { Clause } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
import {
buildClauseView,
formatInt,
liveClauseGauges,
sectionMeta,
type ClauseSessionGauge,
type ClauseView,
type ClauseViewCtx,
} from '~/components/texts/textsModel'
const route = useRoute()
const store = useCollectiveStore()
const doc = computed(() =>
store.docs.find(d => d.slug === String(route.params.slug)) ?? null)
const isPact = computed(() => doc.value?.role === 'pact')
// ── Clauses & sections (A1 en tête : l'ordre des positions fait foi) ──
const docClauses = computed(() =>
store.clauses
.filter(c => c.docId === doc.value?.id)
.sort((a, b) => a.position - b.position))
interface SectionGroup { tag: string; label: string; icon: string; clauses: Clause[] }
const sections = computed((): SectionGroup[] => {
const groups = new Map<string, Clause[]>()
for (const clause of docClauses.value) {
const list = groups.get(clause.section) ?? []
list.push(clause)
groups.set(clause.section, list)
}
return [...groups.entries()].map(([tag, clauses]) => ({ tag, ...sectionMeta(tag), clauses }))
})
// ── Assemblage des vues de clauses (pur, dans textsModel) ──
const gauges = computed(() => {
const map = new Map<string, ClauseSessionGauge>()
for (const gauge of liveClauseGauges(docClauses.value, store.decisions, store.sessions, store.votes)) {
map.set(gauge.clauseId, gauge)
}
return map
})
const viewCtx = computed((): ClauseViewCtx => ({
versions: store.versions,
decisions: store.decisions,
people: store.people,
protocols: store.protocols,
settings: store.settings,
gauges: gauges.value,
isPact: isPact.value,
memberCount: store.people.length,
}))
const viewByClause = computed(() => new Map<string, ClauseView>(
docClauses.value.map(c => [c.id, buildClauseView(c, viewCtx.value)]),
))
function viewOf(clause: Clause): ClauseView {
return viewByClause.value.get(clause.id) ?? buildClauseView(clause, viewCtx.value)
}
// ── Dépliage clause + repli sections ──
const expandedClauseId = ref<string | null>(null)
function toggleClause(id: string) {
expandedClauseId.value = expandedClauseId.value === id ? null : id
}
const collapsedSections = ref<Record<string, boolean>>({})
watch(sections, (list) => {
if (list.length > 0 && Object.keys(collapsedSections.value).length === 0) {
const map: Record<string, boolean> = {}
list.forEach((s, i) => { map[s.tag] = i >= 2 })
collapsedSections.value = map
}
}, { immediate: true })
// ── Vue projetée : le document si les votes en cours passaient ──
const projected = ref(false)
function projectedContent(clause: Clause): { content: string; changed: boolean } {
const view = viewOf(clause)
const proposed = view.proposed[0]
return proposed
? { content: proposed.version.content, changed: true }
: { content: view.current?.content ?? '', changed: false }
}
const projectedChanges = computed(() =>
docClauses.value.filter(c => viewOf(c).proposed.length > 0).length)
// ── Sommaire + scroll-spy ──
const activeSection = ref<string | null>(null)
let observer: IntersectionObserver | null = null
function observeSections() {
observer?.disconnect()
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) activeSection.value = entry.target.id.replace('sec-', '')
}
}, { rootMargin: '-15% 0px -70% 0px' })
for (const section of sections.value) {
const el = document.getElementById(`sec-${section.tag}`)
if (el) observer.observe(el)
}
}
onMounted(() => nextTick(observeSections))
watch([sections, projected], () => nextTick(observeSections))
onUnmounted(() => observer?.disconnect())
function scrollToSection(tag: string) {
if (collapsedSections.value[tag]) collapsedSections.value[tag] = false
nextTick(() => {
document.getElementById(`sec-${tag}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
})
}
</script>
<template>
<!-- ld-v2 -->
<div class="doc-page">
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet doc-page__back">
<UIcon name="i-lucide-arrow-left" />
Tous les textes
</NuxtLink>
<p v-if="!doc" class="doc-page__missing">
Ce texte est introuvable il a peut-être été remplacé ou retiré.
</p>
<template v-else>
<!-- En-tête -->
<header class="doc-page__header">
<div class="doc-page__badges">
<span v-if="isPact" class="doc-page__pact-badge">{{ PACT_BADGE }}</span>
<span v-else class="doc-page__ref-badge">document de référence</span>
</div>
<h1 class="doc-page__title">{{ doc.title }}</h1>
<p class="doc-page__desc">{{ isPact ? PACT_SUBTITLE : doc.description }}</p>
<div v-if="isPact" class="doc-page__compass">
<UIcon name="i-lucide-compass" />
<span>Autonomie</span><span class="doc-page__compass-dot">·</span>
<span>Équilibre</span><span class="doc-page__compass-dot">·</span>
<span>Liaison</span>
</div>
<div class="doc-page__meta">
<span class="doc-page__meta-chip">{{ formatInt(docClauses.length) }} clauses</span>
<span v-if="gauges.size" class="doc-page__meta-chip doc-page__meta-chip--vote">
{{ gauges.size }} session{{ gauges.size > 1 ? 's' : '' }} en cours
</span>
<button
type="button"
class="doc-page__projected"
:class="{ 'doc-page__projected--on': projected }"
:disabled="projectedChanges === 0"
:title="projectedChanges === 0 ? 'Aucune version proposée sur ce texte' : ''"
@click="projected = !projected"
>
<UIcon name="i-lucide-telescope" />
{{ projected ? 'Revenir au texte en vigueur' : 'Le document si les votes en cours passaient' }}
</button>
</div>
</header>
<div class="doc-page__body">
<!-- Sommaire : latéral en desktop, dropdown en mobile -->
<nav class="doc-page__toc" aria-label="Sommaire">
<select
class="doc-page__toc-select"
:value="activeSection ?? ''"
@change="scrollToSection(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Aller à une section</option>
<option v-for="section in sections" :key="section.tag" :value="section.tag">
{{ section.label }} ({{ section.clauses.length }})
</option>
</select>
<ul class="doc-page__toc-list">
<li v-for="section in sections" :key="section.tag">
<button
type="button"
class="doc-page__toc-item"
:class="{ 'doc-page__toc-item--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" />
<span class="doc-page__toc-label">{{ section.label }}</span>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
</button>
</li>
</ul>
</nav>
<!-- Les sections -->
<div class="doc-page__sections">
<section
v-for="section in sections"
:id="`sec-${section.tag}`"
:key="section.tag"
class="doc-page__section"
>
<button
type="button"
class="doc-page__section-head"
@click="collapsedSections[section.tag] = !collapsedSections[section.tag]"
>
<UIcon :name="section.icon" class="doc-page__section-icon" />
<h2 class="doc-page__section-title">{{ section.label }}</h2>
<span class="doc-page__toc-count">{{ section.clauses.length }}</span>
<UIcon
name="i-lucide-chevron-down"
class="doc-page__chevron"
:class="{ 'doc-page__chevron--open': !collapsedSections[section.tag] }"
/>
</button>
<div v-show="!collapsedSections[section.tag]" class="doc-page__clauses">
<!-- Vue projetée : lecture, votes appliqués -->
<template v-if="projected">
<article
v-for="clause in section.clauses"
:key="clause.id"
class="doc-page__projected-clause"
:class="{ 'doc-page__projected-clause--changed': projectedContent(clause).changed }"
>
<p class="doc-page__clause-head">
<span class="doc-page__clause-code">{{ clause.code }}</span>
<span class="doc-page__clause-name">{{ clause.title }}</span>
<span v-if="projectedContent(clause).changed" class="doc-page__changed-chip">
si le vote passe
</span>
</p>
<MarkdownRenderer
:content="projectedContent(clause).content"
class="doc-page__projected-text"
/>
</article>
</template>
<!-- Vue structurée : la boucle vote texte -->
<template v-else>
<ClauseCard
v-for="clause in section.clauses"
:key="clause.id"
:view="viewOf(clause)"
:expanded="expandedClauseId === clause.id"
@toggle="toggleClause(clause.id)"
/>
</template>
</div>
</section>
<TextProvenance v-if="doc.provenance" :provenance="doc.provenance" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.1rem;
max-width: 64rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
.doc-page__missing { font-size: 0.9375rem; color: var(--mood-text-muted); font-style: italic; }
.doc-page__header { display: flex; flex-direction: column; gap: 0.45rem; }
.doc-page__badges { display: flex; gap: 0.4rem; }
.doc-page__pact-badge,
.doc-page__ref-badge {
font-size: 0.6875rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 3px 10px; border-radius: var(--r-pill);
}
.doc-page__pact-badge { background: var(--mood-accent); color: var(--mood-accent-text); }
.doc-page__ref-badge {
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-page__title {
font-size: clamp(1.35rem, 4.5vw, 1.8rem); font-weight: 800;
letter-spacing: -0.02em; color: var(--mood-text); line-height: 1.2;
}
.doc-page__desc { font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.55; }
.doc-page__compass {
display: inline-flex; align-items: center; gap: 0.45rem; align-self: flex-start;
font-size: 0.8125rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 10%, transparent);
padding: 0.3rem 0.9rem; border-radius: var(--r-pill);
}
.doc-page__compass-dot { opacity: 0.5; }
.doc-page__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.2rem; }
.doc-page__meta-chip {
font-size: 0.75rem; font-weight: 700; padding: 3px 10px; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-page__meta-chip--vote { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.doc-page__projected {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.75rem; font-weight: 700; padding: 4px 12px; border-radius: var(--r-pill);
background: var(--mood-surface); color: var(--mood-text-muted);
box-shadow: var(--shadow-card); cursor: pointer;
}
.doc-page__projected:disabled { opacity: 0.45; cursor: not-allowed; }
.doc-page__projected--on {
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
box-shadow: 0 0 0 2px var(--mood-status-fige);
}
.doc-page__body { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }
@media (min-width: 1024px) { .doc-page__body { grid-template-columns: 15rem 1fr; } }
.doc-page__toc { display: flex; flex-direction: column; gap: 0.5rem; }
.doc-page__toc-select {
padding: 0.55rem 0.8rem;
font-size: 0.875rem;
font-weight: 600;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.doc-page__toc-list { display: none; }
@media (min-width: 1024px) {
.doc-page__toc-select { display: none; }
.doc-page__toc { position: sticky; top: 4.5rem; }
.doc-page__toc-list { display: flex; flex-direction: column; gap: 2px; }
}
.doc-page__toc-item {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.45rem 0.7rem; border-radius: var(--r-input);
font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted);
background: none; cursor: pointer; text-align: left;
}
.doc-page__toc-item:hover { background: var(--mood-accent-soft); color: var(--mood-text); }
.doc-page__toc-item--active { background: var(--mood-accent-soft); color: var(--mood-accent); font-weight: 700; }
.doc-page__toc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-page__toc-count { font-size: 0.6875rem; font-weight: 800; opacity: 0.6; }
.doc-page__sections { display: flex; flex-direction: column; gap: 1.4rem; min-width: 0; }
.doc-page__section { scroll-margin-top: 4.5rem; }
.doc-page__section-head {
display: flex; align-items: center; gap: 0.55rem; width: 100%;
padding: 0.5rem 0; background: none; cursor: pointer;
box-shadow: 0 2px 0 color-mix(in srgb, var(--mood-accent) 14%, transparent);
}
.doc-page__section-icon { color: var(--mood-accent); }
.doc-page__section-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
.doc-page__chevron { margin-left: auto; color: var(--mood-text-muted); transform: rotate(-90deg); transition: transform 0.2s ease; }
.doc-page__chevron--open { transform: rotate(0); }
.doc-page__clauses { display: flex; flex-direction: column; gap: 0.6rem; padding-top: 0.7rem; }
.doc-page__projected-clause { padding: 0.6rem 0.9rem; border-radius: var(--r-input); }
.doc-page__projected-clause--changed { background: color-mix(in srgb, var(--mood-status-fige) 8%, transparent); }
.doc-page__clause-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.doc-page__clause-code {
font-family: monospace; font-size: 0.75rem; font-weight: 800;
color: var(--mood-accent); background: var(--mood-accent-soft);
padding: 2px 8px; border-radius: 7px;
}
.doc-page__clause-name { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.doc-page__changed-chip {
font-size: 0.6875rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill);
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
}
.doc-page__projected-text { font-size: 0.875rem; }
</style>