v3 : interface des objections, des coquilles validées et de la messagerie ; contrôles C28–C31

Une version retenue par une objection le dit sous l'article, à chacun
selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les
votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé »
pour une redite, « Reformuler » pour une contradiction) ; la personne qui
objecte, lever ou réitérer avant l'échéance. Le panier la montre sans
choix et ne l'envoie jamais.

« Détails » : faire une objection (redit / contredit), les objections et
où elles en sont, les coquilles proposées par d'autres avec leur diff
resserré, valider / refuser / revenir, l'historique dit qui a validé.
Une coquille se propose sur toute version ouverte ou en vigueur.

Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur
le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message
traité n'a plus de geste. Répondre à une objection lit son message.

Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur
sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 04:21:20 +01:00
co-authored by Claude Opus 5.5
parent 3f1f968f16
commit 83f8ca0981
31 changed files with 1732 additions and 197 deletions
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
// ADR 0010): what waits for a gesture of the person — an objection to answer,
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
// deadline. A message dealt with offers no gesture any more. No mail.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import DiffText from '$lib/components/DiffText.svelte';
import Guard from '$lib/components/Guard.svelte';
import { condense } from '$lib/diff';
import { panelSearch } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { answerPanel } from '$lib/objections';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Message } from '$lib/types';
import { errorText } from '$lib/vote';
const box = new Remote(() => api.get<{ unread: number; messages: Message[] }>('/messages'));
onMount(() => {
if (session.me) void box.load();
});
let busy = $state(false);
let error = $state('');
const unread = $derived(box.value?.unread ?? 0);
async function reload() {
await box.load();
session.unread = box.value?.unread ?? session.unread;
}
async function read(m: Message) {
if (m.read) return;
const r = await api.post<{ unread: number }>(`/messages/${m.id}/read`).catch(() => null);
if (r) session.unread = r.unread;
await reload();
}
async function readAll() {
await api.post('/messages/read').catch(() => null);
await reload();
}
/** The text of a message, in the language of the reader, from its subject. */
function textOf(m: Message): string {
const o = m.objection;
const fix = m.typo;
if (o) {
const held = o.heldVersion?.title ?? '';
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
if (m.kind === 'objection_reiterated') return t('messages.objection_reiterated', { name: o.objector ?? '', held });
return t('messages.objection_answered', { held });
}
if (fix) return t(fix.answerer === 'admin' ? 'messages.typo_pending.admin' : 'messages.typo_pending.author', { name: fix.writer ?? '', title: fix.version?.title ?? '' });
return '';
}
function outcomeOf(m: Message): string {
const o = m.outcome;
if (!o) return t('messages.outcome.done');
if (o === 'deadline') return t(m.topic === 'typo' ? 'messages.outcome.deadline.typo' : 'messages.outcome.deadline.objection');
const key = `messages.outcome.${o}`;
return key in OUTCOMES ? t(key as Key) : t('messages.outcome.done');
}
const OUTCOMES: Record<string, true> = Object.fromEntries(
['answered', 'reiterated', 'lifted', 'admin', 'withdrawn', 'applied', 'refused', 'stale'].map((k) => [`messages.outcome.${k}`, true]),
);
/** The version a message is about, in the reader: its details. */
function hrefOf(m: Message): string {
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
if (!v) return base;
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
}
async function gesture(m: Message, g: Message['gestures'][number]) {
error = '';
if (g === 'respond' && m.objection) {
const panel = answerPanel(m.objection, session.me?.id ?? null);
void read(m);
await goto(`/g/${m.doc.groupSlug}/docs/${m.doc.slug}?${panelSearch(panel, null)}`);
return;
}
busy = true;
try {
if ((g === 'lift' || g === 'reiterate') && m.objection) {
await api.post(`/objections/${m.objection.id}/${g}`);
toasts.show(t(g === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
} else if ((g === 'validate' || g === 'refuse') && m.typo) {
await api.post(`/typos/${m.typo.id}/${g}`);
toasts.show(t(g === 'validate' ? 'typo.validated' : 'typo.refused'));
}
await reload();
} catch (e) {
error = errorText(e);
await reload();
} finally {
busy = false;
}
}
function label(m: Message, g: Message['gestures'][number]): string {
switch (g) {
case 'respond':
return m.objection ? t(m.objection.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit') : t('messages.respond');
case 'lift':
return t('objection.lift');
case 'reiterate':
return t('objection.reiterate');
case 'validate':
return t('typo.validate');
case 'refuse':
return t('typo.refuse');
}
}
</script>
<svelte:head><title>{t('messages.title')} · libreDecision</title></svelte:head>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if box.error}
<Guard code={box.error} onretry={() => box.load()} />
{:else}
<div class="page">
<header class="head">
<h1>{t('messages.title')}</h1>
<p class="muted">{t('messages.intro')}</p>
{#if unread}
<button class="btn small" type="button" onclick={readAll}>{t('messages.readAll')}</button>
{/if}
</header>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
{#if box.value}
<ul class="box">
{#each box.value.messages as m (m.id)}
<li class="message" class:unread={!m.read} class:done={m.done}>
<p class="top">
<span class="tag" class:ochre={m.topic === 'objection'} class:water={m.topic === 'typo'}>{t(`messages.topic.${m.topic}`)}</span>
<span class="doc">{m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''}</span>
<span class="faint">{i18n.date(m.createdAt)}</span>
{#if !m.read}<span class="new">{t('messages.unread')}</span>{/if}
</p>
<p class="text">{textOf(m)}</p>
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
{#if m.done}
<p class="outcome">{outcomeOf(m)}</p>
{:else if m.dueAt}
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
{/if}
<div class="actions">
{#if !m.done}
{#each m.gestures as g (g)}
<button class="btn small" class:primary={g === 'respond' || g === 'lift' || g === 'validate'} type="button" disabled={busy} onclick={() => gesture(m, g)}>{label(m, g)}</button>
{/each}
{/if}
<a class="linkish" href={hrefOf(m)} onclick={() => void read(m)}>{t('messages.open')}</a>
{#if !m.read}<button class="linkish" type="button" onclick={() => read(m)}>{t('messages.read')}</button>{/if}
</div>
</li>
{:else}
<li class="empty muted">{t('messages.none')}</li>
{/each}
</ul>
{:else}
<p class="faint">{t('common.loading')}</p>
{/if}
</div>
{/if}
<style>
.page {
max-width: 44rem;
margin: 0 auto;
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 5rem;
display: grid;
gap: 1.4rem;
}
.head {
display: grid;
gap: 0.7rem;
}
.head .btn {
justify-self: start;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.5rem);
}
.box {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.message {
display: grid;
gap: 0.5rem;
padding: 1rem 1.1rem;
border-radius: var(--r-card);
background: var(--surface);
box-shadow: var(--shadow);
}
.message.unread {
background: color-mix(in srgb, var(--surface) 88%, var(--water));
}
.message.done {
opacity: 0.78;
box-shadow: none;
background: var(--surface-2);
}
.top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.6rem;
font-size: 0.84rem;
}
.doc {
font-weight: 750;
}
.new {
margin-left: auto;
padding: 0.1rem 0.55rem;
border-radius: var(--r-pill);
background: var(--water);
color: var(--on-water);
font-size: 0.75rem;
font-weight: 800;
}
.text {
font-size: 1rem;
line-height: 1.5;
}
.reason {
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
font-size: 0.92rem;
}
.due {
font-weight: 750;
color: var(--ochre);
font-size: 0.88rem;
}
.outcome {
font-weight: 700;
color: var(--ink-soft);
font-size: 0.88rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 0.8rem;
}
.linkish {
border: 0;
background: none;
padding: 0.2rem 0;
min-height: 2.25rem;
display: inline-flex;
align-items: center;
color: var(--water);
font-weight: 750;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.empty {
padding: 1rem 0;
}
</style>