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,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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user