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
@@ -0,0 +1,524 @@
<script setup lang="ts">
// <!-- ld-v2 --> L'établi de l'Atelier — porté du FormulaEditor v1 (sliders,
// exposants Forgeron/Comité) et du FormulaDisplay v1 (rendu KaTeX), branché sur
// l'UNIQUE moteur ~/engine (wotThreshold, smithThreshold, techcommThreshold).
// La seule maison des lettres : W, T, M, B, G, C n'apparaissent qu'ici.
import { wotThreshold, smithThreshold, techcommThreshold } from '~/engine'
import { INERTIA_LABELS, thresholdSentence } from '~/lexicon'
import type { AtelierInit } from './textsModel'
import { INERTIA_COLORS, INERTIA_ORDER, INERTIA_PARAMS, formatInt } from './textsModel'
const props = defineProps<{ initial?: AtelierInit }>()
// ── L'état de l'établi ───────────────────────────────────────
const W = ref(Math.max(1, Math.round(props.initial?.W ?? 100)))
const T = ref(Math.max(0, Math.round(props.initial?.T ?? Math.max(1, Math.round(W.value / 3)))))
const M = ref(props.initial?.M ?? 50)
const B = ref(props.initial?.B ?? 0.1)
const G = ref(props.initial?.G ?? 0.2)
const C = ref(props.initial?.C ?? 0)
const withSmith = ref(props.initial?.S !== undefined)
const S = ref(props.initial?.S ?? 0.1)
const smithW = ref(12)
const withTech = ref(false)
const techExp = ref(0.1)
const techW = ref(6)
watch(W, (w) => { if (T.value > w) T.value = w })
/** Cas Forgeron 2026 — un clic : W=7224, T=120 ⇒ 94 pour requis. */
function loadForgeron2026(): void {
W.value = 7224
T.value = 120
M.value = 50
B.value = 0.1
G.value = 0.2
C.value = 0
withSmith.value = true
S.value = 0.1
withTech.value = false
}
// ── Les seuils — source unique ~/engine ──────────────────────
const threshold = computed(() => wotThreshold(W.value, T.value, M.value, B.value, G.value, C.value))
const thresholdPct = computed(() =>
T.value > 0 ? Math.round((threshold.value / T.value) * 100) : null)
const sentence = computed(() =>
T.value > 0 ? thresholdSentence(W.value, T.value, threshold.value) : null)
const smithRequired = computed(() =>
withSmith.value ? smithThreshold(Math.max(1, smithW.value), S.value) : null)
const techRequired = computed(() =>
withTech.value ? techcommThreshold(Math.max(1, techW.value), techExp.value) : null)
// ── KaTeX (chargé globalement — repli <code> sinon) ──────────
const MAIN_TEX = 'Seuil = C + B^{W} + \\left(M + (1-M)\\cdot\\left(1 - \\left(\\tfrac{T}{W}\\right)^{G}\\right)\\right)\\cdot\\max(0,\\,T - C)'
const katexTick = ref(0)
onMounted(() => {
const w = window as unknown as { katex?: unknown }
if (w.katex) return
const timer = setInterval(() => {
if (w.katex) { katexTick.value++; clearInterval(timer) }
}, 200)
setTimeout(() => clearInterval(timer), 6000)
})
function renderTex(tex: string): string {
const w = window as unknown as {
katex?: { renderToString: (t: string, o: object) => string }
}
if (typeof window !== 'undefined' && w.katex) {
return w.katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="lab__tex-fallback">${tex}</code>`
}
const mainFormulaHtml = computed(() => { void katexTick.value; return renderTex(MAIN_TEX) })
const smithFormulaHtml = computed(() => {
void katexTick.value
return withSmith.value ? renderTex(`Seuil_{forgerons} = \\lceil ${smithW.value}^{${S.value}} \\rceil = ${smithRequired.value}`) : null
})
const techFormulaHtml = computed(() => {
void katexTick.value
return withTech.value ? renderTex(`Seuil_{comit\\acute{e}} = \\lceil ${techW.value}^{${techExp.value}} \\rceil = ${techRequired.value}`) : null
})
// ── Table de participation : T croissant sur W donné ─────────
const PARTICIPATION_STEPS = [1, 2, 5, 10, 25, 50, 75, 100]
const participationRows = computed(() => {
const seen = new Set<number>()
return PARTICIPATION_STEPS.flatMap((pct) => {
const votes = Math.max(1, Math.round((W.value * pct) / 100))
if (seen.has(votes)) return []
seen.add(votes)
const required = wotThreshold(W.value, votes, M.value, B.value, G.value, C.value)
return [{
pct,
votes,
required,
ratio: Math.round((required / votes) * 100),
isCurrent: Math.abs(votes - T.value) === Math.min(
...PARTICIPATION_STEPS.map(p => Math.abs(Math.max(1, Math.round((W.value * p) / 100)) - T.value)),
),
}]
})
})
// ── Presets d'inertie comparés (câblage réel du domaine) ─────
const presetRows = computed(() => INERTIA_ORDER.map((preset) => {
const params = INERTIA_PARAMS[preset]
const required = T.value > 0
? wotThreshold(W.value, T.value, params.majorityPct, 0.1, params.gradientExponent, 0)
: 0
return {
preset,
label: INERTIA_LABELS[preset],
color: INERTIA_COLORS[preset],
majorityPct: params.majorityPct,
gradient: params.gradientExponent,
required,
ratio: T.value > 0 ? Math.round((required / T.value) * 100) : 0,
active: params.majorityPct === M.value && params.gradientExponent === G.value,
}
}))
/** Courbes seuil (%) / participation — portées de l'InertiaSlider v1. */
function curvePath(majorityPct: number, gradient: number): string {
const m = majorityPct / 100
const points: string[] = []
for (let i = 0; i <= 40; i++) {
const participation = i / 40
const ratio = m + (1 - m) * (1 - participation ** gradient)
points.push(`${(30 + participation * 170).toFixed(1)},${(10 + (1 - ratio) * 70).toFixed(1)}`)
}
return `M ${points.join(' L ')}`
}
const presetCurves = computed(() => presetRows.value.map(row => ({
...row,
path: curvePath(row.majorityPct, row.gradient),
})))
const currentCurve = computed(() => curvePath(M.value, G.value))
</script>
<template>
<!-- ld-v2 -->
<section class="lab">
<!-- La formule, rendue -->
<div class="lab__formula ld-card">
<div class="lab__tex" v-html="mainFormulaHtml" />
<div class="lab__legend">
<span><strong>W</strong> membres éligibles</span>
<span><strong>T</strong> votes exprimés</span>
<span><strong>M</strong> majorité cible</span>
<span><strong>B</strong> plancher dynamique</span>
<span><strong>G</strong> gradient d'inertie</span>
<span><strong>C</strong> plancher fixe</span>
</div>
</div>
<div class="lab__grid">
<!-- Les réglages -->
<div class="lab__controls ld-card">
<div class="lab__scenario">
<label class="lab__field">
<span class="lab__field-label">W — membres éligibles</span>
<input v-model.number="W" type="number" min="1" max="1000000" class="lab__number">
</label>
<label class="lab__field">
<span class="lab__field-label">T — votes exprimés</span>
<input v-model.number="T" type="number" min="0" :max="W" class="lab__number">
</label>
<button type="button" class="ld-btn ld-btn--ghost lab__forgeron" @click="loadForgeron2026">
<UIcon name="i-lucide-hammer" />
Cas Forgeron 2026
</button>
</div>
<label class="lab__slider">
<span class="lab__slider-head">
<span>M — majorité cible</span><strong>{{ M }} %</strong>
</span>
<input v-model.number="M" type="range" min="0" max="100" step="1">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>B — plancher dynamique</span><strong>{{ B }}</strong>
</span>
<input v-model.number="B" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>G — gradient d'inertie</span><strong>{{ G }}</strong>
</span>
<input v-model.number="G" type="range" min="0.01" max="2" step="0.01">
</label>
<label class="lab__slider">
<span class="lab__slider-head">
<span>C plancher fixe</span><strong>{{ C }}</strong>
</span>
<input v-model.number="C" type="range" min="0" max="100" step="1">
</label>
<div class="lab__optional">
<label class="lab__check">
<input v-model="withSmith" type="checkbox">
<span>Critère Forgeron</span>
</label>
<div v-if="withSmith" class="lab__sub">
<label class="lab__slider">
<span class="lab__slider-head"><span>S exposant</span><strong>{{ S }}</strong></span>
<input v-model.number="S" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__field lab__field--inline">
<span class="lab__field-label">forgerons actifs</span>
<input v-model.number="smithW" type="number" min="1" max="10000" class="lab__number">
</label>
</div>
<label class="lab__check">
<input v-model="withTech" type="checkbox">
<span>Critère Comité technique</span>
</label>
<div v-if="withTech" class="lab__sub">
<label class="lab__slider">
<span class="lab__slider-head"><span>exposant</span><strong>{{ techExp }}</strong></span>
<input v-model.number="techExp" type="range" min="0.01" max="1" step="0.01">
</label>
<label class="lab__field lab__field--inline">
<span class="lab__field-label">membres du comité</span>
<input v-model.number="techW" type="number" min="1" max="1000" class="lab__number">
</label>
</div>
</div>
</div>
<!-- Le résultat -->
<div class="lab__result ld-card">
<p class="lab__result-heading">Le seuil, ici et maintenant</p>
<p class="lab__result-figure">
<span class="lab__result-number">{{ formatInt(threshold) }}</span>
<span class="lab__result-unit">pour requis</span>
</p>
<p v-if="thresholdPct !== null" class="lab__result-pct">
soit {{ thresholdPct }} % des {{ formatInt(T) }} votes exprimés
</p>
<p v-if="sentence" class="lab__result-sentence">{{ sentence }}</p>
<div v-if="smithFormulaHtml" class="lab__criterion" v-html="smithFormulaHtml" />
<div v-if="techFormulaHtml" class="lab__criterion" v-html="techFormulaHtml" />
</div>
</div>
<!-- Table de participation -->
<div class="lab__table-card ld-card">
<p class="lab__block-title">Le seuil descend quand la participation monte</p>
<p class="lab__block-sub">T croissant, sur une toile de {{ formatInt(W) }} membres</p>
<div class="lab__table-scroll">
<table class="lab__table">
<thead>
<tr>
<th>participation</th>
<th>T</th>
<th>seuil</th>
<th>% des exprimés</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in participationRows"
:key="row.pct"
:class="{ 'lab__row--current': row.isCurrent }"
>
<td>{{ row.pct }} %</td>
<td>{{ formatInt(row.votes) }}</td>
<td class="lab__cell-strong">{{ formatInt(row.required) }}</td>
<td>{{ row.ratio }} %</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Presets d'inertie comparés -->
<div class="lab__presets ld-card">
<p class="lab__block-title">Les quatre inerties, comparées</p>
<p class="lab__block-sub">
mêmes W et T — seule l'inertie change ; ce sont les presets réels des clauses
</p>
<svg viewBox="0 0 230 100" class="lab__curves" role="img" aria-label="Courbes de seuil par inertie">
<line x1="30" y1="10" x2="30" y2="80" class="lab__axis" />
<line x1="30" y1="80" x2="200" y2="80" class="lab__axis" />
<path
v-for="curve in presetCurves"
:key="curve.preset"
:d="curve.path"
fill="none"
:style="{ stroke: curve.color }"
:stroke-width="curve.active ? 2.6 : 1.4"
:opacity="curve.active ? 1 : 0.55"
stroke-linecap="round"
/>
<path
v-if="!presetCurves.some(curve => curve.active)"
:d="currentCurve"
fill="none"
class="lab__curve-current"
stroke-width="2.2"
stroke-dasharray="5 4"
/>
<text x="14" y="14" class="lab__axis-label">100%</text>
<text x="18" y="84" class="lab__axis-label">M</text>
<text x="30" y="94" class="lab__axis-label">0%</text>
<text x="180" y="94" class="lab__axis-label">100%</text>
<text x="110" y="99" class="lab__axis-title">participation T/W</text>
<text x="4" y="52" class="lab__axis-title" transform="rotate(-90, 8, 52)">seuil</text>
</svg>
<div class="lab__preset-rows">
<button
v-for="row in presetRows"
:key="row.preset"
type="button"
class="lab__preset"
:class="{ 'lab__preset--active': row.active }"
:style="{ '--preset-color': row.color }"
@click="M = row.majorityPct; G = row.gradient; B = 0.1; C = 0"
>
<span class="lab__preset-dot" />
<span class="lab__preset-label">{{ row.label }}</span>
<span class="lab__preset-params">M{{ row.majorityPct }} · G{{ row.gradient }}</span>
<span class="lab__preset-required">{{ formatInt(row.required) }} pour ({{ row.ratio }} %)</span>
</button>
</div>
</div>
</section>
</template>
<style scoped>
.lab { display: flex; flex-direction: column; gap: 1rem; }
.lab__formula { padding: clamp(0.9rem, 3vw, 1.4rem); overflow-x: auto; }
.lab__tex { text-align: center; }
.lab__tex :deep(.lab__tex-fallback) {
font-family: monospace;
font-size: 0.8125rem;
color: var(--mood-text);
}
.lab__legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem 1rem;
margin-top: 0.6rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.lab__legend strong { color: var(--mood-accent); font-family: monospace; }
.lab__grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 768px) { .lab__grid { grid-template-columns: 3fr 2fr; } }
.lab__controls {
padding: clamp(0.9rem, 3vw, 1.3rem);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.lab__scenario { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
.lab__field { display: flex; flex-direction: column; gap: 0.25rem; }
.lab__field--inline { flex-direction: row; align-items: center; gap: 0.5rem; }
.lab__field-label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--mood-text-muted);
}
.lab__number {
width: 7.5rem;
padding: 0.4rem 0.7rem;
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
box-shadow: inset 0 0 0 1.5px var(--mood-input-border);
}
.lab__forgeron { font-size: 0.8125rem; }
.lab__slider { display: flex; flex-direction: column; gap: 0.3rem; }
.lab__slider-head {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.lab__slider-head strong {
color: var(--mood-accent);
font-variant-numeric: tabular-nums;
font-family: monospace;
}
.lab__slider input[type='range'] {
width: 100%;
accent-color: var(--mood-accent);
background: transparent;
min-height: 1.4rem;
}
.lab__optional {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-top: 0.75rem;
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 8%, transparent);
}
.lab__check {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
cursor: pointer;
}
.lab__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.lab__sub {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 1rem;
}
.lab__result {
padding: clamp(0.9rem, 3vw, 1.3rem);
display: flex;
flex-direction: column;
gap: 0.5rem;
background:
linear-gradient(150deg, var(--mood-accent-soft), transparent 60%),
var(--mood-surface);
}
.lab__result-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
}
.lab__result-figure { display: flex; align-items: baseline; gap: 0.5rem; }
.lab__result-number {
font-size: clamp(2.2rem, 7vw, 3rem);
font-weight: 800;
color: var(--mood-accent);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.lab__result-unit { font-size: 0.9375rem; font-weight: 700; color: var(--mood-text); }
.lab__result-pct { font-size: 0.875rem; color: var(--mood-text-muted); }
.lab__result-sentence {
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text);
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 4%, transparent);
}
.lab__criterion { overflow-x: auto; font-size: 0.875rem; }
.lab__table-card, .lab__presets { padding: clamp(0.9rem, 3vw, 1.3rem); }
.lab__block-title { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
.lab__block-sub { font-size: 0.75rem; color: var(--mood-text-muted); margin-bottom: 0.7rem; }
.lab__table-scroll { overflow-x: auto; }
.lab__table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.lab__table th {
text-align: left;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
padding: 0.35rem 0.6rem;
}
.lab__table td {
padding: 0.35rem 0.6rem;
font-variant-numeric: tabular-nums;
color: var(--mood-text);
}
.lab__table tbody tr:nth-child(odd) td {
background: color-mix(in srgb, var(--mood-text) 3%, transparent);
}
.lab__row--current td { background: var(--mood-accent-soft) !important; font-weight: 700; }
.lab__cell-strong { font-weight: 800; color: var(--mood-accent); }
.lab__curves { width: 100%; max-width: 26rem; height: auto; margin: 0 auto 0.5rem; display: block; }
.lab__axis { stroke: color-mix(in srgb, var(--mood-text) 25%, transparent); stroke-width: 1; }
.lab__axis-label { font-size: 5.5px; fill: var(--mood-text-muted); font-family: monospace; }
.lab__axis-title { font-size: 5.5px; fill: var(--mood-text-muted); font-weight: 600; }
.lab__curve-current { stroke: var(--mood-text); }
.lab__preset-rows { display: flex; flex-direction: column; gap: 0.4rem; }
.lab__preset {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.55rem 0.8rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--preset-color) 6%, transparent);
cursor: pointer;
text-align: left;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.lab__preset:hover { transform: translateY(-1px); box-shadow: 0 3px 10px var(--mood-shadow); }
.lab__preset--active { box-shadow: 0 0 0 2px var(--preset-color); }
.lab__preset-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--preset-color);
flex-shrink: 0;
}
.lab__preset-label { font-size: 0.8125rem; font-weight: 800; color: var(--mood-text); }
.lab__preset-params {
font-size: 0.6875rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.lab__preset-required {
margin-left: auto;
font-size: 0.8125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--preset-color);
}
</style>
@@ -0,0 +1,163 @@
<script setup lang="ts">
// <!-- ld-v2 --> Mini-démo interactive du Réglage collectif : cinq curseurs-votes
// jouets, la médiane basse recalculée en direct par ~/engine (medianByElement),
// et la règle de cristallisation — l'agrégat éclaire, le geste cristallise.
import { medianByElement } from '~/engine'
import { CRYSTALLIZE_ACTION, MEDIAN_EXPLANATION } from '~/lexicon'
const TOY_VOTERS = ['Ana', 'Brice', 'Chloé', 'Dado', 'Elsa'] as const
const votes = ref<number[]>([20, 35, 40, 65, 80])
const median = computed(() => {
const result = medianByElement(votes.value.map(v => [v]))
return result[0] ?? 0
})
/** La médiane est toujours une valeur réellement votée — on la nomme. */
const medianVoters = computed(() =>
TOY_VOTERS.filter((_, i) => votes.value[i] === median.value))
</script>
<template>
<!-- ld-v2 -->
<div class="median-demo">
<div class="median-demo__sliders">
<label v-for="(name, i) in TOY_VOTERS" :key="name" class="median-demo__vote">
<span class="median-demo__voter">{{ name }}</span>
<input
v-model.number="votes[i]"
type="range"
min="0"
max="100"
step="5"
:aria-label="`Curseur-vote de ${name}`"
>
<span class="median-demo__value">{{ votes[i] }}</span>
</label>
</div>
<!-- Le faisceau : chaque point est un vote, le trait épais est la médiane -->
<div class="median-demo__strip" aria-hidden="true">
<span
v-for="(vote, i) in votes"
:key="i"
class="median-demo__dot"
:style="{ left: `${vote}%` }"
/>
<span class="median-demo__median-mark" :style="{ left: `${median}%` }" />
</div>
<p class="median-demo__readout">
Médiane basse : <strong>{{ median }}</strong>
<span v-if="medianVoters.length" class="median-demo__who">
c'est la position de {{ medianVoters.join(' et ') }}
</span>
</p>
<p class="median-demo__explain">{{ MEDIAN_EXPLANATION }}</p>
<div class="median-demo__rule">
<UIcon name="i-lucide-stamp" class="median-demo__rule-icon" />
<div>
<p class="median-demo__rule-title">L'agrégat éclaire, le geste cristallise.</p>
<p class="median-demo__rule-body">
À la clôture, les votes sont figés et la médiane s'affiche mais rien n'est
adopté tant qu'un·e garant·e n'a pas fait le geste « {{ CRYSTALLIZE_ACTION }} »,
daté et signé. Jamais d'adoption automatique.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.median-demo { display: flex; flex-direction: column; gap: 0.9rem; }
.median-demo__sliders { display: flex; flex-direction: column; gap: 0.45rem; }
.median-demo__vote {
display: grid;
grid-template-columns: 3.4rem 1fr 2.4rem;
align-items: center;
gap: 0.6rem;
}
.median-demo__voter { font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted); }
.median-demo__vote input[type='range'] {
width: 100%;
accent-color: var(--mood-tertiary);
background: transparent;
min-height: 1.3rem;
}
.median-demo__value {
font-size: 0.8125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--mood-text);
}
.median-demo__strip {
position: relative;
height: 2rem;
margin: 0 0.3rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 5%, transparent);
}
.median-demo__dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--mood-tertiary);
opacity: 0.55;
transition: left 0.15s ease;
}
.median-demo__median-mark {
position: absolute;
top: 3px;
bottom: 3px;
transform: translateX(-50%);
width: 4px;
border-radius: 2px;
background: var(--mood-accent);
transition: left 0.15s ease;
}
.median-demo__readout { font-size: 0.9375rem; color: var(--mood-text); }
.median-demo__readout strong {
color: var(--mood-accent);
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.median-demo__who { font-size: 0.8125rem; color: var(--mood-text-muted); }
.median-demo__explain {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--mood-text-muted);
}
.median-demo__rule {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.8rem 0.95rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
}
.median-demo__rule-icon {
font-size: 1.2rem;
color: var(--mood-accent);
margin-top: 2px;
flex-shrink: 0;
}
.median-demo__rule-title { font-size: 0.875rem; font-weight: 800; color: var(--mood-text); }
.median-demo__rule-body {
font-size: 0.8125rem;
line-height: 1.55;
color: var(--mood-text-muted);
margin-top: 2px;
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
// <!-- ld-v2 --> Carte d'une clause dans la vue structurée : code, titre,
// mini-jauge de session réelle, pastille d'inertie câblée, statut — tap pour
// déplier le détail complet (ClauseDetail).
import type { ClauseView } from './textsModel'
defineProps<{
view: ClauseView
expanded: boolean
}>()
defineEmits<{ (e: 'toggle'): void }>()
</script>
<template>
<!-- ld-v2 -->
<article class="clause-card ld-card">
<button type="button" class="clause-card__row" @click="$emit('toggle')">
<span class="clause-card__code">{{ view.clause.code }}</span>
<span class="clause-card__title">{{ view.clause.title }}</span>
<span class="clause-card__side">
<SessionMiniGauge v-if="view.gauge" :gauge="view.gauge" @click.stop />
<InertiaBadge
:preset="view.clause.inertia"
:amend-protocol="view.amendProtocol"
:protected-clause="view.protectedClause"
compact
/>
<span class="status-pill clause-card__status" :class="view.status.css">
{{ view.status.label }}
</span>
</span>
</button>
<p v-if="view.settingText && !expanded" class="clause-card__setting">
{{ view.settingText }}
</p>
<ClauseDetail
v-if="expanded"
class="clause-card__detail"
:clause="view.clause"
:current-version="view.current"
:proposed="view.proposed"
:founding="view.founding"
:amendments="view.amendments"
:setting-text="view.settingText"
:gauge="view.gauge"
:atelier-link="view.atelierLink"
/>
</article>
</template>
<style scoped>
.clause-card { overflow: hidden; }
.clause-card__row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 0.9rem;
background: none;
cursor: pointer;
text-align: left;
flex-wrap: wrap;
}
.clause-card__row:hover { background: color-mix(in srgb, var(--mood-accent) 4%, transparent); }
.clause-card__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;
flex-shrink: 0;
}
.clause-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
flex: 1;
min-width: 10rem;
}
.clause-card__side { display: inline-flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.clause-card__status { font-size: 0.6875rem; padding: 2px 9px; }
.clause-card__setting {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
padding: 0 0.9rem 0.7rem;
}
.clause-card__detail { margin: 0 0.55rem 0.55rem; }
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
// <!-- ld-v2 --> Détail dépliable d'une clause : version courante (markdown
// simple), versions proposées avec diff (contenu d'abord, auteur replié),
// décision fondatrice et amendements chaînés, valeur en clair des clauses de
// réglage du Pacte (« effet immédiat à l'adoption »), geste « Proposer une version ».
import type { Clause, ClauseVersion } from '~/types/domain'
import { STATUS_LABELS } from '~/lexicon'
import type { ChainEntry, ClauseSessionGauge, ProposedEntry } from './textsModel'
import { formatDateFr } from './textsModel'
const props = withDefaults(defineProps<{
clause: Clause
currentVersion?: ClauseVersion
proposed?: ProposedEntry[]
founding?: ChainEntry
amendments?: ChainEntry[]
/** Valeur en clair (clauses de réglage du Pacte). */
settingText?: string | null
gauge?: ClauseSessionGauge | null
/** Atelier pré-réglé sur le protocole d'amendement de cette clause. */
atelierLink?: string | null
}>(), { proposed: () => [], amendments: () => [], settingText: null, gauge: null, atelierLink: null })
const hasChain = computed(() => props.founding !== undefined || props.amendments.length > 0)
</script>
<template>
<!-- ld-v2 -->
<div class="clause-detail">
<!-- Valeur en clair Pacte -->
<div v-if="settingText" class="clause-detail__setting">
<UIcon name="i-lucide-settings-2" class="clause-detail__setting-icon" />
<div>
<p class="clause-detail__setting-value">{{ settingText }}</p>
<p class="clause-detail__setting-note">Effet immédiat à l'adoption — le Pacte est le réglage.</p>
</div>
</div>
<!-- Session en cours -->
<SessionMiniGauge v-if="gauge" :gauge="gauge" with-title class="clause-detail__gauge" />
<!-- Version courante -->
<div v-if="currentVersion" class="clause-detail__current">
<p class="clause-detail__heading">
Version en vigueur
<span class="clause-detail__version-label">{{ currentVersion.versionLabel }}</span>
<span v-if="currentVersion.adoptedAt" class="clause-detail__date">
adoptée le {{ formatDateFr(currentVersion.adoptedAt) }}
</span>
</p>
<MarkdownRenderer :content="currentVersion.content" class="clause-detail__content" />
</div>
<p v-else class="clause-detail__empty">Cette clause n'a pas encore de version en vigueur.</p>
<!-- Versions proposées : le contenu d'abord, l'auteur replié -->
<div v-if="proposed.length" class="clause-detail__proposals">
<p class="clause-detail__heading">
{{ proposed.length > 1 ? `${proposed.length} versions proposées` : 'Une version est proposée' }}
</p>
<article
v-for="entry in proposed"
:key="entry.version.id"
class="clause-detail__proposal"
>
<ClauseVersionDiff
:current="currentVersion?.content ?? ''"
:proposed="entry.version.content"
/>
<details class="clause-detail__author">
<summary class="clause-detail__author-summary">
<UIcon name="i-lucide-user" />
Qui propose ? <span class="clause-detail__version-label">{{ entry.version.versionLabel }}</span>
</summary>
<p class="clause-detail__author-body">
Proposée par <strong>{{ entry.authorName }}</strong>
<template v-if="entry.decisionId">
dans le cadre de
<NuxtLink :to="`/decisions/${entry.decisionId}`" class="clause-detail__link">
{{ entry.decisionTitle ?? 'la décision liée' }}
</NuxtLink>
</template>
</p>
</details>
</article>
</div>
<!-- Décision fondatrice + amendements chaînés -->
<div v-if="hasChain" class="clause-detail__chain">
<p class="clause-detail__heading">D'où vient cette clause</p>
<ol class="clause-detail__chain-list">
<li v-if="founding" class="clause-detail__chain-item">
<UIcon name="i-lucide-sprout" class="clause-detail__chain-icon" />
<NuxtLink :to="`/decisions/${founding.id}`" class="clause-detail__link">
{{ founding.title }}
</NuxtLink>
<span v-if="founding.decidedAt" class="clause-detail__date">
{{ formatDateFr(founding.decidedAt) }}
</span>
</li>
<li
v-for="amendment in amendments"
:key="amendment.id"
class="clause-detail__chain-item"
>
<UIcon name="i-lucide-git-commit-vertical" class="clause-detail__chain-icon" />
<NuxtLink :to="`/decisions/${amendment.id}`" class="clause-detail__link">
{{ amendment.title }}
</NuxtLink>
<span class="status-pill" :class="`status-${amendment.status}`">
{{ STATUS_LABELS[amendment.status] }}
</span>
</li>
</ol>
</div>
<!-- Le geste -->
<div class="clause-detail__actions">
<NuxtLink :to="`/decider?clause=${clause.id}`" class="ld-btn clause-detail__propose">
<UIcon name="i-lucide-pen-line" />
Proposer une version
</NuxtLink>
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--quiet clause-detail__propose">
<UIcon name="i-lucide-flask-conical" />
Comprendre ce seuil
</NuxtLink>
<p class="clause-detail__actions-note">
Amender, c'est décider le chemin s'ouvre avec l'inertie de cette clause.
</p>
</div>
</div>
</template>
<style scoped>
.clause-detail {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.9rem 1rem 1.1rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-text) 2.5%, var(--mood-surface));
}
.clause-detail__setting {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.7rem 0.9rem;
border-radius: var(--r-input);
background: var(--mood-accent-soft);
}
.clause-detail__setting-icon { color: var(--mood-accent); font-size: 1.1rem; margin-top: 2px; }
.clause-detail__setting-value { font-size: 0.9375rem; font-weight: 800; color: var(--mood-text); }
.clause-detail__setting-note { font-size: 0.75rem; color: var(--mood-text-muted); margin-top: 2px; }
.clause-detail__gauge { align-self: flex-start; }
.clause-detail__heading {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
margin-bottom: 0.4rem;
}
.clause-detail__version-label {
font-family: monospace;
font-size: 0.6875rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text);
text-transform: none;
letter-spacing: 0;
}
.clause-detail__date {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: none;
letter-spacing: 0;
}
.clause-detail__content { font-size: 0.875rem; }
.clause-detail__empty { font-size: 0.8125rem; color: var(--mood-text-muted); font-style: italic; }
.clause-detail__proposal { display: flex; flex-direction: column; gap: 0.4rem; }
.clause-detail__proposal + .clause-detail__proposal { margin-top: 0.75rem; }
.clause-detail__author-summary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
user-select: none;
}
.clause-detail__author-body { font-size: 0.8125rem; color: var(--mood-text); margin-top: 0.35rem; }
.clause-detail__chain-list { display: flex; flex-direction: column; gap: 0.35rem; }
.clause-detail__chain-item {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.8125rem;
}
.clause-detail__chain-icon { color: var(--mood-accent); font-size: 0.95rem; flex-shrink: 0; }
.clause-detail__chain-item .status-pill { font-size: 0.6875rem; padding: 2px 9px; }
.clause-detail__link { color: var(--mood-accent); text-decoration: none; font-weight: 600; }
.clause-detail__link:hover { text-decoration: underline; }
.clause-detail__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.clause-detail__propose { text-decoration: none; font-size: 0.875rem; }
.clause-detail__actions-note { font-size: 0.75rem; color: var(--mood-text-muted); }
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
// <!-- ld-v2 --> Diff inline +/ entre la version courante et une version
// proposée — contenu d'abord, calculé localement (LCS ligne à ligne, sans lib).
const props = defineProps<{
current: string
proposed: string
}>()
interface DiffLine {
text: string
type: 'kept' | 'removed' | 'added'
}
/** Line-based LCS diff — texts are clause-sized, the DP table stays tiny. */
function lineDiff(a: string[], b: string[]): DiffLine[] {
const n = a.length
const m = b.length
// lcs[i][j] = LCS length of a[i:] / b[j:]
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i]![j] = a[i] === b[j]
? lcs[i + 1]![j + 1]! + 1
: Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ text: a[i]!, type: 'kept' })
i++
j++
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
out.push({ text: a[i]!, type: 'removed' })
i++
} else {
out.push({ text: b[j]!, type: 'added' })
j++
}
}
while (i < n) out.push({ text: a[i++]!, type: 'removed' })
while (j < m) out.push({ text: b[j++]!, type: 'added' })
return out
}
const lines = computed(() => {
const a = props.current.split('\n')
const b = props.proposed.split('\n')
return lineDiff(a, b).filter(l => !(l.type === 'kept' && l.text.trim() === ''))
})
const changedCount = computed(() => lines.value.filter(l => l.type !== 'kept').length)
</script>
<template>
<!-- ld-v2 -->
<div class="clause-diff">
<div v-if="changedCount === 0" class="clause-diff__same">
Aucun changement de texte les deux versions sont identiques.
</div>
<div
v-for="(line, index) in lines"
v-else
:key="index"
class="clause-diff__line"
:class="`clause-diff__line--${line.type}`"
>
<span class="clause-diff__marker" aria-hidden="true">
{{ line.type === 'added' ? '+' : line.type === 'removed' ? '' : '' }}
</span>
<span class="clause-diff__text">{{ line.text }}</span>
</div>
</div>
</template>
<style scoped>
.clause-diff {
border-radius: var(--r-input);
overflow: hidden;
background: color-mix(in srgb, var(--mood-text) 3%, var(--mood-surface));
font-size: 0.8125rem;
line-height: 1.55;
}
.clause-diff__same {
padding: 0.6rem 0.9rem;
color: var(--mood-text-muted);
font-style: italic;
}
.clause-diff__line {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0.9rem;
}
.clause-diff__line--removed {
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
color: color-mix(in srgb, var(--mood-error) 80%, var(--mood-text));
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.clause-diff__line--added {
background: color-mix(in srgb, var(--mood-success) 11%, transparent);
color: color-mix(in srgb, var(--mood-success) 75%, var(--mood-text));
}
.clause-diff__marker {
flex-shrink: 0;
width: 0.9rem;
font-weight: 800;
font-family: monospace;
text-align: center;
}
.clause-diff__text { white-space: pre-wrap; word-break: break-word; }
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
// <!-- ld-v2 --> Pastille d'inertie d'une clause — CÂBLÉE au protocole
// d'amendement résolu par le Pacte (settings.protocolByRange.clauseByInertia) :
// le tooltip dit par quel protocole cette clause s'amende, en français,
// sans aucune lettre de formule (elles vivent à l'Atelier seulement).
import type { InertiaPreset } from '~/types/domain'
import { INERTIA_LABELS } from '~/lexicon'
import { INERTIA_COLORS, INERTIA_ORDER } from './textsModel'
const props = withDefaults(defineProps<{
preset: InertiaPreset
/** Résolu par la page : « Vote WoT standard — 30 jours ». */
amendProtocol?: string
/** Clause qui protège le réglage de l'inertie lui-même (pastille max). */
protectedClause?: boolean
compact?: boolean
}>(), { compact: false, protectedClause: false })
const color = computed(() => INERTIA_COLORS[props.preset])
const label = computed(() => INERTIA_LABELS[props.preset])
const steps = computed(() => INERTIA_ORDER.indexOf(props.preset) + 1)
const tooltip = computed(() => {
const lines: string[] = [label.value]
if (props.amendProtocol) lines.push(`S'amende par : ${props.amendProtocol}`)
if (props.protectedClause) lines.push('Clause protégée — elle garde les règles qui gardent les règles.')
return lines.join('\n')
})
</script>
<template>
<!-- ld-v2 -->
<span
class="inertia-badge"
:class="{ 'inertia-badge--compact': compact }"
:style="{ '--ib-color': color }"
:title="tooltip"
>
<span class="inertia-badge__dots" aria-hidden="true">
<span
v-for="i in 4"
:key="i"
class="inertia-badge__dot"
:class="{ 'inertia-badge__dot--on': i <= steps }"
/>
</span>
<span v-if="!compact" class="inertia-badge__label">{{ label }}</span>
<UIcon v-if="protectedClause" name="i-lucide-lock" class="inertia-badge__lock" />
</span>
</template>
<style scoped>
.inertia-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--ib-color) 11%, transparent);
color: var(--ib-color);
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
cursor: help;
user-select: none;
}
.inertia-badge--compact { padding: 3px 7px; gap: 0.3rem; }
.inertia-badge__dots {
display: inline-flex;
align-items: center;
gap: 2.5px;
}
.inertia-badge__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: color-mix(in srgb, var(--ib-color) 25%, transparent);
}
.inertia-badge__dot--on { background: var(--ib-color); }
.inertia-badge__label { letter-spacing: 0.01em; }
.inertia-badge__lock { font-size: 0.8rem; }
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
// <!-- ld-v2 --> Mini-jauge d'une session RÉELLE en cours sur une version
// proposée : positions déposées / liste arrêtée, échéance, état figé.
import { FROZEN_LABEL } from '~/lexicon'
import type { ClauseSessionGauge } from './textsModel'
import { formatDateFr } from './textsModel'
const props = withDefaults(defineProps<{
gauge: ClauseSessionGauge
/** Affiche le titre de la décision au-dessus de la jauge. */
withTitle?: boolean
}>(), { withTitle: false })
const ratio = computed(() => {
if (props.gauge.listSize <= 0) return 0
return Math.min(100, Math.round((props.gauge.positions / props.gauge.listSize) * 100))
})
const tooltip = computed(() =>
`${props.gauge.decisionTitle}\n${props.gauge.positions} position${props.gauge.positions > 1 ? 's' : ''} `
+ `sur une liste arrêtée de ${props.gauge.listSize} — clôture le ${formatDateFr(props.gauge.closesAt)}`)
</script>
<template>
<!-- ld-v2 -->
<NuxtLink class="mini-gauge" :to="`/decisions/${gauge.decisionId}`" :title="tooltip">
<span v-if="withTitle" class="mini-gauge__title">{{ gauge.decisionTitle }}</span>
<span class="mini-gauge__row">
<UIcon :name="gauge.frozen ? 'i-lucide-snowflake' : 'i-lucide-vote'" class="mini-gauge__icon" />
<span class="mini-gauge__track">
<span class="mini-gauge__fill" :style="{ width: `${ratio}%` }" />
</span>
<span class="mini-gauge__count">{{ gauge.positions }}/{{ gauge.listSize }}</span>
<span v-if="gauge.frozen" class="mini-gauge__frozen">{{ FROZEN_LABEL }}</span>
</span>
</NuxtLink>
</template>
<style scoped>
.mini-gauge {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
text-decoration: none;
color: inherit;
}
.mini-gauge__title {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 15rem;
}
.mini-gauge__row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.mini-gauge__icon {
font-size: 0.85rem;
color: var(--mood-status-vote);
flex-shrink: 0;
}
.mini-gauge__track {
position: relative;
width: 3.5rem;
height: 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
overflow: hidden;
}
.mini-gauge__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 3px;
background: var(--mood-status-vote);
transition: width 0.3s ease;
}
.mini-gauge__count {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-status-vote);
font-variant-numeric: tabular-nums;
}
.mini-gauge__frozen {
font-size: 0.625rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-pill);
background: var(--mood-status-fige-bg);
color: var(--mood-status-fige);
}
</style>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// <!-- ld-v2 --> Carte d'un document de la bibliothèque : titre, rôle,
// nombre de clauses, mini-jauges des sessions en cours sur ses clauses,
// provenance résumée (version, vote d'origine), badge 井 si gravures.
import type { TextDoc } from '~/types/domain'
import { PACT_BADGE } from '~/lexicon'
import type { ClauseSessionGauge } from './textsModel'
import { formatInt } from './textsModel'
const props = withDefaults(defineProps<{
doc: TextDoc
clauseCount: number
gauges?: ClauseSessionGauge[]
engraved?: number
/** Carte épinglée du Pacte — mise en avant. */
pinned?: boolean
}>(), { gauges: () => [], engraved: 0, pinned: false })
const roleLabel = computed(() => (props.doc.role === 'pact' ? PACT_BADGE : 'document de référence'))
const version = computed(() =>
props.doc.provenance?.sources.find(s => s.version)?.version)
const voteSummary = computed(() => {
const r = props.doc.provenance?.voteRecord?.result
if (!r) return null
return `${formatInt(r.for)} pour · ${formatInt(r.against)} contre — toile de ${formatInt(r.wotSize)}`
})
const visibleGauges = computed(() => props.gauges.slice(0, 3))
</script>
<template>
<!-- ld-v2 -->
<NuxtLink
:to="`/textes/${doc.slug}`"
class="doc-card ld-card ld-card--hover"
:class="{ 'doc-card--pinned': pinned }"
>
<header class="doc-card__head">
<span class="doc-card__role" :class="{ 'doc-card__role--pact': doc.role === 'pact' }">
<UIcon :name="doc.role === 'pact' ? 'i-lucide-scroll' : 'i-lucide-book-open'" />
{{ roleLabel }}
</span>
<span v-if="engraved > 0" class="doc-card__engraved" title="Décisions gravées sur ce texte">
{{ engraved }}
</span>
</header>
<h3 class="doc-card__title">{{ doc.title }}</h3>
<p class="doc-card__desc">{{ doc.description }}</p>
<div class="doc-card__meta">
<span class="doc-card__chip">
<UIcon name="i-lucide-list" />
{{ clauseCount }} clause{{ clauseCount > 1 ? 's' : '' }}
</span>
<span v-if="version" class="doc-card__chip doc-card__chip--mono">v{{ version }}</span>
<span v-if="voteSummary" class="doc-card__chip doc-card__chip--vote">
<UIcon name="i-lucide-vote" />
{{ voteSummary }}
</span>
</div>
<div v-if="visibleGauges.length" class="doc-card__sessions">
<p class="doc-card__sessions-heading">
{{ gauges.length > 1 ? `${gauges.length} sessions en cours` : 'Une session en cours' }}
</p>
<div class="doc-card__gauges">
<SessionMiniGauge
v-for="gauge in visibleGauges"
:key="gauge.decisionId"
:gauge="gauge"
@click.stop
/>
</div>
</div>
</NuxtLink>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: clamp(1rem, 3vw, 1.35rem);
text-decoration: none;
color: inherit;
}
.doc-card--pinned {
background:
linear-gradient(140deg, var(--mood-accent-soft), transparent 55%),
var(--mood-surface);
}
.doc-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__role {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 7%, transparent);
color: var(--mood-text-muted);
}
.doc-card__role--pact { background: var(--mood-accent-soft); color: var(--mood-accent); }
.doc-card__engraved {
font-size: 0.8125rem;
font-weight: 800;
color: var(--mood-status-vigueur);
}
.doc-card__title {
font-size: clamp(1rem, 2.5vw, 1.15rem);
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
line-height: 1.25;
}
.doc-card__desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.doc-card__meta { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.15rem; }
.doc-card__chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.71875rem;
font-weight: 700;
padding: 3px 9px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-text) 6%, transparent);
color: var(--mood-text-muted);
}
.doc-card__chip--mono { font-family: monospace; }
.doc-card__chip--vote {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
}
.doc-card__sessions {
margin-top: 0.35rem;
padding-top: 0.6rem;
box-shadow: 0 -1px 0 color-mix(in srgb, var(--mood-text) 7%, transparent);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.doc-card__sessions-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-status-vote);
}
.doc-card__gauges { display: flex; flex-direction: column; gap: 0.3rem; }
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
// <!-- ld-v2 --> Provenance d'un document — le bloc de genèse porté du v1 :
// sources datées, vote d'origine (résultats bruts, période, lien), contributeurs,
// notes (les typos des textes votés sont signalées, jamais corrigées).
// Aucune lettre de formule ici : « comprendre ce seuil » mène à l'Atelier pré-réglé.
import type { Provenance } from '~/types/domain'
import { parseModeParams } from '~/engine'
import { formatInt } from './textsModel'
const props = defineProps<{ provenance: Provenance }>()
const record = computed(() => props.provenance.voteRecord)
/** Query Atelier pré-réglée depuis le vote d'origine (lettres côté URL seulement). */
const atelierLink = computed(() => {
const r = record.value
if (!r) return null
const p = parseModeParams(r.modeParams)
const query = new URLSearchParams({
W: String(r.result.wotSize),
T: String(r.result.for + r.result.against),
M: String(p.majority_pct),
B: String(p.base_exponent),
G: String(p.gradient_exponent),
C: String(p.constant_base),
})
if (p.smith_exponent !== null) query.set('S', String(p.smith_exponent))
return `/textes/formules?${query.toString()}`
})
const sourcesOpen = ref(false)
</script>
<template>
<!-- ld-v2 -->
<section class="prov ld-card">
<header class="prov__header">
<UIcon name="i-lucide-landmark" class="prov__header-icon" />
<div>
<h2 class="prov__title">Provenance</h2>
<p class="prov__sub">d'où vient ce texte, et par quel geste il fait loi</p>
</div>
</header>
<!-- Vote d'origine -->
<div v-if="record" class="prov__vote">
<p class="prov__vote-heading">Le vote d'origine</p>
<div class="prov__vote-figures">
<span class="prov__figure prov__figure--for">{{ formatInt(record.result.for) }} pour</span>
<span class="prov__figure prov__figure--against">{{ formatInt(record.result.against) }} contre</span>
<span v-if="record.result.invalid" class="prov__figure prov__figure--muted">
{{ formatInt(record.result.invalid) }} invalides
</span>
</div>
<p class="prov__vote-line">
Toile de {{ formatInt(record.result.wotSize) }} membres —
seuil requis : {{ formatInt(record.result.thresholdRequired) }} pour.
Résultat : <strong>{{ record.result.status }}</strong>.
</p>
<p class="prov__vote-line prov__vote-line--muted">Période : {{ record.period }}</p>
<div class="prov__vote-actions">
<NuxtLink v-if="atelierLink" :to="atelierLink" class="ld-btn ld-btn--ghost prov__btn">
<UIcon name="i-lucide-flask-conical" />
Comprendre ce seuil
</NuxtLink>
<a :href="record.url" target="_blank" rel="noopener" class="ld-btn ld-btn--quiet prov__btn">
<UIcon name="i-lucide-external-link" />
Le fil du vote
</a>
</div>
</div>
<!-- Sources -->
<div v-if="provenance.sources.length" class="prov__block">
<button type="button" class="prov__toggle" @click="sourcesOpen = !sourcesOpen">
<UIcon name="i-lucide-library" />
{{ provenance.sources.length }} source{{ provenance.sources.length > 1 ? 's' : '' }}
<UIcon
name="i-lucide-chevron-down"
class="prov__chevron"
:class="{ 'prov__chevron--open': sourcesOpen }"
/>
</button>
<ul v-show="sourcesOpen" class="prov__sources">
<li v-for="source in provenance.sources" :key="source.url" class="prov__source">
<a :href="source.url" target="_blank" rel="noopener" class="prov__source-link">
{{ source.title }}
</a>
<span v-if="source.version" class="prov__source-meta">v{{ source.version }}</span>
<span v-if="source.date" class="prov__source-meta">{{ source.date }}</span>
</li>
</ul>
</div>
<!-- Contributeurs -->
<div v-if="provenance.contributors?.length" class="prov__block">
<p class="prov__block-heading">
<UIcon name="i-lucide-users" />
Contributeurs
</p>
<ul class="prov__contributors">
<li v-for="person in provenance.contributors" :key="person" class="prov__contributor">
{{ person }}
</li>
</ul>
</div>
<!-- Notes -->
<details v-if="provenance.notes" class="prov__notes">
<summary class="prov__notes-summary">
<UIcon name="i-lucide-sticky-note" />
Notes de provenance — les coquilles des textes votés sont signalées, jamais corrigées
</summary>
<p class="prov__notes-body">{{ provenance.notes }}</p>
</details>
</section>
</template>
<style scoped>
.prov {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: clamp(1rem, 3vw, 1.5rem);
}
.prov__header { display: flex; align-items: center; gap: 0.75rem; }
.prov__header-icon {
font-size: 1.4rem;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: var(--r-icon);
padding: 0.5rem;
box-sizing: content-box;
}
.prov__title { font-size: 1.05rem; font-weight: 800; color: var(--mood-text); }
.prov__sub { font-size: 0.8125rem; color: var(--mood-text-muted); }
.prov__vote {
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prov__vote-heading {
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mood-text-muted);
}
.prov__vote-figures { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.prov__figure {
padding: 3px 11px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
}
.prov__figure--for { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
.prov__figure--against { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
.prov__figure--muted { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
.prov__vote-line { font-size: 0.875rem; color: var(--mood-text); }
.prov__vote-line--muted { font-size: 0.8125rem; color: var(--mood-text-muted); }
.prov__vote-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.25rem; }
.prov__btn { font-size: 0.8125rem; min-height: 2rem; padding: 0.35rem 0.9rem; text-decoration: none; }
.prov__block { display: flex; flex-direction: column; gap: 0.5rem; }
.prov__block-heading,
.prov__toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
}
.prov__toggle { background: none; cursor: pointer; padding: 0; align-self: flex-start; }
.prov__chevron { transition: transform 0.2s ease; color: var(--mood-text-muted); }
.prov__chevron--open { transform: rotate(180deg); }
.prov__sources { display: flex; flex-direction: column; gap: 0.3rem; }
.prov__source { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; }
.prov__source-link {
font-size: 0.8125rem;
color: var(--mood-accent);
text-decoration: none;
}
.prov__source-link:hover { text-decoration: underline; }
.prov__source-meta { font-size: 0.6875rem; color: var(--mood-text-muted); font-family: monospace; }
.prov__contributors { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.prov__contributor {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--mood-secondary) 12%, transparent);
color: var(--mood-secondary);
}
.prov__notes-summary {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
user-select: none;
}
.prov__notes-body {
margin-top: 0.6rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--mood-text-muted);
white-space: pre-wrap;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
/**
* Shared pure helpers for the texts screens — library (/textes), living
* document (/textes/[slug]) and formula atelier (/textes/formules).
* No I/O, no store access: pages assemble, these functions compute.
*/
import type {
Clause,
ClauseVersion,
CollectiveSettings,
Decision,
DecisionStatus,
Id,
InertiaPreset,
ISODate,
Json,
Person,
Protocol,
Vote,
VoteSession,
} from '~/types/domain'
import { INERTIA_LABELS } from '~/lexicon'
// ── Shared view-model shapes (SFC scripts cannot export types) ──
/** A proposed clause version, content first — author folded behind it. */
export interface ProposedEntry {
version: ClauseVersion
authorName: string
decisionId?: Id
decisionTitle?: string
}
/** One link of a clause's decision chain (founding, then amendments). */
export interface ChainEntry {
id: Id
title: string
status: DecisionStatus
decidedAt?: string
}
/** Query-preset state of the formula atelier (?W=&T=&M=&B=&G=&C=&S=). */
export interface AtelierInit {
W?: number
T?: number
M?: number
B?: number
G?: number
C?: number
S?: number
}
// ── Inertia presets — REAL wiring (types/domain.ts) ──────────
export const INERTIA_PARAMS: Record<InertiaPreset, { majorityPct: number; gradientExponent: number }> = {
low: { majorityPct: 50, gradientExponent: 0.1 },
standard: { majorityPct: 50, gradientExponent: 0.2 },
high: { majorityPct: 60, gradientExponent: 0.4 },
max: { majorityPct: 66, gradientExponent: 0.6 },
}
/** Tint per preset — derived from the mood custom properties. */
export const INERTIA_COLORS: Record<InertiaPreset, string> = {
low: 'var(--mood-success)',
standard: 'var(--mood-accent)',
high: 'var(--mood-warning)',
max: 'var(--mood-error)',
}
export const INERTIA_ORDER: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
// ── Sections — labels + icons for known tags, graceful fallback ──
const SECTION_META: Record<string, { label: string; icon: string }> = {
preambule: { label: 'Préambule', icon: 'i-lucide-compass' },
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
mission: { label: 'Mission', icon: 'i-lucide-target' },
composition: { label: 'Composition', icon: 'i-lucide-users' },
engagements: { label: 'Engagements', icon: 'i-lucide-heart-handshake' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
// the raw tag never reaches the UI — this label replaces it
triage: { label: 'Seuils et fenêtres', icon: 'i-lucide-route' },
protocoles: { label: 'Protocoles de vote', icon: 'i-lucide-vote' },
}
export function sectionMeta(tag: string): { label: string; icon: string } {
const known = SECTION_META[tag]
if (known) return known
const label = tag.charAt(0).toUpperCase() + tag.slice(1)
return { label, icon: 'i-lucide-file-text' }
}
// ── Formatting ───────────────────────────────────────────────
export function formatDateFr(iso: ISODate | undefined): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
export function formatInt(n: number): string {
return n.toLocaleString('fr-FR')
}
// ── Live sessions on clause amendments — the mini-gauges ─────
export interface ClauseSessionGauge {
clauseId: Id
clauseCode: string
decisionId: Id
decisionTitle: string
positions: number // last active votes cast
listSize: number // arrested voter list size
closesAt: ISODate
frozen: boolean
}
/** Last-active-vote count of a session (supersedes chains resolved). */
function activeVoteCount(sessionId: Id, votes: Vote[]): number {
const sessionVotes = votes.filter(v => v.sessionId === sessionId)
const superseded = new Set(
sessionVotes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined),
)
return sessionVotes.filter(v => !superseded.has(v.id)).length
}
/**
* Real running sessions (open or frozen) over decisions that amend one of
* `clauses` — one gauge per amending decision, latest session wins.
*/
export function liveClauseGauges(
clauses: Clause[],
decisions: Decision[],
sessions: VoteSession[],
votes: Vote[],
): ClauseSessionGauge[] {
const byId = new Map(clauses.map(c => [c.id, c]))
const gauges: ClauseSessionGauge[] = []
for (const decision of decisions) {
if (!decision.amendsClauseId) continue
const clause = byId.get(decision.amendsClauseId)
if (!clause) continue
const latest = sessions
.filter(s => s.decisionId === decision.id)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
if (!latest || (latest.status !== 'open' && latest.status !== 'frozen')) continue
gauges.push({
clauseId: clause.id,
clauseCode: clause.code,
decisionId: decision.id,
decisionTitle: decision.title,
positions: activeVoteCount(latest.id, votes),
listSize: latest.corpusSize,
closesAt: latest.closesAt,
frozen: latest.status === 'frozen',
})
}
return gauges
}
/** Engraved decisions linked to these clauses (井 filter of the library). */
export function engravedCount(clauses: Clause[], decisions: Decision[]): number {
const ids = new Set(clauses.map(c => c.id))
return decisions.filter(d => d.engraving && d.amendsClauseId && ids.has(d.amendsClauseId)).length
}
// ── Versions per clause ──────────────────────────────────────
/** The 'current' version of a clause, preferring clause.currentVersionId. */
export function currentVersionOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion | undefined {
const mine = versions.filter(v => v.clauseId === clause.id)
if (clause.currentVersionId) {
const pinned = mine.find(v => v.id === clause.currentVersionId)
if (pinned) return pinned
}
return mine.find(v => v.status === 'current')
}
export function proposedVersionsOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion[] {
return versions
.filter(v => v.clauseId === clause.id && v.status === 'proposed')
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
}
// ── Pact setting clauses — the VALUE IN CLEAR ────────────────
const EFFECTS_LABELS: Record<string, string> = {
none: 'aucune exigence',
structural: 'décisions structurantes',
binding: 'décisions engageantes et structurantes',
}
/**
* One French sentence stating the current value of a Pact setting clause
* (« Petit groupe : jusqu'à 5 personnes »). `protocolName` resolves the
* protocols.* keys; returns null when nothing readable can be said.
*/
export function settingSentence(
key: string,
value: Json | undefined,
protocolName?: string,
): string | null {
if (value === undefined && !key.startsWith('protocols.')) return null
switch (key) {
case 'triage.smallGroupMax': return `Petit groupe : jusqu'à ${value} personnes`
case 'triage.collectiveMin': return `Grand corps : à partir de ${value} personnes`
case 'triage.consentMax': return `Tour de consentement : jusqu'à ${value} personnes`
case 'triage.objectionWindowHours': return `Fenêtre d'objection : ${value} heures`
case 'triage.adviceWindowHours': return `Fenêtre d'avis : ${value} heures`
case 'triage.framingDays': return `Temps de formulation : ${value} jours`
case 'triage.concernEscalateRatio':
return `Affluence : traitement obligatoire à partir de ${Math.round(Number(value) * 100)} % de concernés`
case 'triage.recurrenceThreshold':
return `Récurrence : ${value} décisions semblables suggèrent une règle`
case 'triage.reviewDelayDays': return `Épreuve du réel : ${value} jours après adoption`
case 'triage.requireEffects':
return `Matière exigée : ${EFFECTS_LABELS[String(value)] ?? String(value)}`
}
if (key.startsWith('protocols.clauseByInertia.')) {
const preset = key.slice('protocols.clauseByInertia.'.length) as InertiaPreset
const label = INERTIA_LABELS[preset]
return protocolName && label ? `Amender une clause en ${label} : ${protocolName}` : null
}
if (key.startsWith('protocols.')) {
return protocolName ? `Protocole retenu : ${protocolName}` : null
}
return null
}
// ── Full clause view assembly (page /textes/[slug]) ──────────
export interface ClauseViewCtx {
versions: ClauseVersion[]
decisions: Decision[]
people: Person[]
protocols: Protocol[]
settings: CollectiveSettings | null
gauges: Map<Id, ClauseSessionGauge>
isPact: boolean
memberCount: number
}
export interface ClauseView {
clause: Clause
current?: ClauseVersion
proposed: ProposedEntry[]
founding?: ChainEntry
amendments: ChainEntry[]
settingText: string | null
gauge: ClauseSessionGauge | null
/** « Vote WoT standard — 30 jours » — le protocole d'amendement résolu. */
amendProtocol?: string
atelierLink: string | null
protectedClause: boolean
status: { label: string; css: string }
}
/** The amendment protocol a clause resolves to, via the Pact settings. */
function amendProtocolOf(preset: InertiaPreset, ctx: ClauseViewCtx): Protocol | undefined {
const id = ctx.settings?.protocolByRange.clauseByInertia?.[preset]
?? ctx.settings?.protocolByRange.consent
return ctx.protocols.find(p => p.id === id)
}
/** Everything a clause row + detail needs, computed once per clause. */
export function buildClauseView(clause: Clause, ctx: ClauseViewCtx): ClauseView {
const current = currentVersionOf(clause, ctx.versions)
const proposedVersions = proposedVersionsOf(clause, ctx.versions)
const proposed: ProposedEntry[] = proposedVersions.map((version) => {
const decision = ctx.decisions.find(d => d.id === version.decisionId)
const author = ctx.people.find(p => p.id === decision?.authorId)
return {
version,
authorName: author?.displayName ?? 'quelqu\'un du collectif',
...(decision !== undefined
? { decisionId: decision.id, decisionTitle: decision.title }
: {}),
}
})
const chain: ChainEntry[] = ctx.decisions
.filter(d => d.amendsClauseId === clause.id)
.sort((a, b) => ((a.decidedAt ?? a.createdAt) < (b.decidedAt ?? b.createdAt) ? -1 : 1))
.map(d => ({
id: d.id,
title: d.title,
status: d.status,
...(d.decidedAt !== undefined ? { decidedAt: d.decidedAt } : {}),
}))
const [founding, ...amendments] = chain
const protocolNameForValue = typeof current?.settingValue === 'string'
? ctx.protocols.find(p => p.id === current.settingValue)?.name
: undefined
const settingText = ctx.isPact && clause.settingKey
? settingSentence(clause.settingKey, current?.settingValue, protocolNameForValue)
: null
const protocol = amendProtocolOf(clause.inertia, ctx)
const atelierLink = protocol?.method === 'binary'
? `/textes/formules?W=${ctx.memberCount}&M=${protocol.formula.majorityPct}`
+ `&B=${protocol.formula.baseExponent}&G=${protocol.formula.gradientExponent}`
+ `&C=${protocol.formula.constantBase}`
: null
const gauge = ctx.gauges.get(clause.id) ?? null
const status = gauge
? { label: 'en vote', css: 'status-voting' }
: proposed.length > 0
? { label: 'version proposée', css: 'status-framing' }
: { label: 'en vigueur', css: 'status-adopted' }
return {
clause,
...(current !== undefined ? { current } : {}),
proposed,
...(founding !== undefined ? { founding } : {}),
amendments,
settingText,
gauge,
...(protocol !== undefined
? { amendProtocol: `${protocol.name}${protocol.durationDays} jours` }
: {}),
atelierLink,
protectedClause: clause.settingKey?.startsWith('protocols.clauseByInertia.') === true,
status,
}
}