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
+274 -84
View File
@@ -1,14 +1,17 @@
<script lang="ts">
// A version in the panel, tab « Détails »: its text and what changed, then
// what is not the vote — typos, translations, flags of contradiction, history.
// The vote itself unfolds under the article (VoteFold).
// what is not the vote — typos (and the fixes waiting for an answer),
// translations, objections, history. The vote itself unfolds under the
// article (VoteFold). Objections and validated typo fixes: ADR 0010.
import { api, ApiError } from '$lib/api';
import { condense } from '$lib/diff';
import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { titleOf } from '$lib/labels';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import { announce } from '$lib/vote';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
import type { DiffPart, Entered, Inertia, Lang, Objection, ObjectionKind, Proposal, TypoFix } from '$lib/types';
import DiffText from './DiffText.svelte';
import Prose from './Prose.svelte';
@@ -16,27 +19,42 @@
doc,
proposalId,
lang,
edit = false,
onvote,
onpropose,
onanswer,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
/** Opened to reword one's own version (an answer to an objection). */
edit?: boolean;
/** Unfolds the vote of this version under its article. */
onvote: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
/** Answers an objection holding a version back: modify the article repeated, or reword. */
onanswer: (objection: Objection) => void;
} = $props();
interface HistoryItem {
revision: number;
kind: 'initial' | 'typo' | 'substantive' | 'amendment' | 'revert';
author: string | null;
authorInvited: boolean;
at: string;
diff: DiffPart[] | null;
/** A fix someone else wrote: who let it in, or that it passed at its deadline. */
typo: { id: string; answeredBy: string | null; deadline: boolean } | null;
}
interface Detail {
proposal: Proposal & { author: string | null; authorInvited: boolean };
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' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
signals: {
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
history: HistoryItem[];
objections: Objection[];
typos: TypoFix[];
}
let detail = $state<Detail | null>(null);
@@ -49,20 +67,23 @@
let typoSubstantive = $state(false);
let trTitle = $state('');
let trBody = $state('');
let signalTarget = $state('');
let signalReason = $state('');
let objectionKind = $state<ObjectionKind>('contredit');
let objectionTarget = $state('');
let objectionReason = $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 signalTargets = $derived(
/** Open or in force: fixed, translated, objected to — whether held back or not. */
const live = $derived(p?.status === 'open' || p?.status === 'in_force');
/** The versions an objection may name: another article's, open or in force — never two texts in force. */
const objectionTargets = $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))
.filter((x): x is Proposal => !!x && x.id !== p?.id && (x.status === 'open' || x.status === 'in_force') && x.kind === 'text')
.filter((x) => u.id !== unit?.id && !(x.status === 'in_force' && p?.status === 'in_force'))
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
),
);
@@ -75,6 +96,7 @@
typoTitle = d.proposal.title;
typoBody = d.proposal.body;
typoSubstantive = false;
if (edit && d.proposal.mine && d.proposal.status === 'open') typoOpen = true;
const tr = d.proposal.translations[lang];
trTitle = tr?.title ?? d.proposal.title;
trBody = tr?.body ?? d.proposal.body;
@@ -86,6 +108,7 @@
$effect(() => {
void proposalId;
void lang;
void edit;
void load();
});
@@ -96,15 +119,36 @@
async function refresh() {
await doc.reload();
await load();
void session.refreshUnread();
}
async function run(fn: () => Promise<void>) {
busy = true;
error = '';
try {
await fn();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
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'));
const r = await api.patch<{ edit: string; typo?: { id: string; dueAt: string; answerer: 'author' | 'admin' } }>(
`/proposals/${proposalId}`,
{ title: typoTitle, body: typoBody },
);
if (r.edit === 'typo_pending' && r.typo) {
toasts.show(t(r.typo.answerer === 'admin' ? 'typo.pending.admin' : 'typo.pending.author', { date: i18n.date(r.typo.dueAt) }), 'info', 7000);
} else {
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
}
typoOpen = false;
await refresh();
} catch (err) {
@@ -118,60 +162,72 @@
}
}
async function answerTypo(fix: TypoFix, gesture: 'validate' | 'refuse') {
await run(async () => {
await api.post(`/typos/${fix.id}/${gesture}`);
toasts.show(t(gesture === 'validate' ? 'typo.validated' : 'typo.refused'));
await refresh();
});
}
async function saveTranslation(e: SubmitEvent) {
e.preventDefault();
busy = true;
try {
await run(async () => {
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) {
async function raise(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 run(async () => {
await api.post(`/proposals/${proposalId}/objections`, { otherId: objectionTarget, kind: objectionKind, reason: objectionReason });
objectionTarget = '';
objectionReason = '';
toasts.show(t('objection.sent'), 'info', 6000);
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`);
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
await run(async () => {
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
await refresh();
announce(doc, r.entered);
} catch (err) {
error = message(err);
} finally {
busy = false;
}
announce(doc, r.entered ?? []);
});
}
async function withdraw() {
busy = true;
try {
await run(async () => {
await api.post(`/proposals/${proposalId}/withdraw`);
toasts.show(t('propose.withdrawn'));
await doc.reload();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
});
}
/** Where an objection stands, in a few words. */
function stateOf(o: Objection): string {
if (o.phase === 'lifted') return t(`objection.lifted.${o.lift ?? 'objector'}` as Key);
if (o.phase === 'objector') return t('objection.phase.objector', { date: i18n.date(o.dueAt) });
return t('objection.phase.author');
}
/** What became of a fix, or when it applies by itself. */
function typoState(fix: TypoFix): string {
if (fix.status === 'pending') return t('typo.status.pending', { date: i18n.date(fix.dueAt) });
if (fix.status === 'stale') return t('typo.status.stale');
const what = t(`typo.status.${fix.status}`);
if (fix.settledBy === 'deadline') return `${what} · ${t('typo.settled.deadline')}`;
if (fix.settledBy && fix.settledBy !== 'changed') return `${what} · ${t('typo.settled.by', { name: fix.settledBy })}`;
return what;
}
function typoGesture(fix: TypoFix, g: 'validate' | 'refuse'): string {
if (fix.status === 'applied') return t('typo.undo');
if (fix.status === 'refused') return t('typo.redo');
return t(g === 'validate' ? 'typo.validate' : 'typo.refuse');
}
</script>
@@ -184,6 +240,7 @@
{#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.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
</p>
</header>
@@ -202,14 +259,14 @@
<del class="removed"><Prose text={detail.inForce.body} /></del>
{/if}
{#if votable}
{#if live}
<p class="tally faint">
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
</p>
{/if}
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
{#if p.kind === 'text' && live}
<section class="sub">
<details bind:open={typoOpen}>
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
@@ -227,6 +284,30 @@
</section>
{/if}
{#if detail.typos.length}
<section class="sub" aria-labelledby="typos-{p.id}">
<h4 id="typos-{p.id}">{t('typo.list')}</h4>
<ul class="fixes">
{#each detail.typos as fix (fix.id)}
<li class="fix {fix.status}">
<DiffText parts={condense(fix.diff)} />
<p class="faint small">
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)}
</p>
<p class="small state">{typoState(fix)}</p>
{#if fix.gestures.length}
<div class="gestures">
{#each fix.gestures as g (g)}
<button class="btn small" class:primary={g === 'validate' && fix.status === 'pending'} type="button" disabled={busy} onclick={() => answerTypo(fix, g)}>{typoGesture(fix, g)}</button>
{/each}
</div>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if lang !== source && p.kind === 'text'}
<section class="sub">
<details>
@@ -247,7 +328,10 @@
<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 h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
<p class="small">
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
{#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}
</p>
{#if h.diff}<DiffText parts={h.diff} />{/if}
</li>
{/each}
@@ -255,33 +339,69 @@
</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 })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
{#if p.kind === 'text' && (live || detail.objections.length)}
<section class="sub" aria-labelledby="objections-{p.id}">
<h4 id="objections-{p.id}">{t('objection.title')}</h4>
{#each detail.objections as o (o.id)}
{@const standing = o.phase !== 'lifted'}
<div class="objection" class:lifted={!standing}>
<p class="small">
<span class="tag" class:ochre={standing}>{t(`objection.kind.${o.kind}`)}</span>
<b>{t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}</b>
</p>
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
<p class="reason">{o.reason}</p>
<p class="faint small">
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)}
· {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if}
</p>
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
{#if standing}
<div class="gestures">
{#if o.iAnswer && !o.mine && o.phase === 'author'}
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
{/if}
{#if o.canLift}
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
{/if}
{#if o.canReiterate}
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
{/if}
</div>
{/if}
</div>
{:else}
<p class="faint small">{t('signal.none')}</p>
<p class="faint small">{t('objection.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>
{#if live && objectionTargets.length}
<details>
<summary>{t('objection.add')}</summary>
<form class="form" onsubmit={raise}>
<p class="faint small">{t('objection.note')}</p>
<div class="kinds" role="radiogroup" aria-label={t('objection.kind')}>
{#each ['redit', 'contredit'] as const as k (k)}
<label class:on={objectionKind === k}>
<input type="radio" bind:group={objectionKind} value={k} />
<b>{t(`objection.kind.${k}`)}</b><span class="hint">{t(`objection.kind.${k}.hint`)}</span>
</label>
{/each}
</div>
<select class="select" bind:value={objectionTarget} required aria-label={t('objection.target')}>
<option value="" disabled>{t('objection.target')}</option>
{#each objectionTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
</select>
<textarea
class="textarea short"
bind:value={objectionReason}
rows="3"
minlength="10"
maxlength="600"
placeholder={t(`objection.reason.${objectionKind}`)}
aria-label={t(`objection.reason.${objectionKind}`)}
></textarea>
<button class="btn small" type="submit" disabled={busy || !objectionTarget || objectionReason.trim().length < 10}>{t('objection.send')}</button>
</form>
</details>
{/if}
</section>
{/if}
@@ -375,18 +495,88 @@
display: grid;
gap: 0.6rem;
}
.signal {
.fixes {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.fix,
.objection {
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
display: grid;
gap: 0.2rem;
gap: 0.25rem;
}
.signal.lifted {
.fix {
background: var(--water-wash);
}
.fix.pending .state {
font-weight: 750;
color: var(--water);
}
.fix.refused,
.fix.stale {
background: var(--surface-2);
opacity: 0.8;
}
.objection {
background: var(--ochre-wash);
}
.objection.lifted {
background: var(--surface-2);
opacity: 0.75;
}
.signal .btn {
justify-self: start;
.reason {
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
font-size: 0.92rem;
}
.held {
font-weight: 750;
color: var(--ochre);
}
.gestures {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.2rem;
}
.kinds {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.4rem;
}
.kinds label {
display: grid;
gap: 0.1rem;
padding: 0.55rem 0.8rem;
border-radius: var(--r-input);
background: var(--surface-2);
cursor: pointer;
font-size: 0.9rem;
}
.kinds label.on {
background: var(--ochre);
color: var(--on-water);
}
.kinds label.on .hint {
color: inherit;
opacity: 0.85;
}
.kinds input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.kinds label:has(input:focus-visible) {
outline: 2px solid var(--water);
outline-offset: 2px;
}
.hint {
font-size: 0.8rem;
color: var(--ink-soft);
}
</style>