v3 : interface Svelte — accueil, groupes, lecteur du texte en vigueur, panneau contextuel, éditeur à assistant, trustinWallet

- Accueil sobre, menu complet à gauche (Décisions, Docs de référence, Mandats,
  Protocoles), intro demandée, quatre sections, connexion Ğ1.
- Lecteur : texte en vigueur en Literata, actions contextuelles par article
  (le vote, propositions, proposer), propositions en cours sous la « surface »
  avec leur niveau d'eau, aperçus « si les bien cotées passent » / « si tout
  passe », langue du texte fr/en/es (traductions éditoriales).
- Panneau : vote pour/contre, seuil et formule (MathML), inertie et son
  changement (voté en inertie +), coquille sans vote, traduction, signalement
  de contradiction, historique des corrections.
- trustinWallet (/acces) : groupes et membres, invitations à usage unique,
  administrateurs, retours d'usage.
- Contrôles : vitest (markdown, catalogues, anti-lexique), Playwright C1–C10 + M1.
- Sections du seed portées jusqu'à l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:58:34 +02:00
co-authored by Claude Opus 5.5
parent 67ddee28b2
commit 7a022dd35e
66 changed files with 7771 additions and 15 deletions
+520
View File
@@ -0,0 +1,520 @@
<script lang="ts">
import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposalId,
lang,
onopen,
onpropose,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
onopen: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
} = $props();
interface Detail {
proposal: Proposal & { author: string | null };
unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | null;
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[];
signals: {
id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
voteFloor: number;
}
let detail = $state<Detail | null>(null);
let busy = $state(false);
let showDiff = $state(false);
let error = $state('');
let inertiaTarget = $state<Inertia | ''>('');
let typoOpen = $state(false);
let typoTitle = $state('');
let typoBody = $state('');
let typoSubstantive = $state(false);
let trTitle = $state('');
let trBody = $state('');
let signalTarget = $state('');
let signalReason = $state('');
const view = $derived(doc.view);
const unit = $derived(doc.unit(detail?.unit.id ?? null));
const p = $derived(detail?.proposal);
const source = $derived(view?.doc.sourceLang ?? 'fr');
const text = $derived(p ? shown(p, lang, source) : null);
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open'));
const signalTargets = $derived(
(view?.units ?? []).flatMap((u) =>
[u.inForce, ...u.proposals]
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
.filter((x) => !(u.id === unit?.id))
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
),
);
async function load() {
try {
detail = await api.get<Detail>(`/proposals/${proposalId}`);
error = '';
const d = detail;
typoTitle = d.proposal.title;
typoBody = d.proposal.body;
typoSubstantive = false;
const tr = d.proposal.translations[lang];
trTitle = tr?.title ?? d.proposal.title;
trBody = tr?.body ?? d.proposal.body;
} catch (e) {
error = message(e);
}
}
$effect(() => {
void proposalId;
void lang;
void load();
});
function message(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
async function refresh() {
await doc.reload();
await load();
}
function announce(entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
async function vote(choice: 'for' | 'against' | null) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
await refresh();
announce(r.entered);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function proposeInertia() {
if (!unit || !inertiaTarget) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget });
inertiaTarget = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) {
e.preventDefault();
busy = true;
typoSubstantive = false;
try {
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
typoOpen = false;
await refresh();
} catch (err) {
if (err instanceof ApiError && (err.code === 'substantive_in_force' || err.code === 'substantive_not_author')) {
typoSubstantive = true;
} else {
error = message(err);
}
} finally {
busy = false;
}
}
async function saveTranslation(e: SubmitEvent) {
e.preventDefault();
busy = true;
try {
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
toasts.show(t('translate.saved'));
await refresh();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function sendSignal(e: SubmitEvent) {
e.preventDefault();
busy = true;
try {
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
signalTarget = '';
signalReason = '';
toasts.show(t('signal.sent'));
await refresh();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function lift(id: string) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
await refresh();
announce(r.entered);
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function withdraw() {
busy = true;
try {
await api.post(`/proposals/${proposalId}/withdraw`);
toasts.show(t('propose.withdrawn'));
await doc.reload();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
</script>
{#if detail && p && text && view}
<article class="vote">
<header>
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
<p class="meta faint">
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
{#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
</p>
</header>
{#if p.kind === 'text'}
{#if detail.diff && showDiff}
<DiffText parts={detail.diff} />
{:else}
<Prose text={text.body} />
{/if}
{#if detail.diff}
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
{/if}
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
{:else if p.kind === 'remove' && detail.inForce}
<del class="removed"><Prose text={detail.inForce.body} /></del>
{/if}
<section class="box">
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if votable}
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<span class="label">{t('vote.yours')}</span>
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
</div>
<p class="faint small">{t('vote.private')}</p>
{:else}
<p class="muted">{t('vote.closed')}</p>
{/if}
</section>
{#if p.kind !== 'inertia' && unit}
<section class="sub">
<h4>{t('inertia.title')} · <span class="muted">{t(`inertia.level.${unit.inertia}`)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each inertiaOpen as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>{t('inertia.change.open', { level: t(`inertia.level.${ip.inertiaTarget ?? 'standard'}`) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="row">
<select class="select" bind:value={inertiaTarget} aria-label={t('inertia.change.to')}>
<option value="" disabled>{t('inertia.change.to')}</option>
{#each ['low', 'standard', 'high', 'max'] as const as level (level)}
{#if level !== unit.inertia}<option value={level}>{t(`inertia.level.${level}`)}</option>{/if}
{/each}
</select>
<button class="btn small" type="button" disabled={!inertiaTarget || busy} onclick={proposeInertia}>{t('inertia.change.submit')}</button>
</div>
<p class="faint small">{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}</p>
</details>
</section>
{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} presets={view.inertia} floor={detail.voteFloor} base={view.doc.formula.base} />
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub">
<details bind:open={typoOpen}>
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
<form class="form" onsubmit={saveTypo}>
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>
{#if typoSubstantive}
<p class="warn">{t('typo.substantive')}</p>
<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>
{/if}
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
</form>
</details>
</section>
{/if}
{#if lang !== source && p.kind === 'text'}
<section class="sub">
<details>
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
<form class="form" onsubmit={saveTranslation}>
<p class="faint small">{t('translate.note')}</p>
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
</form>
</details>
</section>
{/if}
{#if detail.history.length > 1}
<section class="sub">
<h4>{t('history.title')}</h4>
<ol class="history">
{#each detail.history.slice(1) as h (h.at + h.revision)}
<li>
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}</p>
{#if h.diff}<DiffText parts={h.diff} />{/if}
</li>
{/each}
</ol>
</section>
{/if}
{#if p.kind === 'text' && votable}
<section class="sub">
<h4>{t('signal.title')}</h4>
{#each detail.signals as s (s.id)}
<div class="signal" class:lifted={s.liftedAt}>
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
<p class="faint small">
{t('signal.by', { name: s.author })} · {i18n.date(s.at)}
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
</p>
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
</div>
{:else}
<p class="faint small">{t('signal.none')}</p>
{/each}
<details>
<summary>{t('signal.add')}</summary>
<form class="form" onsubmit={sendSignal}>
<p class="faint small">{t('signal.note')}</p>
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
<option value="" disabled>{t('signal.target')}</option>
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
</select>
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
</form>
</details>
</section>
{/if}
{#if p.mine && p.status === 'open'}
<button class="btn quiet small" type="button" disabled={busy} onclick={withdraw}>{t('propose.withdraw')}</button>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</article>
{:else if error}
<p class="warn" role="alert">{error}</p>
{:else}
<p class="faint">{t('common.loading')}</p>
{/if}
<style>
.vote {
display: grid;
gap: 1.1rem;
}
header {
display: grid;
gap: 0.35rem;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.4rem;
}
h4 {
font-size: 0.92rem;
font-weight: 800;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.85rem;
}
.note,
.small {
font-size: 0.84rem;
}
.removed {
opacity: 0.6;
}
.box {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
margin-right: 0.3rem;
}
.btn.on {
background: var(--moss);
color: var(--on-water);
}
.btn.on-against {
background: var(--ochre);
color: var(--on-water);
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
.sub {
display: grid;
gap: 0.5rem;
}
details > summary {
cursor: pointer;
font-weight: 750;
color: var(--water);
font-size: 0.92rem;
}
.row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form {
display: grid;
gap: 0.6rem;
margin-top: 0.6rem;
}
.form .btn {
justify-self: start;
}
.textarea.short {
min-height: 5rem;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.history {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.6rem;
}
.signal {
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
display: grid;
gap: 0.2rem;
}
.signal.lifted {
background: var(--surface-2);
opacity: 0.75;
}
.signal .btn {
justify-self: start;
}
</style>