- 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>
378 lines
15 KiB
Vue
378 lines
15 KiB
Vue
<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>
|