forked from yvv/decision
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:
@@ -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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /textes/formules — l'Atelier des formules : la pédagogie du seuil,
|
||||
* seule maison des lettres W/T/M/B/G/C. Query params ?W=&T=&M=&B=&G=&C=(&S=)
|
||||
* pré-règlent l'établi — les liens « comprendre ce seuil » arrivent ici.
|
||||
*/
|
||||
import { BINARY_DESCRIPTION, BINARY_COST, METHOD_LABELS } from '~/lexicon'
|
||||
import type { AtelierInit } from '~/components/texts/textsModel'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function numParam(name: string): number | undefined {
|
||||
const raw = route.query[name]
|
||||
const value = Number(Array.isArray(raw) ? raw[0] : raw)
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const initial = computed((): AtelierInit => {
|
||||
const init: AtelierInit = {}
|
||||
const W = numParam('W')
|
||||
const T = numParam('T')
|
||||
const M = numParam('M')
|
||||
const B = numParam('B')
|
||||
const G = numParam('G')
|
||||
const C = numParam('C')
|
||||
const S = numParam('S')
|
||||
if (W !== undefined) init.W = W
|
||||
if (T !== undefined) init.T = T
|
||||
if (M !== undefined) init.M = M
|
||||
if (B !== undefined) init.B = B
|
||||
if (G !== undefined) init.G = G
|
||||
if (C !== undefined) init.C = C
|
||||
if (S !== undefined) init.S = S
|
||||
return init
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="atelier">
|
||||
<NuxtLink to="/textes" class="ld-btn ld-btn--quiet atelier__back">
|
||||
<UIcon name="i-lucide-arrow-left" />
|
||||
Tous les textes
|
||||
</NuxtLink>
|
||||
|
||||
<header class="atelier__header">
|
||||
<h1 class="atelier__title">L'Atelier des formules</h1>
|
||||
<p class="atelier__subtitle">
|
||||
La pédagogie du seuil — c'est ici, et seulement ici, que vivent les lettres.
|
||||
Partout ailleurs, l'outil parle français.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- L'établi -->
|
||||
<AtelierFormulaLab :key="JSON.stringify(initial)" :initial="initial" />
|
||||
|
||||
<!-- Le pour/contre, présenté pour ce qu'il est -->
|
||||
<section class="atelier__card ld-card">
|
||||
<div class="atelier__card-head">
|
||||
<span class="atelier__card-icon atelier__card-icon--binary">
|
||||
<UIcon name="i-lucide-scale" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="atelier__card-title">{{ METHOD_LABELS.binary }}</h2>
|
||||
<p class="atelier__card-sub">{{ BINARY_DESCRIPTION }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="atelier__prose">
|
||||
Cette formule inertielle est un héritage de la Toile de Confiance Ğ1 : quand
|
||||
des milliers de membres sont éligibles et que peu s'expriment, elle exige
|
||||
presque l'unanimité ; quand la participation monte, le seuil descend vers la
|
||||
majorité cible. C'est une protection remarquable pour les très grands corps —
|
||||
et un outil d'exception : son coût reste « {{ BINARY_COST }} ».
|
||||
Pour la vie courante d'un collectif, le consentement, le vote nuancé et le
|
||||
réglage collectif font mieux vivre le désaccord.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Le Réglage collectif : la médiane basse expliquée -->
|
||||
<section class="atelier__card ld-card">
|
||||
<div class="atelier__card-head">
|
||||
<span class="atelier__card-icon atelier__card-icon--parametric">
|
||||
<UIcon name="i-lucide-sliders-horizontal" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="atelier__card-title">{{ METHOD_LABELS.parametric }}</h2>
|
||||
<p class="atelier__card-sub">décider au curseur — la médiane basse, expliquée en jouant</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="atelier__prose">
|
||||
Quand la question est un nombre — un montant, un taux, une répartition —
|
||||
voter pour ou contre appauvrit la question. Chacun pose son curseur, et le
|
||||
collectif retient la médiane basse. Bouge les cinq curseurs-votes ci-dessous :
|
||||
tire un vote vers l'extrême, la médiane bouge à peine.
|
||||
</p>
|
||||
<AtelierMedianDemo />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.atelier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
.atelier__back { align-self: flex-start; font-size: 0.8125rem; text-decoration: none; }
|
||||
|
||||
.atelier__header { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.atelier__title {
|
||||
font-size: clamp(1.5rem, 5vw, 1.9rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.atelier__subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.55;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.atelier__card {
|
||||
padding: clamp(1rem, 3vw, 1.4rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.atelier__card-head { display: flex; align-items: center; gap: 0.8rem; }
|
||||
.atelier__card-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--r-icon);
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.atelier__card-icon--binary {
|
||||
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
|
||||
color: var(--mood-secondary);
|
||||
}
|
||||
.atelier__card-icon--parametric {
|
||||
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
|
||||
color: var(--mood-tertiary);
|
||||
}
|
||||
.atelier__card-title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
|
||||
.atelier__card-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
|
||||
.atelier__prose {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /textes — la bibliothèque des documents sous vote permanent.
|
||||
* Pacte épinglé en tête, cartes documents (clauses, sessions en cours,
|
||||
* provenance résumée), lien vers l'Atelier des formules, filtre gravées 井.
|
||||
*/
|
||||
import type { TextDoc } from '~/types/domain'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { PACT_BADGE, PACT_SUBTITLE } from '~/lexicon'
|
||||
import {
|
||||
engravedCount,
|
||||
liveClauseGauges,
|
||||
type ClauseSessionGauge,
|
||||
} from '~/components/texts/textsModel'
|
||||
|
||||
const store = useCollectiveStore()
|
||||
|
||||
interface DocEntry {
|
||||
doc: TextDoc
|
||||
clauseCount: number
|
||||
gauges: ClauseSessionGauge[]
|
||||
engraved: number
|
||||
}
|
||||
|
||||
function entryFor(doc: TextDoc): DocEntry {
|
||||
const clauses = store.clauses.filter(c => c.docId === doc.id)
|
||||
return {
|
||||
doc,
|
||||
clauseCount: clauses.length,
|
||||
gauges: liveClauseGauges(clauses, store.decisions, store.sessions, store.votes),
|
||||
engraved: engravedCount(clauses, store.decisions),
|
||||
}
|
||||
}
|
||||
|
||||
const pactEntry = computed((): DocEntry | null =>
|
||||
store.pactDoc ? entryFor(store.pactDoc) : null)
|
||||
|
||||
const referenceEntries = computed((): DocEntry[] =>
|
||||
store.docs
|
||||
.filter(d => d.id !== store.pactDoc?.id)
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||
.map(entryFor))
|
||||
|
||||
// ── Filtre gravées 井 ──
|
||||
const onlyEngraved = ref(false)
|
||||
const visibleEntries = computed(() =>
|
||||
onlyEngraved.value
|
||||
? referenceEntries.value.filter(entry => entry.engraved > 0)
|
||||
: referenceEntries.value)
|
||||
const pactVisible = computed(() =>
|
||||
pactEntry.value !== null && (!onlyEngraved.value || pactEntry.value.engraved > 0))
|
||||
|
||||
const liveSessionTotal = computed(() =>
|
||||
(pactEntry.value?.gauges.length ?? 0)
|
||||
+ referenceEntries.value.reduce((n, entry) => n + entry.gauges.length, 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="texts-page">
|
||||
<header class="texts-page__header">
|
||||
<div>
|
||||
<h1 class="texts-page__title">Textes</h1>
|
||||
<p class="texts-page__subtitle">la bibliothèque des documents sous vote permanent</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="texts-page__filter"
|
||||
:class="{ 'texts-page__filter--on': onlyEngraved }"
|
||||
@click="onlyEngraved = !onlyEngraved"
|
||||
>
|
||||
<span class="texts-page__filter-seal">井</span>
|
||||
gravées
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="liveSessionTotal > 0" class="texts-page__pulse">
|
||||
<UIcon name="i-lucide-activity" />
|
||||
{{ liveSessionTotal }} session{{ liveSessionTotal > 1 ? 's' : '' }} de vote en cours
|
||||
sur ces textes — le document vit.
|
||||
</p>
|
||||
|
||||
<!-- Le Pacte, épinglé -->
|
||||
<section v-if="pactVisible && pactEntry" class="texts-page__pact">
|
||||
<div class="texts-page__pact-banner">
|
||||
<UIcon name="i-lucide-pin" class="texts-page__pin" />
|
||||
<span class="texts-page__pact-badge">{{ PACT_BADGE }}</span>
|
||||
<span class="texts-page__pact-subtitle">{{ PACT_SUBTITLE }}</span>
|
||||
</div>
|
||||
<TextDocCard
|
||||
:doc="pactEntry.doc"
|
||||
:clause-count="pactEntry.clauseCount"
|
||||
:gauges="pactEntry.gauges"
|
||||
:engraved="pactEntry.engraved"
|
||||
pinned
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Les documents de référence -->
|
||||
<section v-if="visibleEntries.length" class="texts-page__grid">
|
||||
<TextDocCard
|
||||
v-for="entry in visibleEntries"
|
||||
:key="entry.doc.id"
|
||||
:doc="entry.doc"
|
||||
:clause-count="entry.clauseCount"
|
||||
:gauges="entry.gauges"
|
||||
:engraved="entry.engraved"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p v-else-if="onlyEngraved" class="texts-page__empty">
|
||||
Aucun texte gravé pour l'instant — la gravure vient avec les décisions qui comptent.
|
||||
</p>
|
||||
<p v-else-if="!pactEntry" class="texts-page__empty">
|
||||
Aucun texte ici pour l'instant — les documents naissent des décisions.
|
||||
</p>
|
||||
|
||||
<!-- L'Atelier des formules -->
|
||||
<NuxtLink to="/textes/formules" class="texts-page__atelier ld-card ld-card--hover">
|
||||
<span class="texts-page__atelier-icon">
|
||||
<UIcon name="i-lucide-flask-conical" />
|
||||
</span>
|
||||
<span class="texts-page__atelier-text">
|
||||
<span class="texts-page__atelier-title">L'Atelier des formules</span>
|
||||
<span class="texts-page__atelier-sub">
|
||||
la pédagogie du seuil — pourquoi 94 pour quand 120 votent sur 7 224
|
||||
</span>
|
||||
</span>
|
||||
<UIcon name="i-lucide-arrow-right" class="texts-page__atelier-arrow" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.texts-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.texts-page__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.texts-page__title {
|
||||
font-size: clamp(1.5rem, 5vw, 1.9rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
.texts-page__subtitle { font-size: 0.875rem; color: var(--mood-text-muted); margin-top: 2px; }
|
||||
|
||||
.texts-page__filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 1rem;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-text-muted);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.texts-page__filter:hover { transform: translateY(-1px); }
|
||||
.texts-page__filter--on {
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
color: var(--mood-status-vigueur);
|
||||
box-shadow: 0 0 0 2px var(--mood-status-vigueur);
|
||||
}
|
||||
.texts-page__filter-seal { font-size: 1rem; font-weight: 800; }
|
||||
|
||||
.texts-page__pulse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
align-self: flex-start;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-vote);
|
||||
background: var(--mood-status-vote-bg);
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.texts-page__pact { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.texts-page__pact-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.texts-page__pin { color: var(--mood-accent); font-size: 0.95rem; }
|
||||
.texts-page__pact-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
.texts-page__pact-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.texts-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (min-width: 768px) { .texts-page__grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.texts-page__empty {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.texts-page__atelier {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: clamp(0.9rem, 3vw, 1.2rem);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.texts-page__atelier-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--r-icon);
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.texts-page__atelier-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.texts-page__atelier-title { font-size: 1rem; font-weight: 800; color: var(--mood-text); }
|
||||
.texts-page__atelier-sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
.texts-page__atelier-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--mood-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user