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>
+159
View File
@@ -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>
+261
View File
@@ -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>