From 5d1f8652fc8f752dd36ff21c3833155c4a6251cc Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 00:41:50 +0100 Subject: [PATCH] =?UTF-8?q?v3=20:=20interface=20d'une=20version=20retenue?= =?UTF-8?q?=20=E2=80=94=20=C2=AB=20Contre=20=C2=BB=20et=20=C2=AB=20Retirer?= =?UTF-8?q?=20mon=20vote=20=C2=BB,=20jamais=20=C2=AB=20Pour=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/e2e/objections.spec.ts | 54 ++++++++++++++++--- web/src/lib/basket.ts | 54 +++++++++++++++---- web/src/lib/components/BallotPanel.svelte | 49 ++++++++++++++--- web/src/lib/components/ChoicePicker.svelte | 18 +++++-- web/src/lib/components/VoteFold.svelte | 10 ++-- web/src/lib/i18n/en.ts | 22 ++++---- web/src/lib/i18n/es.ts | 22 ++++---- web/src/lib/i18n/fr.ts | 22 ++++---- web/src/lib/picks.svelte.ts | 15 +++--- .../routes/g/[group]/docs/[doc]/+page.svelte | 2 +- web/tests/unit/basket.test.ts | 34 ++++++++---- 11 files changed, 223 insertions(+), 79 deletions(-) diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index b940ae7..1b7f31c 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -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); }); diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts index e1b1d8d..6761881 100644 --- a/web/src/lib/basket.ts +++ b/web/src/lib/basket.ts @@ -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; + /** The versions one may vote now: those a choice of the basket is sent for. */ -export function votableIds(all: Section[]): Set { - 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): { 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 } { diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte index cec0b73..e15affd 100644 --- a/web/src/lib/components/BallotPanel.svelte +++ b/web/src/lib/components/BallotPanel.svelte @@ -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)}
- - - + + +
{/snippet} +{#snippet skippedNote(key: string | null)} + {#if skipped && skipped.key === key} +

{i18n.plural('basket.held.skipped', skipped.n)}

+ {/if} +{/snippet} +

{t('basket.title')}

{t('basket.intro')}

@@ -108,6 +126,7 @@ {t('basket.doc')} {@render bulk('*', t('basket.doc'))} + {@render skippedNote('*')} {#if left.length}

{i18n.plural('basket.waiting', left.length)}

{/if} @@ -121,6 +140,7 @@

{label}

{@render bulk(s.key, label)} + {@render skippedNote(s.key)} {/if}
    {#each s.slots as slot (slot.unit.id)} @@ -152,9 +172,20 @@
    {labelOf(v, slot)} - {t('tally.short', { for: v.tally.for, against: v.tally.against })} + + {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} + + {t('basket.held')} - {t('basket.held')} + 1 ? `${title} — ${labelOf(v, slot)}` : title })} + disabled={!open} + onpick={(c) => basket.pick(v.id, c)} + />
    {/each} @@ -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); diff --git a/web/src/lib/components/ChoicePicker.svelte b/web/src/lib/components/ChoicePicker.svelte index f6d8971..0c36b64 100644 --- a/web/src/lib/components/ChoicePicker.svelte +++ b/web/src/lib/components/ChoicePicker.svelte @@ -1,6 +1,7 @@ - + {#if !held} + + {/if} {#if voted || value === 'none'} diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index a52586b..92bbd7d 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -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}

    {t('vote.closed')}

    - {:else if p.blocked} - {:else if access && !access.open}

    {t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

    {:else if access}
    - + + {#if !p.blocked} + + {/if} {#if p.myVote}{/if}
    diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f55fc51..53f7543 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -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.', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 8736461..b5a606f 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -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á.', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 96b8fa5..ed9159e 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -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.', diff --git a/web/src/lib/picks.svelte.ts b/web/src/lib/picks.svelte.ts index 6d2ea27..c00e512 100644 --- a/web/src/lib/picks.svelte.ts +++ b/web/src/lib/picks.svelte.ts @@ -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({}); #key = ''; - /** Takes back the basket of this document, keeping only the versions still open to vote. */ - load(docId: string, known: Set) { + /** + * 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) { - 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) { diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 620ef53..9481bc9 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -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; diff --git a/web/tests/unit/basket.test.ts b/web/tests/unit/basket.test.ts index c2bec14..f1abf4a 100644 --- a/web/tests/unit/basket.test.ts +++ b/web/tests/unit/basket.test.ts @@ -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 { 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' }); }); });