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
+12 -4
View File
@@ -17,6 +17,8 @@ export interface Slot {
versions: Proposal[];
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
inertia: Proposal[];
/** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */
held: Proposal[];
}
export interface Section {
@@ -32,12 +34,13 @@ export function sections(units: Unit[]): Section[] {
const open = unit.proposals.filter((p) => p.status === 'open');
const versions = [
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
...open.filter((p) => p.kind !== 'inertia'),
...open.filter((p) => p.kind !== 'inertia' && p.votable !== false),
];
const inertia = open.filter((p) => p.kind === 'inertia');
if (!versions.length && !inertia.length) continue;
const held = open.filter((p) => p.kind !== 'inertia' && p.votable === false);
if (!versions.length && !inertia.length && !held.length) continue;
const last = out.at(-1);
const slot = { unit, versions, inertia };
const slot = { unit, versions, inertia, held };
if (last && last.key === unit.section) last.slots.push(slot);
else out.push({ key: unit.section, slots: [slot] });
}
@@ -59,9 +62,14 @@ export function waiting(slots: Slot[], picks: Picks): Slot[] {
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
}
/** The versions one may vote now: those a choice of the basket is sent for. */
export function votableIds(all: Section[]): Set<string> {
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
}
/** Empties the basket of these articles only. */
export function clearScope(slots: Slot[], picks: Picks): Picks {
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)));
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia, ...s.held].map((p) => p.id)));
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
}
+25 -4
View File
@@ -3,8 +3,9 @@
// section entière, ou un article avec son titre, ou séparément »): choices
// article by article, « tout pour » / « tout contre » for a section or the whole
// document — never for an article whose versions compete —, then one gesture.
// A version held back by an objection is shown, with nothing to choose.
import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
@@ -37,7 +38,7 @@
const access = $derived(view?.vote);
const all = $derived(sections(view?.units ?? []));
const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
const known = $derived(votableIds(all));
const toSend = $derived(lines(basket.picks, known));
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
const left = $derived(waiting(slots, basket.picks));
@@ -51,7 +52,7 @@
});
function headline(s: Slot): string {
const text = s.versions.find((p) => p.kind === 'text');
const text = [...s.versions, ...s.held].find((p) => p.kind === 'text');
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
}
@@ -123,7 +124,7 @@
{#each s.slots as slot (slot.unit.id)}
{@const title = headline(slot)}
{@const several = slot.versions.length > 1}
{@const first = slot.versions[0] ?? slot.inertia[0]}
{@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[0]}
<li class="slot" class:several class:waiting={left.includes(slot)}>
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
@@ -145,6 +146,15 @@
/>
</div>
{/each}
{#each slot.held as v (v.id)}
<div class="version held">
<span class="about">
<span class="label">{labelOf(v, slot)}</span>
<span class="faint small">{t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
</span>
<span class="held-note">{t('basket.held')}</span>
</div>
{/each}
</li>
{/each}
</ul>
@@ -279,6 +289,17 @@
.small {
font-size: 0.8rem;
}
.version.held .label {
color: var(--ink-soft);
}
.held-note {
padding: 0.25rem 0.7rem;
border-radius: var(--r-pill);
background: var(--ochre-wash);
color: var(--ochre);
font-size: 0.8rem;
font-weight: 750;
}
.send {
position: sticky;
bottom: -1.6rem;
+1 -1
View File
@@ -62,7 +62,7 @@
{#snippet marks(p: Proposal)}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet}
+15 -3
View File
@@ -5,7 +5,9 @@
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import { answerPanel } from '$lib/objections';
import type { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang } from '$lib/types';
import type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte';
@@ -77,7 +79,7 @@
<div class="bar">
<div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
{/each}
</div>
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
@@ -91,8 +93,13 @@
{doc}
proposalId={voteId}
{lang}
edit={!!panel.edit}
onvote={(id) => onvote(id, true)}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })}
onanswer={(o) => {
const next = answerPanel(o, session.me?.id ?? null);
if (next) onchange(next);
}}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
@@ -103,7 +110,7 @@
<span class="text">
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<span class="title">{titleOf(v, unit)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
</span>
</button>
</li>
@@ -116,6 +123,8 @@
unitId={panel.unitId}
draftId={panel.draftId ?? null}
prefill={panel.prefill ?? null}
from={panel.objectionId ? (panel.proposalId ?? null) : null}
objectionId={panel.objectionId ?? null}
ondone={(r) =>
r.proposalId && r.submitted
? onvote(r.proposalId, true)
@@ -223,6 +232,9 @@
.small {
font-size: 0.82rem;
}
.held {
color: var(--ochre);
}
@keyframes enter {
from {
transform: translateX(16px);
+3 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
// A few line icons, drawn here rather than pulled from a library.
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus'; size?: number } = $props();
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus' | 'inbox'; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -22,5 +22,7 @@
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
{:else if name === 'plus'}
<path d="M12 5v14M5 12h14" />
{:else if name === 'inbox'}
<path d="M4 13l2.2-7.3A1 1 0 0 1 7.2 5h9.6a1 1 0 0 1 1 .7L20 13v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" /><path d="M4 13h4.5l1 2h5l1-2H20" />
{/if}
</svg>
+20 -3
View File
@@ -16,12 +16,18 @@
unitId,
draftId = null,
prefill = null,
from = null,
objectionId = null,
ondone,
}: {
doc: DocState;
unitId: string | null;
draftId?: string | null;
prefill?: { title: string; body: string } | null;
/** The version whose text the new one starts from (an answer to an objection). */
from?: string | null;
/** The objection this proposal answers (ADR 0010): linked to it when created. */
objectionId?: string | null;
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
} = $props();
@@ -42,16 +48,18 @@
// (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];
const [u0, d0, pf, f0] = [unitId, draftId, prefill, from];
void objectionId;
untrack(() => {
const u = doc.unit(u0);
const d = doc.proposal(d0);
const f = doc.proposal(f0);
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 ?? '';
title = pf?.title ?? d?.title ?? f?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
body = pf?.body ?? d?.body ?? f?.body ?? u?.inForce?.body ?? '';
error = '';
});
});
@@ -93,6 +101,7 @@
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
body: mode === 'remove' ? '' : body,
submit: false,
...(objectionId && mode !== 'new' ? { objectionId } : {}),
});
savedId = r.id;
return r.id;
@@ -152,6 +161,7 @@
</script>
<div class="propose">
{#if objectionId && mode !== 'new' && !draft}<p class="linked">{t('objection.linked')}</p>{/if}
{#if !draft}
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
{#if unit}
@@ -249,6 +259,13 @@
.assist .btn {
justify-self: start;
}
.linked {
padding: 0.7rem 0.9rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
font-weight: 650;
font-size: 0.9rem;
}
.small {
font-size: 0.84rem;
}
+42
View File
@@ -57,6 +57,17 @@
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
<span class="eyebrow">{t('nav.groups')}</span>
</a>
<a
class="messages"
href="/messages"
class:current={path === '/messages'}
aria-current={path === '/messages' ? 'page' : undefined}
aria-label={session.unread ? i18n.plural('nav.messages.count', session.unread) : undefined}
onclick={() => onnavigate?.()}
>
<Icon name="inbox" size={17} /><span>{t('nav.messages')}</span>
{#if session.unread}<span class="unread" aria-hidden="true">{session.unread}</span>{/if}
</a>
{/if}
<ul class="tools">
@@ -146,6 +157,37 @@
.groups.current .eyebrow {
color: var(--ink);
}
.messages {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.5rem;
margin-top: -0.9rem;
padding: 0.5rem 0.7rem;
border-radius: var(--r-input);
color: var(--ink);
font-weight: 700;
text-decoration: none;
transition: background-color 0.15s ease;
}
.messages:hover,
.messages.current {
background: var(--water-wash);
}
.messages.current {
color: var(--water);
}
.unread {
margin-left: auto;
min-width: 1.6rem;
padding: 0.05rem 0.45rem;
border-radius: var(--r-pill);
background: var(--ochre);
color: var(--on-water);
font-size: 0.78rem;
font-weight: 800;
text-align: center;
}
.tools {
list-style: none;
margin: 0;
+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>
+97 -7
View File
@@ -3,16 +3,20 @@
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
// never who voted what —, how far the threshold is, your vote and where it
// stands, then the inertia and the formula. The rest of a version (typos,
// translations, flags, history) lives in the panel, tab « Détails ».
// stands, then the inertia and the formula. A version held back by an
// objection says so, and tells each one what is theirs to do (ADR 0010). The
// rest of a version (typos, translations, objections, history) lives in the
// panel, tab « Détails ».
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, titleOf } from '$lib/labels';
import { holding } from '$lib/objections';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import type { LevelInfo, Proposal, Unit } from '$lib/types';
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import WaterLevel from './WaterLevel.svelte';
@@ -24,6 +28,7 @@
onopen,
onsettings,
ondetails,
onanswer,
onsign,
}: {
doc: DocState;
@@ -33,6 +38,8 @@
onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void;
ondetails: (proposalId: string) => void;
/** The author of a version held back answers its objection: modify the article repeated, or reword. */
onanswer: (objection: Objection) => void;
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
onsign: (lines: Line[]) => void;
} = $props();
@@ -41,6 +48,7 @@
electorate: number;
voteFloor: number;
levels: LevelInfo[];
objections: Objection[];
}
let detail = $state<Detail | null>(null);
@@ -51,7 +59,9 @@
const view = $derived(doc.view);
const access = $derived(view?.vote);
const chain = $derived(access?.way === 'chain');
const votable = $derived(p.status === 'open' || p.status === 'in_force');
const live = $derived(p.status === 'open' || p.status === 'in_force');
/** The objections holding this version back, and what each one asks of whom. */
const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []);
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
const voted = $derived(p.myVote !== null && !p.myVoteStale);
const ballot = $derived(p.myBallot);
@@ -60,6 +70,9 @@
const id = p.id;
void p.tally.for;
void p.tally.against;
void p.blocked;
void p.objections;
void p.revision;
api
.get<Detail>(`/proposals/${id}`)
.then((d) => {
@@ -74,6 +87,22 @@
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
});
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
error = '';
busy = true;
try {
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
await doc.reload();
announce(doc, r.entered ?? []);
void session.refreshUnread();
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
async function cast(choice: 'for' | 'against' | null) {
error = '';
const lines: Line[] = [{ proposalId: p.id, choice }];
@@ -105,7 +134,38 @@
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if p.blocked}
<div class="held" role="status">
<p class="lead">{t('vote.held')}</p>
{#each held as o (o.id)}
<div class="objection">
<p>
<b>{t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })}</b>
<span class="faint">· {t('objection.by', { name: o.objector ?? '' })}</span>
</p>
<p class="reason">{o.reason}</p>
{#if o.mine}
<p>{o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}</p>
{:else if o.phase === 'objector'}
<p>{t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}</p>
{:else if o.iAnswer}
<p>{t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}</p>
{/if}
<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>
</div>
{/each}
</div>
{/if}
<div class="mine" aria-live="polite">
{#if ballot?.status === 'pending'}
@@ -125,8 +185,10 @@
{/if}
</div>
{#if !votable}
{#if !live}
<p class="muted">{t('vote.closed')}</p>
{:else if p.blocked}
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
{:else if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{:else if access}
@@ -247,6 +309,34 @@
font-weight: 700;
font-size: 0.9rem;
}
.held {
display: grid;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
}
.lead {
font-weight: 750;
}
.objection {
display: grid;
gap: 0.35rem;
font-size: 0.92rem;
}
.reason {
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
}
.gestures {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.gestures:empty {
display: none;
}
.more {
display: grid;
gap: 0.9rem;
+31
View File
@@ -0,0 +1,31 @@
// A typo fix is a sign or two in a long text: shown around its changes only,
// a few words on each side, the rest cut between words.
import type { DiffPart } from './types';
function head(text: string, n: number): string {
if (text.length <= n) return text;
let s = text.slice(0, n);
if (!/\s/.test(text[n] ?? ' ') && /\s/.test(s)) s = s.slice(0, s.search(/\s\S*$/));
return s.trimEnd();
}
function tail(text: string, n: number): string {
if (text.length <= n) return text;
let s = text.slice(-n);
if (!/\s/.test(text[text.length - n - 1] ?? ' ') && /\s/.test(s)) s = s.slice(s.search(/\s/) + 1);
return s.trimStart();
}
/** The parts of a diff, the unchanged text kept to `context` signs around each change. */
export function condense(parts: DiffPart[], context = 40): DiffPart[] {
if (!parts.some((p) => p.op !== 'same')) return parts;
const last = parts.length - 1;
return parts.map((p, i) => {
if (p.op !== 'same') return p;
if (i === 0) return p.text.length <= context ? p : { op: 'same', text: `… ${tail(p.text, context)}` };
if (i === last) return p.text.length <= context ? p : { op: 'same', text: `${head(p.text, context)} …` };
if (p.text.length <= 2 * context + 5) return p;
return { op: 'same', text: `${head(p.text, context)} … ${tail(p.text, context)}` };
});
}
+24
View File
@@ -19,6 +19,30 @@ export interface PanelState {
/** The basket, opened on one section. */
section?: string | null;
prefill?: { title: string; body: string } | null;
/** A new proposal that answers this objection (ADR 0010). */
objectionId?: string | null;
/** The details of one's own version, its edit form open: to reword it. */
edit?: boolean;
}
/** The tabs as the URL names them. */
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
/** The query of a reader's URL: the unfolded vote and the panel. */
export function panelSearch(panel: PanelState | null, vote: string | null): string {
const q = new URLSearchParams();
if (vote) q.set('vote', vote);
if (panel) {
q.set('onglet', TAB_NAMES[panel.tab]);
if (panel.unitId) q.set('article', panel.unitId);
if (panel.proposalId) q.set('version', panel.proposalId);
if (panel.draftId) q.set('brouillon', panel.draftId);
if (panel.section) q.set('section', panel.section);
if (panel.objectionId) q.set('objection', panel.objectionId);
if (panel.edit) q.set('modifier', '1');
}
return q.toString();
}
export interface Shown {
+109 -18
View File
@@ -11,6 +11,9 @@ export const en: Messages = {
'nav.mandates': 'Mandates',
'nav.protocols': 'Protocols',
'nav.groups': 'My groups',
'nav.messages': 'Messages',
'nav.messages.count.one': 'Messages, {n} unread',
'nav.messages.count.other': 'Messages, {n} unread',
'nav.docs.count.one': '{label}, {n} document in progress',
'nav.docs.count.other': '{label}, {n} documents in progress',
'nav.access': 'Access',
@@ -137,7 +140,7 @@ export const en: Messages = {
'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia to {level}',
'deep.blocked': 'blocked',
'deep.held': 'not open to vote: objection',
'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming',
'deep.wellrated': 'well-rated',
@@ -198,7 +201,14 @@ export const en: Messages = {
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
'vote.reconfirm': 'Reconfirm: {choice}',
'vote.private': 'Nobody sees your vote: only the totals are visible.',
'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.",
'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.',
'vote.held.redit': 'It repeats “{other}”.',
'vote.held.contredit': 'It contradicts “{other}”.',
'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
'vote.held.author.contredit': 'For it to be voted on again, reword it. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
'vote.held.waiting': 'An answer came: {name} lifts or reiterates the objection by {date}; with no answer, it is lifted.',
'vote.held.mine': 'You raised this objection: lift it when the answer suits you.',
'vote.held.mine.asked': 'An answer came: lift your objection if it suits you, or reiterate it, by {date}. With no answer from you, it is lifted.',
'vote.entered': '“{title}” has come into force.',
'vote.entered.remove': '“{title}” has been removed from the text in force.',
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
@@ -217,9 +227,9 @@ export const en: Messages = {
'vote.choice.none': 'withdrawing your vote',
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.',
'vote.members': 'Only members of this group vote here.',
'vote.details': 'Typos, translations, flags, history',
'vote.details': 'Typos, translations, objections, history',
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
@@ -246,6 +256,7 @@ export const en: Messages = {
'basket.sent.other': '{n} votes saved.',
'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}',
'basket.held': 'not open to vote: an objection holds it back',
'sign.title': 'Vote with your Ğ1 wallet',
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
@@ -327,27 +338,98 @@ export const en: Messages = {
'history.typo': 'typo fixed',
'history.substantive': 'substantive change (votes to reconfirm)',
'history.amendment': 'starting text replaced',
'history.revert': 'fix taken back by the author',
'history.by': 'by {name}',
'history.typo.validated': 'validated by {name}',
'history.typo.deadline': 'applied with no answer within 3 days',
'typo.title': 'Fix a typo',
'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.',
'typo.note': "Accents, capitals, typos, words run together or split: the fix is not voted on. On your own text, it applies at once. On someone else's, its author validates it (an administrator for a seed text); with no answer within 3 days, it applies. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.",
'typo.save': 'Save the fix',
'typo.done': 'Typo fixed, votes untouched.',
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
'typo.asProposal': 'Make it a proposal',
'typo.pending.author': 'Your fix waits for the author of the text: it applies when they validate it, or by itself on {date}.',
'typo.pending.admin': 'Your fix waits for an administrator, as this text comes from the seed: it applies when they validate it, or by itself on {date}.',
'typo.list': 'Suggested typo fixes',
'typo.by': 'suggested by {name}',
'typo.status.pending': 'waiting: applies by itself on {date}',
'typo.status.applied': 'applied',
'typo.status.refused': 'refused',
'typo.status.stale': 'out of date: the text changed meanwhile',
'typo.settled.by': 'by {name}',
'typo.settled.deadline': 'with no answer within 3 days',
'typo.validate': 'Validate',
'typo.refuse': 'Refuse',
'typo.undo': 'Go back to the text before',
'typo.redo': 'Apply after all',
'typo.validated': 'Fix applied.',
'typo.refused': 'Fix refused.',
'signal.title': 'Contradictions',
'signal.none': 'No flags.',
'signal.add': 'Flag a contradiction',
'signal.target': 'Contradicts',
'signal.reason': 'How these two texts contradict each other',
'signal.send': 'Flag',
'signal.sent': 'Flag saved.',
'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.",
'signal.lift': 'Lift',
'signal.lifted': 'lifted',
'signal.lifted.revision': 'lapsed: the text changed',
'signal.by': 'by {name}',
'objection.title': 'Objections',
'objection.none': 'No objection.',
'objection.add': 'Raise an objection',
'objection.kind': 'Your objection',
'objection.kind.redit': 'Repeats',
'objection.kind.contredit': 'Contradicts',
'objection.kind.redit.hint': 'this text repeats another article',
'objection.kind.contredit.hint': 'this text says the opposite of another article',
'objection.target': 'Which article?',
'objection.reason.redit': 'How these two texts say the same thing',
'objection.reason.contredit': 'How these two texts contradict each other',
'objection.send': 'Send the objection',
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.',
'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.',
'objection.says.redit': '“{held}” repeats “{other}”',
'objection.says.contredit': '“{held}” contradicts “{other}”',
'objection.by': 'objection by {name}',
'objection.held.here': 'this version cannot be voted on',
'objection.phase.author': "waiting for the author's answer",
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
'objection.answer.revision': 'answer: the held text was reworded',
'objection.answer.other': 'answer: the other text changed',
'objection.answer.proposal': 'answer: a linked proposal',
'objection.lifted.objector': 'lifted by the person who raised it',
'objection.lifted.admin': 'lifted by an administrator',
'objection.lifted.deadline': 'lifted: 3 days without follow-up',
'objection.lifted.withdrawn': 'lifted: the proposal was withdrawn',
'objection.lifted.revision': 'lapsed: the text changed',
'objection.lift': 'Lift the objection',
'objection.lift.admin': 'Lift the objection (administrator)',
'objection.reiterate': 'Reiterate the objection',
'objection.done.lifted': 'Objection lifted.',
'objection.done.reiterated': 'Objection reiterated: a new answer is awaited.',
'objection.respond.redit': 'Change the article it repeats',
'objection.respond.contredit': 'Reword',
'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.',
'messages.title': 'Messages',
'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.',
'messages.none': 'No messages for now.',
'messages.readAll': 'Mark all as read',
'messages.read': 'Mark as read',
'messages.unread': 'unread',
'messages.due': 'deadline: {date}',
'messages.open': 'See it in the text',
'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Typo',
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.',
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.',
'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.',
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
'messages.respond': 'Answer',
'messages.outcome.answered': 'answered',
'messages.outcome.reiterated': 'objection reiterated',
'messages.outcome.lifted': 'objection lifted',
'messages.outcome.admin': 'objection lifted by an administrator',
'messages.outcome.deadline.objection': 'objection lifted: 3 days without follow-up',
'messages.outcome.deadline.typo': 'fix applied: 3 days without an answer',
'messages.outcome.withdrawn': 'proposal withdrawn',
'messages.outcome.applied': 'fix applied',
'messages.outcome.refused': 'fix refused',
'messages.outcome.stale': 'fix out of date: the text changed',
'messages.outcome.done': 'dealt with',
'propose.mode.replace': 'Change this text',
'propose.mode.remove': 'Remove this article',
@@ -458,7 +540,7 @@ export const en: Messages = {
'error.internal': 'Something failed on our side. Try again in a moment.',
'error.title': 'The title is empty or too long (120 characters max).',
'error.body': 'The text is empty or too long (4,000 characters max).',
'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).',
'error.reason': 'Say in a few words what your objection rests on (10 to 600 characters).',
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
@@ -483,6 +565,15 @@ export const en: Messages = {
'error.author': 'This account is not a Ğ1 address.',
'error.not_votable': 'This version is no longer open for voting.',
'error.members_only': 'Only members of this group vote here.',
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.',
'error.kind': 'Say whether this text repeats the other or contradicts it.',
'error.other': 'Choose another article, in force or proposed.',
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
'error.objection_exists': 'You already have an objection standing on these two texts.',
'error.nothing_to_reiterate': 'Nothing to reiterate: no answer has come yet.',
'error.lifted': 'This objection is already lifted.',
'error.settled': 'This fix is already decided.',
'error.link': 'This proposal cannot answer this objection: it targets another article, or the objection is lifted.',
'error.retry': 'Try again',
'common.cancel': 'Cancel',
+109 -18
View File
@@ -11,6 +11,9 @@ export const es: Messages = {
'nav.mandates': 'Mandatos',
'nav.protocols': 'Protocolos',
'nav.groups': 'Mis grupos',
'nav.messages': 'Mensajes',
'nav.messages.count.one': 'Mensajes, {n} sin leer',
'nav.messages.count.other': 'Mensajes, {n} sin leer',
'nav.docs.count.one': '{label}, {n} documento en curso',
'nav.docs.count.other': '{label}, {n} documentos en curso',
'nav.access': 'Accesos',
@@ -137,7 +140,7 @@ export const es: Messages = {
'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia a {level}',
'deep.blocked': 'bloqueada',
'deep.held': 'no votable: objeción',
'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar',
'deep.wellrated': 'bien valorada',
@@ -198,7 +201,14 @@ export const es: Messages = {
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
'vote.reconfirm': 'Reconfirmar: {choice}',
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.',
'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.',
'vote.held.redit': 'Repite «{other}».',
'vote.held.contredit': 'Contradice «{other}».',
'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
'vote.held.author.contredit': 'Para que vuelva a votarse, reformúlala. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
'vote.held.waiting': 'Llegó una respuesta: {name} levanta o reitera la objeción antes del {date}; sin respuesta, se levanta.',
'vote.held.mine': 'Hiciste esta objeción: levántala cuando la respuesta te convenga.',
'vote.held.mine.asked': 'Llegó una respuesta: levanta tu objeción si te convence, o reitérala, antes del {date}. Sin respuesta tuya, se levanta.',
'vote.entered': '«{title}» ha entrado en vigor.',
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
@@ -217,9 +227,9 @@ export const es: Messages = {
'vote.choice.none': 'retirar tu voto',
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.',
'vote.members': 'Aquí solo votan los miembros de este grupo.',
'vote.details': 'Erratas, traducciones, señalamientos, historial',
'vote.details': 'Erratas, traducciones, objeciones, historial',
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
@@ -246,6 +256,7 @@ export const es: Messages = {
'basket.sent.other': '{n} votos registrados.',
'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}',
'basket.held': 'no votable: una objeción la retiene',
'sign.title': 'Votar con tu monedero Ğ1',
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
@@ -327,27 +338,98 @@ export const es: Messages = {
'history.typo': 'errata corregida',
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
'history.amendment': 'texto de partida sustituido',
'history.revert': 'corrección retirada por el autor',
'history.by': 'por {name}',
'history.typo.validated': 'validada por {name}',
'history.typo.deadline': 'aplicada sin respuesta en 3 días',
'typo.title': 'Corregir una errata',
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección no se vota. En tu texto, se aplica enseguida. En el de otra persona, su autor la valida (un administrador para un texto del seed); sin respuesta en 3 días, se aplica. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
'typo.save': 'Guardar la corrección',
'typo.done': 'Errata corregida, sin tocar los votos.',
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
'typo.asProposal': 'Convertirlo en propuesta',
'typo.pending.author': 'Tu corrección espera al autor del texto: se aplica cuando la valida, o sola el {date}.',
'typo.pending.admin': 'Tu corrección espera a un administrador, porque este texto viene del seed: se aplica cuando la valida, o sola el {date}.',
'typo.list': 'Erratas propuestas',
'typo.by': 'propuesta por {name}',
'typo.status.pending': 'en espera: se aplica sola el {date}',
'typo.status.applied': 'aplicada',
'typo.status.refused': 'rechazada',
'typo.status.stale': 'caducada: el texto cambió entretanto',
'typo.settled.by': 'por {name}',
'typo.settled.deadline': 'sin respuesta en 3 días',
'typo.validate': 'Validar',
'typo.refuse': 'Rechazar',
'typo.undo': 'Volver al texto anterior',
'typo.redo': 'Aplicar al final',
'typo.validated': 'Corrección aplicada.',
'typo.refused': 'Corrección rechazada.',
'signal.title': 'Contradicciones',
'signal.none': 'Ningún aviso.',
'signal.add': 'Señalar una contradicción',
'signal.target': 'Contradice',
'signal.reason': 'En qué se contradicen estos dos textos',
'signal.send': 'Señalar',
'signal.sent': 'Aviso registrado.',
'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.',
'signal.lift': 'Levantar',
'signal.lifted': 'levantado',
'signal.lifted.revision': 'sin efecto: el texto ha cambiado',
'signal.by': 'por {name}',
'objection.title': 'Objeciones',
'objection.none': 'Ninguna objeción.',
'objection.add': 'Hacer una objeción',
'objection.kind': 'Tu objeción',
'objection.kind.redit': 'Repite',
'objection.kind.contredit': 'Contradice',
'objection.kind.redit.hint': 'este texto repite otro artículo',
'objection.kind.contredit.hint': 'este texto dice lo contrario de otro artículo',
'objection.target': '¿Qué artículo?',
'objection.reason.redit': 'En qué dicen lo mismo estos dos textos',
'objection.reason.contredit': 'En qué se contradicen estos dos textos',
'objection.send': 'Enviar la objeción',
'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.',
'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.',
'objection.says.redit': '«{held}» repite «{other}»',
'objection.says.contredit': '«{held}» contradice «{other}»',
'objection.by': 'objeción de {name}',
'objection.held.here': 'esta versión no se vota',
'objection.phase.author': 'en espera de la respuesta del autor',
'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
'objection.answer.other': 'respuesta: el otro texto cambió',
'objection.answer.proposal': 'respuesta: una propuesta vinculada',
'objection.lifted.objector': 'levantada por la persona que la hizo',
'objection.lifted.admin': 'levantada por un administrador',
'objection.lifted.deadline': 'levantada: 3 días sin continuación',
'objection.lifted.withdrawn': 'levantada: la propuesta se retiró',
'objection.lifted.revision': 'caída: el texto cambió',
'objection.lift': 'Levantar la objeción',
'objection.lift.admin': 'Levantar la objeción (administrador)',
'objection.reiterate': 'Reiterar la objeción',
'objection.done.lifted': 'Objeción levantada.',
'objection.done.reiterated': 'Objeción reiterada: se espera una nueva respuesta.',
'objection.respond.redit': 'Modificar el artículo que repite',
'objection.respond.contredit': 'Reformular',
'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.',
'messages.title': 'Mensajes',
'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.',
'messages.none': 'Ningún mensaje por ahora.',
'messages.readAll': 'Marcar todo como leído',
'messages.read': 'Marcar como leído',
'messages.unread': 'sin leer',
'messages.due': 'plazo: {date}',
'messages.open': 'Verlo en el texto',
'messages.topic.objection': 'Objeción',
'messages.topic.typo': 'Errata',
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.',
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.',
'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.',
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
'messages.respond': 'Responder',
'messages.outcome.answered': 'respuesta dada',
'messages.outcome.reiterated': 'objeción reiterada',
'messages.outcome.lifted': 'objeción levantada',
'messages.outcome.admin': 'objeción levantada por un administrador',
'messages.outcome.deadline.objection': 'objeción levantada: 3 días sin continuación',
'messages.outcome.deadline.typo': 'corrección aplicada: 3 días sin respuesta',
'messages.outcome.withdrawn': 'propuesta retirada',
'messages.outcome.applied': 'corrección aplicada',
'messages.outcome.refused': 'corrección rechazada',
'messages.outcome.stale': 'corrección caducada: el texto cambió',
'messages.outcome.done': 'tratado',
'propose.mode.replace': 'Modificar este texto',
'propose.mode.remove': 'Retirar este artículo',
@@ -458,7 +540,7 @@ export const es: Messages = {
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).',
'error.reason': 'Di en pocas palabras en qué se basa tu objeción (de 10 a 600 caracteres).',
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
@@ -483,6 +565,15 @@ export const es: Messages = {
'error.author': 'Esta cuenta no es una dirección Ğ1.',
'error.not_votable': 'Esta versión ya no está en votación.',
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.',
'error.kind': 'Di si este texto repite el otro o lo contradice.',
'error.other': 'Elige otro artículo, en vigor o propuesto.',
'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.',
'error.objection_exists': 'Ya tienes una objeción en curso sobre estos dos textos.',
'error.nothing_to_reiterate': 'Nada que reiterar: aún no ha llegado ninguna respuesta.',
'error.lifted': 'Esta objeción ya está levantada.',
'error.settled': 'Esta corrección ya está decidida.',
'error.link': 'Esta propuesta no puede responder a esta objeción: apunta a otro artículo, o la objeción está levantada.',
'error.retry': 'Reintentar',
'common.cancel': 'Cancelar',
+109 -18
View File
@@ -14,6 +14,9 @@ export const fr = {
'nav.mandates': 'Mandats',
'nav.protocols': 'Protocoles',
'nav.groups': 'Mes groupes',
'nav.messages': 'Messages',
'nav.messages.count.one': 'Messages, {n} non lu',
'nav.messages.count.other': 'Messages, {n} non lus',
'nav.docs.count.one': '{label}, {n} doc en cours',
'nav.docs.count.other': '{label}, {n} docs en cours',
'nav.access': 'Accès',
@@ -140,7 +143,7 @@ export const fr = {
'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie vers {level}',
'deep.blocked': 'bloquée',
'deep.held': 'non votable : objection',
'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer',
'deep.wellrated': 'bien cotée',
@@ -201,7 +204,14 @@ export const fr = {
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
'vote.reconfirm': 'Reconfirmer : {choice}',
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.",
'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.",
'vote.held.redit': 'Elle redit « {other} ».',
'vote.held.contredit': 'Elle contredit « {other} ».',
'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
'vote.held.author.contredit': "Pour qu'elle se vote à nouveau, reformule-la. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
'vote.held.waiting': "Une réponse est arrivée : {name} lève ou réitère l'objection d'ici le {date} ; sans réponse, elle est levée.",
'vote.held.mine': 'Tu as fait cette objection : lève-la quand la réponse te convient.',
'vote.held.mine.asked': "Une réponse est arrivée : lève ton objection si elle te convient, ou réitère-la, d'ici le {date}. Sans réponse de ta part, elle est levée.",
'vote.entered': '« {title} » est entré en vigueur.',
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
@@ -220,9 +230,9 @@ export const fr = {
'vote.choice.none': 'retrait de ton vote',
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection.",
'vote.members': 'Seuls les membres de ce groupe votent ici.',
'vote.details': 'Coquilles, traductions, signalements, historique',
'vote.details': 'Coquilles, traductions, objections, historique',
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
@@ -249,6 +259,7 @@ export const fr = {
'basket.sent.other': '{n} votes enregistrés.',
'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}',
'basket.held': 'non votable : une objection la retient',
'sign.title': 'Voter avec ton portefeuille Ğ1',
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
@@ -330,27 +341,98 @@ export const fr = {
'history.typo': 'coquille corrigée',
'history.substantive': 'changement de fond (votes à reconfirmer)',
'history.amendment': 'texte de départ remplacé',
'history.revert': "correction retirée par l'auteur",
'history.by': 'par {name}',
'history.typo.validated': 'validée par {name}',
'history.typo.deadline': 'appliquée sans réponse sous 3 jours',
'typo.title': 'Corriger une coquille',
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction ne se vote pas. Sur ton texte, elle s'applique tout de suite. Sur celui d'une autre personne, son auteur la valide (un administrateur pour un texte du seed) ; sans réponse sous 3 jours, elle s'applique. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
'typo.save': 'Enregistrer la correction',
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
'typo.asProposal': 'En faire une proposition',
'typo.pending.author': "Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le {date}.",
'typo.pending.admin': "Ta correction attend un administrateur, ce texte venant du seed : elle s'applique quand il la valide, ou d'elle-même le {date}.",
'typo.list': 'Coquilles proposées',
'typo.by': 'proposée par {name}',
'typo.status.pending': "en attente : s'applique d'elle-même le {date}",
'typo.status.applied': 'appliquée',
'typo.status.refused': 'refusée',
'typo.status.stale': 'périmée : le texte a changé entre-temps',
'typo.settled.by': 'par {name}',
'typo.settled.deadline': 'sans réponse sous 3 jours',
'typo.validate': 'Valider',
'typo.refuse': 'Refuser',
'typo.undo': "Revenir au texte d'avant",
'typo.redo': 'Appliquer finalement',
'typo.validated': 'Correction appliquée.',
'typo.refused': 'Correction refusée.',
'signal.title': 'Contradictions',
'signal.none': 'Aucun signalement.',
'signal.add': 'Signaler une contradiction',
'signal.target': 'Contredit',
'signal.reason': 'En quoi ces deux textes se contredisent',
'signal.send': 'Signaler',
'signal.sent': 'Signalement enregistré.',
'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.",
'signal.lift': 'Lever',
'signal.lifted': 'levé',
'signal.lifted.revision': 'tombé : le texte a changé',
'signal.by': 'par {name}',
'objection.title': 'Objections',
'objection.none': 'Aucune objection.',
'objection.add': 'Faire une objection',
'objection.kind': 'Ton objection',
'objection.kind.redit': 'Redit',
'objection.kind.contredit': 'Contredit',
'objection.kind.redit.hint': 'ce texte répète un autre article',
'objection.kind.contredit.hint': "ce texte dit le contraire d'un autre article",
'objection.target': 'Quel article ?',
'objection.reason.redit': 'En quoi ces deux textes disent la même chose',
'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
'objection.send': "Envoyer l'objection",
'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.",
'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.",
'objection.says.redit': '« {held} » redit « {other} »',
'objection.says.contredit': '« {held} » contredit « {other} »',
'objection.by': 'objection de {name}',
'objection.held.here': 'cette version ne se vote plus',
'objection.phase.author': "en attente d'une réponse de l'auteur",
'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même",
'objection.answer.revision': 'réponse : le texte retenu a été reformulé',
'objection.answer.other': "réponse : l'autre texte a changé",
'objection.answer.proposal': 'réponse : une proposition liée',
'objection.lifted.objector': "levée par la personne qui l'a faite",
'objection.lifted.admin': 'levée par un administrateur',
'objection.lifted.deadline': 'levée : 3 jours sans suite',
'objection.lifted.withdrawn': 'levée : la proposition a été retirée',
'objection.lifted.revision': 'tombée : le texte a changé',
'objection.lift': "Lever l'objection",
'objection.lift.admin': "Lever l'objection (administrateur)",
'objection.reiterate': "Réitérer l'objection",
'objection.done.lifted': 'Objection levée.',
'objection.done.reiterated': 'Objection réitérée : une nouvelle réponse est attendue.',
'objection.respond.redit': "Modifier l'article visé",
'objection.respond.contredit': 'Reformuler',
'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.",
'messages.title': 'Messages',
'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.',
'messages.none': "Aucun message pour l'instant.",
'messages.readAll': 'Tout marquer comme lu',
'messages.read': 'Marquer comme lu',
'messages.unread': 'non lu',
'messages.due': 'échéance : {date}',
'messages.open': 'Voir dans le texte',
'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Coquille',
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.",
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.",
'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.',
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
'messages.respond': 'Répondre',
'messages.outcome.answered': 'réponse donnée',
'messages.outcome.reiterated': 'objection réitérée',
'messages.outcome.lifted': 'objection levée',
'messages.outcome.admin': 'objection levée par un administrateur',
'messages.outcome.deadline.objection': 'objection levée : 3 jours sans suite',
'messages.outcome.deadline.typo': 'correction appliquée : 3 jours sans réponse',
'messages.outcome.withdrawn': 'proposition retirée',
'messages.outcome.applied': 'correction appliquée',
'messages.outcome.refused': 'correction refusée',
'messages.outcome.stale': 'correction périmée : le texte a changé',
'messages.outcome.done': 'traité',
'propose.mode.replace': 'Modifier ce texte',
'propose.mode.remove': 'Retirer cet article',
@@ -461,7 +543,7 @@ export const fr = {
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).',
'error.reason': 'Dis en quelques mots ce qui fonde ton objection (10 à 600 signes).',
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
@@ -486,6 +568,15 @@ export const fr = {
'error.author': "Ce compte n'est pas une adresse Ğ1.",
'error.not_votable': 'Cette version ne se vote plus.',
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.",
'error.kind': "Dis si ce texte redit l'autre ou le contredit.",
'error.other': 'Choisis un autre article, en vigueur ou proposé.',
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
'error.objection_exists': 'Tu as déjà une objection en cours sur ces deux textes.',
'error.nothing_to_reiterate': "Rien à réitérer : aucune réponse n'est encore arrivée.",
'error.lifted': 'Cette objection est déjà levée.',
'error.settled': 'Cette correction est déjà tranchée.',
'error.link': 'Cette proposition ne peut pas répondre à cette objection : elle vise un autre article, ou l’objection est levée.',
'error.retry': 'Réessayer',
'common.cancel': 'Annuler',
+28
View File
@@ -0,0 +1,28 @@
// Answering an objection (Yvv, 2026-10-30: « Pour ce qui constitue une
// répétition → modifier l'article cible ; pour une contradiction →
// reformuler »): where the author of the version held back is taken.
import type { PanelState } from './doc.svelte';
import type { Objection } from './types';
/**
* A repetition: a proposal that changes the article repeated, linked to the
* objection, starting from its text. A contradiction: one's own open version is
* reworded in place; a seeded one, by a linked proposal on its article.
*/
export function answerPanel(o: Objection, me: string | null): PanelState | null {
const held = o.heldVersion;
if (!held) return null;
if (o.kind === 'redit') {
return { tab: 'propose', unitId: o.other.unitId, proposalId: o.other.id, objectionId: o.id };
}
if (me && held.authorId === me && held.status === 'open') {
return { tab: 'details', unitId: held.unitId, proposalId: held.id, edit: true };
}
return { tab: 'propose', unitId: held.unitId, proposalId: held.id, objectionId: o.id };
}
/** The objections standing against a version, held back by them. */
export function holding(objections: Objection[]): Objection[] {
return objections.filter((o) => o.held && o.phase !== 'lifted');
}
+28
View File
@@ -1,11 +1,17 @@
import { api } from './api';
import type { Me } from './types';
/** How often the number of unread messages is looked at again, while signed in. */
const UNREAD_EVERY_MS = 60_000;
class Session {
me = $state<Me | null>(null);
ready = $state(false);
g1 = $state(false);
dev = $state(false);
/** Unread messages of one's box, next to « Messages » in the menu (ADR 0010). */
unread = $state(0);
#timer: ReturnType<typeof setInterval> | undefined;
async load() {
const [me, cfg] = await Promise.all([
@@ -16,11 +22,33 @@ class Session {
this.g1 = cfg.g1;
this.dev = cfg.dev;
this.ready = true;
this.watch();
}
/** Looks at the box again: on a navigation, after a gesture, every minute. */
async refreshUnread() {
if (!this.me) {
this.unread = 0;
return;
}
try {
this.unread = (await api.get<{ unread: number }>('/messages/unread')).unread;
} catch {
/* the count waits for the next look */
}
}
/** A sign-in on another page sets `me` directly: the count follows on the next look. */
private watch() {
clearInterval(this.#timer);
void this.refreshUnread();
this.#timer = setInterval(() => void this.refreshUnread(), UNREAD_EVERY_MS);
}
async logout() {
await api.post('/auth/logout');
this.me = null;
this.unread = 0;
}
}
+85 -1
View File
@@ -96,8 +96,12 @@ export interface Proposal {
tally: Tally;
evaluation: Evaluation | null;
votedAtInertia: Inertia;
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
blocked: boolean;
signals: number;
/** May be voted now: open or in force, and not held back (ADR 0010). */
votable: boolean;
/** Standing objections around this version. */
objections: number;
myVote: 'for' | 'against' | null;
myVoteStale: boolean;
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
@@ -215,3 +219,83 @@ export interface Entered {
code: string;
kind: 'text' | 'remove' | 'inertia';
}
export type ObjectionKind = 'redit' | 'contredit';
/** A version as an objection names it. */
export interface VersionRef {
id: string;
title: string | null;
status: Proposal['status'] | null;
unitId: string | null;
code: string | null;
author: string | null;
authorId: string | null;
}
/** An objection, as the session sees it (ADR 0010). */
export interface Objection {
id: string;
kind: ObjectionKind;
reason: string;
objector: string | null;
objectorInvited: boolean;
/** This session raised it. */
mine: boolean;
at: string;
/** The author answers, then the objector lifts or reiterates; or it is lifted. */
phase: 'author' | 'objector' | 'lifted';
cycle: number;
/** While the objector is asked: past this, it is lifted. */
dueAt: string | null;
answer: 'revision' | 'other' | 'proposal' | null;
answerId: string | null;
answeredAt: string | null;
lift: 'objector' | 'admin' | 'deadline' | 'withdrawn' | 'revision' | null;
liftedAt: string | null;
/** The latest arrival of the two, not votable while it stands. */
heldVersion: VersionRef | null;
other: VersionRef;
/** This session may answer by a proposal linked to it (the author of the held version, or anyone for a seeded one). */
iAnswer: boolean;
canLift: boolean;
canReiterate: boolean;
/** Around one version: whether that version is the one held back. */
held?: boolean;
}
/** A typo fix written by someone else than the author (ADR 0010). */
export interface TypoFix {
id: string;
status: 'pending' | 'applied' | 'refused' | 'stale';
writer: string | null;
writerInvited: boolean;
at: string;
dueAt: string;
settledAt: string | null;
/** A name, or `deadline` (passed without an answer), or `changed` (the text moved on). */
settledBy: string | null;
/** Who answers: the author of the version, or an administrator for a seeded text. */
answerer: 'author' | 'admin';
diff: DiffPart[];
gestures: ('validate' | 'refuse')[];
version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null };
}
/** A message of one's box: about an objection or a typo fix, with its gestures now. */
export interface Message {
id: string;
topic: 'objection' | 'typo';
kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending';
cycle: number;
createdAt: string;
dueAt: string | null;
read: boolean;
/** Dealt with: no gesture any more. */
done: boolean;
outcome: string | null;
gestures: ('respond' | 'lift' | 'reiterate' | 'validate' | 'refuse')[];
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
objection?: Objection;
typo?: TypoFix;
}
+2 -1
View File
@@ -22,7 +22,8 @@ export function level(p: Proposal): number {
/**
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
* against. A blocked version comes after the others: it cannot enter into force.
* against. A version held back by an objection comes after the others: it is not
* votable, and cannot enter into force, until the objection is lifted.
*/
export function compare(a: Proposal, b: Proposal): number {
return (
+20 -1
View File
@@ -26,6 +26,7 @@
afterNavigate(() => {
drawer = false;
if (session.ready) void session.refreshUnread();
});
</script>
@@ -43,7 +44,8 @@
<header class="topbar">
<a class="brand" href="/"><Logo size={1.2} /></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
</button>
</header>
@@ -157,6 +159,7 @@
text-decoration: none;
}
.menu {
position: relative;
display: grid;
place-items: center;
width: 2.5rem;
@@ -167,5 +170,21 @@
box-shadow: var(--shadow);
color: var(--ink);
}
/* Unread messages, seen without opening the menu. */
.badge {
position: absolute;
top: -0.3rem;
right: -0.3rem;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: var(--r-pill);
background: var(--ochre);
color: var(--on-water);
font-size: 0.72rem;
font-weight: 800;
line-height: 1.25rem;
text-align: center;
}
}
</style>
@@ -9,10 +9,12 @@
import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { sections } from '$lib/basket';
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { sections, votableIds } from '$lib/basket';
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { answerPanel } from '$lib/objections';
import { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote';
import { articles, type Filter } from '$lib/versions';
@@ -24,23 +26,30 @@
$effect(() => {
void doc.load(group, slug);
});
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
// The basket of this document, kept for the tab; versions no longer open to vote
// (or held back by an objection) leave it.
$effect(() => {
const v = doc.view;
if (!v) return;
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
basket.load(v.doc.id, known);
basket.load(v.doc.id, votableIds(sections(v.units)));
});
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams;
const tab = TABS[q.get('onglet') ?? ''];
if (!tab) return null;
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
return {
unitId: q.get('article'),
proposalId: q.get('version'),
tab,
draftId: q.get('brouillon'),
section: q.get('section'),
prefill,
objectionId: q.get('objection'),
edit: q.get('modifier') === '1',
};
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
const vote = $derived.by(() => {
@@ -50,16 +59,7 @@
function go(nextPanel: PanelState | null, nextVote: string | null) {
prefill = nextPanel?.prefill ?? null;
const q = new URLSearchParams();
if (nextVote) q.set('vote', nextVote);
if (nextPanel) {
q.set('onglet', TAB_NAMES[nextPanel.tab]);
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
if (nextPanel.section) q.set('section', nextPanel.section);
}
const search = q.toString();
const search = panelSearch(nextPanel, nextVote);
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
noScroll: true,
@@ -122,6 +122,7 @@
onopen={(id) => go(panel, id)}
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
onsign={sign}
/>
{/snippet}
+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>