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
+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);