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
+265
View File
@@ -0,0 +1,265 @@
<script lang="ts">
import { untrack } from 'svelte';
import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { toasts } from '$lib/toast.svelte';
type Mode = 'replace' | 'remove' | 'new';
type Hint =
| { kind: 'split'; parts: string[] }
| { kind: 'similar'; proposalId: string; unitId: string | null; title: string | null; inForce: boolean; score: number }
| { kind: 'long'; chars: number };
let {
doc,
unitId,
draftId = null,
prefill = null,
ondone,
}: {
doc: DocState;
unitId: string | null;
draftId?: string | null;
prefill?: { title: string; body: string } | null;
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
} = $props();
const draft = $derived(doc.proposal(draftId));
let target = $state<string | null>(null);
let mode = $state<Mode>('replace');
let after = $state<string>('');
let title = $state('');
let body = $state('');
let hints = $state<Hint[]>([]);
let busy = $state(false);
let error = $state('');
let savedId = $state<string | null>(null);
const unit = $derived(doc.unit(target));
const units = $derived(doc.view?.units ?? []);
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
// which would wipe what the person is typing.
$effect(() => {
const [u0, d0, pf] = [unitId, draftId, prefill];
untrack(() => {
const u = doc.unit(u0);
const d = doc.proposal(d0);
target = u0;
savedId = d0;
mode = u0 ? 'replace' : 'new';
after = doc.inForce.at(-1)?.id ?? '';
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
error = '';
});
});
let timer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const text = `${title}\n${body}`;
clearTimeout(timer);
if (mode === 'remove' || body.trim().length < 20 || !doc.view) {
hints = [];
return;
}
const id = doc.view.doc.id;
timer = setTimeout(async () => {
try {
const r = await api.post<{ hints: Hint[] }>(`/docs/${id}/assist`, { text, proposalId: savedId });
hints = r.hints.filter((h) => h.kind !== 'similar' || h.unitId !== target);
} catch {
hints = [];
}
}, 600);
});
function message(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
/** Create the draft, or update it; returns its id. */
async function save(): Promise<string> {
if (!doc.view) throw new ApiError(0, 'internal');
if (savedId) {
await api.patch(`/proposals/${savedId}`, { title, body });
return savedId;
}
const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, {
kind: mode === 'remove' ? 'remove' : 'text',
unitId: mode === 'new' ? null : target,
afterUnitId: mode === 'new' ? after || null : null,
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
body: mode === 'remove' ? '' : body,
submit: false,
});
savedId = r.id;
return r.id;
}
async function keep() {
busy = true;
try {
const id = await save();
await doc.reload();
toasts.show(t('propose.drafted'));
ondone({ proposalId: id, submitted: false });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function submit() {
busy = true;
try {
const id = await save();
await api.post(`/proposals/${id}/submit`);
await doc.reload();
toasts.show(t('propose.submitted'));
ondone({ proposalId: id, submitted: true });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function split(parts: string[]) {
busy = true;
try {
const id = await save();
await api.post(`/proposals/${id}/split`, { parts });
await doc.reload();
toasts.show(t('assist.split.done', { n: parts.length }));
ondone({ proposalId: null, submitted: false });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
function variantOf(h: Extract<Hint, { kind: 'similar' }>) {
if (!h.unitId || savedId) return;
target = h.unitId;
mode = 'replace';
}
const canSubmit = $derived(mode === 'remove' || (title.trim().length > 0 && body.trim().length > 0));
</script>
<div class="propose">
{#if !draft}
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
{#if unit}
<label class:on={mode === 'replace'}><input type="radio" bind:group={mode} value="replace" />{t('propose.mode.replace')}</label>
{#if unit.inForce}<label class:on={mode === 'remove'}><input type="radio" bind:group={mode} value="remove" />{t('propose.mode.remove')}</label>{/if}
{/if}
<label class:on={mode === 'new'}><input type="radio" bind:group={mode} value="new" />{t('propose.mode.new')}</label>
</div>
{/if}
{#if mode === 'new'}
<label class="field">
<span>{t('propose.after')}</span>
<select class="select" bind:value={after}>
{#each units.filter((u) => u.inForce) as u (u.id)}<option value={u.id}>{u.inForce?.title}</option>{/each}
<option value="">{t('propose.after.end')}</option>
</select>
</label>
{/if}
{#if mode === 'remove'}
<p class="muted">{t('propose.remove.note')}</p>
{:else}
<label class="field"><span>{t('propose.title')}</span><input class="input" bind:value={title} maxlength="120" /></label>
<label class="field"><span>{t('propose.body')}</span><textarea class="textarea" bind:value={body} rows="9" maxlength="4000"></textarea></label>
<section class="assist" aria-live="polite">
<h4 class="eyebrow">{t('assist.title')}</h4>
{#each hints as h, i (i)}
{#if h.kind === 'split'}
<p>{t('assist.split', { n: h.parts.length })}</p>
<button class="btn small" type="button" disabled={busy} onclick={() => split(h.parts)}>{t('assist.split.do', { n: h.parts.length })}</button>
{:else if h.kind === 'similar'}
<p>{t('assist.similar', { title: h.title ?? '', where: h.inForce ? t('assist.similar.inForce') : '' })}</p>
{#if h.unitId && !savedId}<button class="btn small" type="button" onclick={() => variantOf(h)}>{t('assist.similar.variant')}</button>{/if}
{:else if h.kind === 'long'}
<p>{t('assist.long', { n: h.chars })}</p>
{/if}
{:else}
<p class="faint">{t('assist.quiet')}</p>
{/each}
</section>
{/if}
<p class="faint small">{t('propose.rule')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions">
<button class="btn" type="button" disabled={busy || !canSubmit} onclick={keep}>{t('propose.draft')}</button>
<button class="btn primary" type="button" disabled={busy || !canSubmit} onclick={submit}>{t('propose.submit')}</button>
</div>
</div>
<style>
.propose {
display: grid;
gap: 1rem;
}
.modes {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.modes label {
display: inline-flex;
align-items: center;
min-height: 2.25rem;
padding: 0.3rem 0.9rem;
border-radius: var(--r-pill);
background: var(--surface-2);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
}
.modes label.on {
background: var(--water);
color: var(--on-water);
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.modes label:has(input:focus-visible) {
outline: 2px solid var(--water);
outline-offset: 2px;
}
.assist {
display: grid;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
font-size: 0.92rem;
}
.assist .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
</style>