v3 : interface d'une version retenue — « Contre » et « Retirer mon vote », jamais « Pour »

Sous l'article, une version retenue montre « Contre » et le retrait, avec la
phrase qui dit pourquoi. Dans le panier, elle se choisit en « contre » ou en
retrait ; « Tout pour » la saute et le dit, « Tout contre » la prend ; un « pour »
gardé d'avant l'objection quitte le panier (basket.kept). Les phrases qui disaient
« ne se vote plus » disent la règle d'Yvv, en fr, en et es. C28 ajusté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-02 00:41:50 +01:00
co-authored by Claude Opus 5.5
parent 238c7db93f
commit 5d1f8652fc
11 changed files with 223 additions and 79 deletions
+46 -8
View File
@@ -37,11 +37,11 @@ async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible();
await expect(page.getByText("Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.")).toBeVisible();
return panel;
}
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
test('C28 — « redit » : plus de « pour » sur la dernière arrivée (contre ou retrait), l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
const aude = await person(browser, 'Aude');
const oscar = await person(browser, 'Oscar');
const vera = await person(browser, 'Vera');
@@ -51,19 +51,57 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
// Oscar: it repeats « Unicité du compte ».
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible();
await expect(panel.getByText('personne ne peut plus voter pour cette version')).toBeVisible();
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
// Vera, who votes: held back, said so, nothing to press; the list marks it.
// Vera, who votes (Yvv, 2026-11-01: « Retirer ou "contre" »): held back, said so;
// « Contre » and the withdrawal, never « Pour »; the list marks it.
await vera.goto(`${TEST_DOC}?vote=${id}`);
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible();
await expect(
fold.getByText("Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là."),
).toBeVisible();
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
const choice = fold.getByRole('group', { name: 'Ton vote', exact: true });
await expect(choice.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection');
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('retenue par une objection');
await fold.scrollIntoViewIfNeeded();
await shots(vera, 'f2-objection-vue-par-qui-vote');
// « Contre », then the withdrawal: the version can only lose votes; « Pour » never comes back.
await choice.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(vera.getByText('Vote enregistré.')).toBeVisible();
await expect(fold.getByText('Ton vote : contre')).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 1 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await choice.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(vera.getByText('Vote retiré.')).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 0 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the basket: « Contre » or the withdrawal, never « Pour »; « Tout pour » skips it
// and says so, « Tout contre » takes it.
await vera.goto(`${TEST_DOC}?onglet=panier`);
const basket = vera.locator('aside.panel');
await expect(basket.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
const pick = basket.getByRole('group', { name: `Ton choix pour « ${title} »` });
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(pick.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
const heldRow = basket.locator('.version.held').filter({ has: vera.getByRole('group', { name: `Ton choix pour « ${title} »` }) });
await expect(heldRow).toContainText('une objection la retient : contre ou retrait, pas de « pour »');
const whole = basket.getByRole('group', { name: 'Tout le document', exact: true });
await whole.getByRole('button', { name: 'Tout pour' }).click();
await expect(basket.getByText(/^Tu as tout mis pour, sauf \d+ versions? qu'une objection retient : sur elles?, tu peux voter contre ou retirer ton vote\.$/)).toBeVisible();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'false');
await shots(vera, 'g1-panier-tout-pour-la-saute');
await whole.getByRole('button', { name: 'Tout contre' }).click();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'true');
await heldRow.scrollIntoViewIfNeeded();
await shots(vera, 'g2-panier-tout-contre-la-prend');
await whole.getByRole('button', { name: 'Vider' }).click();
await expect(basket.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Aude: a message, and the way to answer — modify the article repeated.
await aude.goto(TEST_DOC);
@@ -99,8 +137,8 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
await expect(answered.locator('.btn')).toHaveCount(0);
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
// Vera: it is voted on again.
await vera.reload();
// Vera: it is voted on again, « Pour » too.
await vera.goto(`${TEST_DOC}?vote=${id}`);
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
});
+45 -9
View File
@@ -4,6 +4,10 @@
// whole document — but never an article whose versions compete: there, one
// chooses one's version. Then one gesture sends it all: one write on the
// server, or one transaction on the Ğ1 chain.
//
// A version held back by an objection (ADR 0010) takes against or a withdrawal,
// never a « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour »
// skips it, « tout contre » takes it.
import type { Choice, Modules, Proposal, Unit } from './types';
@@ -17,7 +21,10 @@ 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). */
/**
* Versions held back by an objection (ADR 0010): apart from those in competition,
* against or a withdrawal only until it is lifted — never a « for ».
*/
held: Proposal[];
}
@@ -68,24 +75,55 @@ export function sections(units: Unit[], modules: Modules = { title: null, orders
return out;
}
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */
/**
* « Tout pour » / « tout contre » over some articles: only those with a single version.
* A version held back by an objection: « tout contre » takes it, « tout pour » skips it.
*/
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
const next = { ...picks };
for (const s of slots) {
const [only] = s.versions;
if (only && s.versions.length === 1) next[only.id] = choice;
if (choice === 'against') for (const p of s.held) next[p.id] = choice;
}
return next;
}
/** The versions held back by an objection among these articles: those « tout pour » skips. */
export function heldIn(slots: Slot[]): Proposal[] {
return slots.flatMap((s) => s.held);
}
/** Articles of several versions where no version is chosen yet: the interface shows them. */
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, and how: `free`, any choice; `held`, held back by an
* objection — against or a withdrawal only.
*/
export type Votable = Map<string, 'free' | 'held'>;
/** 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))));
export function votableIds(all: Section[]): Votable {
const out: Votable = new Map();
for (const x of all.flatMap((s) => s.slots)) {
for (const p of [...x.versions, ...x.inertia]) out.set(p.id, 'free');
for (const p of x.held) out.set(p.id, 'held');
}
return out;
}
/** May this choice be sent for this version now? Never a « for » on a held one. */
export function admits(known: Votable, id: string, choice: Choice): boolean {
const way = known.get(id);
return way === 'free' || (way === 'held' && choice !== 'for');
}
/** The choices of the basket that may be sent now: a version gone, or a « for » on a held one, is left out. */
export function kept(picks: Picks, known: Votable): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id, c]) => admits(known, id, c)));
}
/** Empties the basket of these articles only. */
@@ -94,11 +132,9 @@ export function clearScope(slots: Slot[], picks: Picks): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
}
/** What is sent: one line per version still in the document; withdrawing is a null choice. */
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(picks)
.filter(([id]) => known.has(id))
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
/** What is sent: one line per choice that may be sent now (`kept`); withdrawing is a null choice. */
export function lines(picks: Picks, known: Votable): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(kept(picks, known)).map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
}
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
+41 -8
View File
@@ -3,9 +3,11 @@
// 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.
// A version held back by an objection takes against or a withdrawal, never a
// « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour » skips it
// and says so, « tout contre » takes it.
import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
import { clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
@@ -38,12 +40,14 @@
const slots = $derived(all.flatMap((s) => s.slots));
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 tally = $derived(counts(kept(basket.picks, known)));
const left = $derived(waiting(slots, basket.picks));
const open = $derived(!!access?.open);
let busy = $state(false);
let error = $state('');
let list: HTMLElement | undefined = $state();
/** After « tout pour »: where, and how many versions held back by an objection it left aside. */
let skipped = $state<{ key: string | null; n: number } | null>(null);
onMount(() => {
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
@@ -69,6 +73,14 @@
const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? []));
/** « Tout pour » / « tout contre » / « vider » over a scope; « tout pour » says what it skipped. */
function gesture(key: string | null, what: 'for' | 'against' | 'clear') {
const within = scope(key);
basket.set(what === 'clear' ? clearScope(within, basket.picks) : fill(within, what, basket.picks));
const n = what === 'for' ? heldIn(within).length : 0;
skipped = n ? { key, n } : null;
}
async function send() {
error = '';
if (!toSend.length) return;
@@ -91,12 +103,18 @@
{#snippet bulk(key: string | null, label: string)}
<div class="bulk" role="group" aria-label={label}>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button>
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'for')}>{t('basket.for')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'against')}>{t('basket.against')}</button>
<button class="btn quiet small" type="button" onclick={() => gesture(key, 'clear')}>{t('basket.clear')}</button>
</div>
{/snippet}
{#snippet skippedNote(key: string | null)}
{#if skipped && skipped.key === key}
<p class="note" role="status">{i18n.plural('basket.held.skipped', skipped.n)}</p>
{/if}
{/snippet}
<section class="basket" aria-labelledby="basket-title">
<h3 id="basket-title">{t('basket.title')}</h3>
<p class="muted">{t('basket.intro')}</p>
@@ -108,6 +126,7 @@
<span class="eyebrow">{t('basket.doc')}</span>
{@render bulk('*', t('basket.doc'))}
</div>
{@render skippedNote('*')}
{#if left.length}
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
{/if}
@@ -121,6 +140,7 @@
<h4 class="eyebrow">{label}</h4>
{@render bulk(s.key, label)}
</header>
{@render skippedNote(s.key)}
{/if}
<ul>
{#each s.slots as slot (slot.unit.id)}
@@ -152,9 +172,20 @@
<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 class="faint small">
{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="held-note">{t('basket.held')}</span>
</span>
<span class="held-note">{t('basket.held')}</span>
<ChoicePicker
held
value={basket.picks[v.id]}
voted={!!v.myVote}
label={t('basket.pick', { title: slot.versions.length || slot.held.length > 1 ? `${title} — ${labelOf(v, slot)}` : title })}
disabled={!open}
onpick={(c) => basket.pick(v.id, c)}
/>
</div>
{/each}
</li>
@@ -297,6 +328,8 @@
color: var(--ink-soft);
}
.held-note {
justify-self: start;
margin-top: 0.2rem;
padding: 0.25rem 0.7rem;
border-radius: var(--r-pill);
background: var(--ochre-wash);
+15 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
// One choice of the basket: for, against, or withdraw one's vote (when there is
// one). Pressing the chosen one again takes the version out of the basket.
// one). Pressing the chosen one again takes the version out of the basket. A
// version held back by an objection offers no « for » (Yvv, 2026-11-01).
import { t } from '$lib/i18n/index.svelte';
import type { Choice } from '$lib/types';
@@ -8,13 +9,24 @@
value,
voted,
label,
held = false,
disabled = false,
onpick,
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props();
}: {
value: Choice | undefined;
voted: boolean;
label: string;
/** Held back by an objection: against or a withdrawal only. */
held?: boolean;
disabled?: boolean;
onpick: (c: Choice) => void;
} = $props();
</script>
<span class="picker" role="group" aria-label={label}>
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
{#if !held}
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
{/if}
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
{#if voted || value === 'none'}
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
+6 -4
View File
@@ -4,7 +4,8 @@
// 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. A version held back by an
// objection says so, and tells each one what is theirs to do (ADR 0010). The
// objection says so, and tells each one what is theirs to do (ADR 0010); it
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The
// rest of a version (typos, translations, objections, history) lives in the
// panel, tab « Détails ».
import { onMount } from 'svelte';
@@ -200,13 +201,14 @@
{#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}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
<!-- Held back by an objection: against or a withdrawal, never a « for » (Yvv, 2026-11-01). -->
{#if !p.blocked}
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
{/if}
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
</div>
+12 -10
View File
@@ -142,7 +142,7 @@ export const en: Messages = {
'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia to {level}',
'deep.held': 'not open to vote: objection',
'deep.held': 'held back by an objection',
'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming',
'deep.wellrated': 'well-rated',
@@ -231,7 +231,7 @@ 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.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.',
'vote.held': 'An objection is holding this version back: until it is lifted, nobody can vote for it any more. You can vote against it or take your vote back; once lifted, it enters into force only with the votes still there.',
'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.',
@@ -289,7 +289,9 @@ 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',
'basket.held': 'an objection holds it back: against or withdraw, no “for”',
'basket.held.skipped.one': 'You put everything for, except {n} version an objection holds back: on it, you can vote against or take your vote back.',
'basket.held.skipped.other': 'You put everything for, except {n} versions an objection holds back: on them, you can vote against or take your vote 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.',
@@ -443,12 +445,12 @@ export const en: Messages = {
'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.sent': 'Objection saved: until it is lifted, nobody can vote for the later of the two versions any more.',
'objection.note': 'An objection holds back the later of the two versions until it is lifted — never a text in force: nobody can vote for it any more, anyone can vote against it or take their vote back. 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.held.here': 'nobody can vote for this version any more',
'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',
@@ -477,9 +479,9 @@ export const en: Messages = {
'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_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. 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.',
@@ -638,7 +640,7 @@ export const en: Messages = {
'error.not_a_title': 'This module is not the title of the document.',
'error.same_title': 'This is already the title in force.',
'error.section': 'This section does not exist in this document.',
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.',
'error.objection': 'An objection is holding this version back: until it is lifted, you can vote against it or take your vote back, not vote for it.',
'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.',
+12 -10
View File
@@ -142,7 +142,7 @@ export const es: Messages = {
'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia a {level}',
'deep.held': 'no votable: objeción',
'deep.held': 'retenida por una objeción',
'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar',
'deep.wellrated': 'bien valorada',
@@ -231,7 +231,7 @@ 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.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.',
'vote.held': 'Una objeción retiene esta versión: hasta que se levante, nadie puede votar a favor. Puedes votar en contra o retirar tu voto; al levantarse, solo entra en vigor con los votos que sigan ahí.',
'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.',
@@ -289,7 +289,9 @@ 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',
'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»',
'basket.held.skipped.one': 'Lo pusiste todo a favor, salvo {n} versión que una objeción retiene: en ella, puedes votar en contra o retirar tu voto.',
'basket.held.skipped.other': 'Lo pusiste todo a favor, salvo {n} versiones que una objeción retiene: en ellas, puedes votar en contra o retirar tu voto.',
'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.',
@@ -443,12 +445,12 @@ export const es: Messages = {
'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.sent': 'Objeción registrada: hasta que se levante, nadie puede votar a favor de la más reciente de las dos versiones.',
'objection.note': 'Una objeción retiene la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor: nadie puede votar a favor, cada cual puede votar en contra o retirar su voto. 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.held.here': 'nadie puede votar a favor de esta versión',
'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ó',
@@ -477,9 +479,9 @@ export const es: Messages = {
'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_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». 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.',
@@ -638,7 +640,7 @@ export const es: Messages = {
'error.not_a_title': 'Este módulo no es el título del documento.',
'error.same_title': 'Ya es el título en vigor.',
'error.section': 'Esta sección no existe en este documento.',
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.',
'error.objection': 'Una objeción retiene esta versión: hasta que se levante, puedes votar en contra o retirar tu voto, no votar a favor.',
'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á.',
+12 -10
View File
@@ -145,7 +145,7 @@ export const fr = {
'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie vers {level}',
'deep.held': 'non votable : objection',
'deep.held': 'retenue par une objection',
'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer',
'deep.wellrated': 'bien cotée',
@@ -234,7 +234,7 @@ 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.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.",
'vote.held': "Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là.",
'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.",
@@ -292,7 +292,9 @@ 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',
'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »',
'basket.held.skipped.one': "Tu as tout mis pour, sauf {n} version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote.",
'basket.held.skipped.other': "Tu as tout mis pour, sauf {n} versions qu'une objection retient : sur elles, tu peux voter contre ou retirer ton vote.",
'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é.',
@@ -446,12 +448,12 @@ export const fr = {
'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.sent': "Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.",
'objection.note': "Une objection retient la dernière arrivée des deux versions jusqu'à sa levée — jamais un texte en vigueur : personne ne peut plus voter pour elle, chacun peut voter contre ou retirer son vote. 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.held.here': 'personne ne peut plus voter pour cette version',
'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é',
@@ -480,9 +482,9 @@ export const fr = {
'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_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». 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.',
@@ -641,7 +643,7 @@ export const fr = {
'error.not_a_title': "Ce module n'est pas le titre du document.",
'error.same_title': "C'est déjà le titre en vigueur.",
'error.section': "Cette section n'existe pas dans ce document.",
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.",
'error.objection': "Une objection retient cette version : jusqu'à sa levée, tu peux voter contre ou retirer ton vote, pas voter pour.",
'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.',
+9 -6
View File
@@ -1,15 +1,18 @@
// The basket of the reader: the choices not sent yet, kept for the tab (a reload
// or a look at another article does not lose them), one basket per document.
import type { Picks } from './basket';
import { kept, type Picks, type Votable } from './basket';
import type { Choice } from './types';
export class BasketState {
picks = $state<Picks>({});
#key = '';
/** Takes back the basket of this document, keeping only the versions still open to vote. */
load(docId: string, known: Set<string>) {
/**
* Takes back the basket of this document, keeping only the choices still open: a
* version gone leaves it, and so does a « for » on a version an objection holds back.
*/
load(docId: string, known: Votable) {
const key = `ld:basket:${docId}`;
if (key === this.#key) {
this.#keep(known);
@@ -27,9 +30,9 @@ export class BasketState {
this.#keep(known);
}
#keep(known: Set<string>) {
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id)));
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept);
#keep(known: Votable) {
const still = kept(this.picks, known);
if (Object.keys(still).length !== Object.keys(this.picks).length) this.set(still);
}
set(picks: Picks) {
@@ -29,7 +29,7 @@
void doc.load(group, slug);
});
// The basket of this document, kept for the tab; versions no longer open to vote
// (or held back by an objection) leave it.
// leave it, and so does a « for » on a version held back by an objection.
$effect(() => {
const v = doc.view;
if (!v) return;
+24 -10
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types';
import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket';
import type { Choice, Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return {
@@ -84,7 +84,7 @@ describe('the basket of a vote', () => {
});
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']);
const known = votableIds(sections([I1, E2, E4]));
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null },
@@ -93,8 +93,9 @@ describe('the basket of a vote', () => {
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
});
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => {
// E5 has the text in force and a rival held back; E6 is a new article, held back.
it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => {
// Yvv, 2026-11-01: « Retirer ou "contre" ». E5 has the text in force and a rival
// held back; E6 is a new article, held back.
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
prop('e5r', 'E5', { blocked: true, votable: false }),
]);
@@ -103,15 +104,28 @@ describe('the basket of a vote', () => {
const [slotE5, slotE6] = s[0]?.slots ?? [];
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
// A held article stays in sight, with nothing to choose.
// A held article stays in sight, apart from the versions in competition.
expect(slotE6?.versions).toEqual([]);
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
const all = s.flatMap((x) => x.slots);
// The held rival does not compete now: the text in force is the only choice.
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
// The held rival does not compete now: the text in force is the only version to choose.
expect(waiting(all, {})).toEqual([]);
// A choice kept from before the objection is not sent.
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]);
// « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them.
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(heldIn(all).map((p) => p.id)).toEqual(['e5r', 'e6']);
expect(fill(all, 'against', {})).toEqual({ e5: 'against', e5r: 'against', e6: 'against' });
// What may be chosen: anything on a free version; against or withdrawing on a held one.
const known = votableIds(s);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5', c as Choice))).toEqual([true, true, true]);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5r', c as Choice))).toEqual([false, true, true]);
expect(admits(known, 'gone', 'against')).toBe(false);
// A « for » kept from before the objection is neither kept nor sent; against and withdrawing are.
expect(kept({ e5: 'for', e5r: 'for', e6: 'against', gone: 'none' }, known)).toEqual({ e5: 'for', e6: 'against' });
expect(lines({ e5: 'for', e5r: 'for', e6: 'none' }, known)).toEqual([
{ proposalId: 'e5', choice: 'for' },
{ proposalId: 'e6', choice: null },
]);
expect(lines({ e5r: 'against' }, known)).toEqual([{ proposalId: 'e5r', choice: 'against' }]);
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
});
});