Files
decision/web/src/lib/components/BallotPanel.svelte
T
YvvandClaude Opus 5.5 5d1f8652fc 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>
2026-11-02 00:41:50 +01:00

365 lines
12 KiB
Svelte

<script lang="ts">
// The basket of a vote (Yvv, 2026-10-30: « Je peux voter tout d'un coup, ou une
// 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 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, 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';
import type { BasketState } from '$lib/picks.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Proposal } from '$lib/types';
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import ChoicePicker from './ChoicePicker.svelte';
let {
doc,
basket,
section = null,
onsign,
onread,
}: {
doc: DocState;
basket: BasketState;
/** A section to bring into sight (« Voter cette section »). */
section?: string | null;
onsign: (lines: Line[]) => void;
/** Unfolds the vote of a version under its article. */
onread: (proposalId: string) => void;
} = $props();
const view = $derived(doc.view);
const access = $derived(view?.vote);
// The modules too (ADR 0009): the title first, each order at the end of its section.
const all = $derived(sections(view?.units ?? [], view?.modules));
const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(votableIds(all));
const toSend = $derived(lines(basket.picks, known));
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' });
});
function headline(s: Slot): string {
if (s.unit.kind) return moduleName(s.unit);
const text = [...s.versions, ...s.held].find((p) => p.kind === 'text');
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
}
/** What tells a version from the others of its article. */
function labelOf(p: Proposal, s: Slot): string {
if (p.status === 'in_force') return t('basket.inforce');
// Another title reads as itself; another order, as a proposal (its list unfolds with its vote).
if (p.kind === 'title') return p.title;
if (p.kind === 'order') return p.author?.name ? t('panel.by', { name: p.author.name }) : t('module.order.proposal');
if (p.kind !== 'text') return titleOf(p, s.unit);
if (p.label) return t('deep.variant', { label: p.label });
if (p.author?.name) return t('panel.by', { name: p.author.name });
return relationOf(p, s.unit) || t('panel.proposal');
}
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;
if (access?.way === 'chain') {
onsign(toSend);
return;
}
busy = true;
try {
const n = await voteDirect(doc, toSend);
basket.clear();
toasts.show(i18n.plural('basket.sent', n));
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
</script>
{#snippet bulk(key: string | null, label: string)}
<div class="bulk" role="group" aria-label={label}>
<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>
{#if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{/if}
<div class="whole">
<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}
<div class="sections" bind:this={list}>
{#each all as s, i (s.key ?? `none-${i}`)}
{@const label = sectionLabel(s.key)}
<section class="sec" data-section={s.key ?? ''} class:target={!!section && section === s.key} aria-label={label ?? t('basket.doc')}>
{#if label}
<header>
<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)}
{@const title = headline(slot)}
{@const several = slot.versions.length > 1}
{@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[0]}
<li class="slot" class:several class:waiting={left.includes(slot)}>
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
{#each [...slot.versions, ...slot.inertia] as v (v.id)}
<div class="version">
<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 })}
{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}
</span>
</span>
<ChoicePicker
value={basket.picks[v.id]}
voted={!!v.myVote}
label={t('basket.pick', { title: several || slot.inertia.includes(v) ? `${title} — ${labelOf(v, slot)}` : title })}
disabled={!open}
onpick={(c) => basket.pick(v.id, c)}
/>
</div>
{/each}
{#each slot.held as v (v.id)}
<div class="version held">
<span class="about">
<span class="label">{labelOf(v, slot)}</span>
<span class="faint small">
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
{#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>
<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>
{/each}
</ul>
</section>
{/each}
</div>
<footer class="send">
<p class="count">
{#if tally.total}{i18n.plural('basket.count', tally.total)}{:else}<span class="faint">{t('basket.empty')}</span>{/if}
</p>
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
</button>
{#if open}
<p class="faint small note-send">
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
</p>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</footer>
</section>
<style>
.basket {
display: grid;
gap: 0.9rem;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.4rem;
}
.closed {
font-weight: 700;
color: var(--ink-soft);
}
.whole,
.sec header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 0.8rem;
}
.whole {
padding: 0.7rem 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.bulk {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.note {
font-size: 0.86rem;
font-weight: 700;
color: var(--water);
}
.sections {
display: grid;
gap: 1.2rem;
}
.sec {
display: grid;
gap: 0.5rem;
scroll-margin-top: 0.5rem;
border-radius: var(--r-input);
}
.sec.target {
box-shadow: 0 0 0 2px var(--water-wash);
background: var(--water-wash);
padding: 0.5rem;
margin: -0.5rem;
}
.sec h4 {
color: var(--ink-soft);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.slot {
display: grid;
gap: 0.35rem;
padding: 0.6rem 0.7rem;
border-radius: var(--r-input);
background: var(--surface-2);
}
.slot.several {
background: color-mix(in srgb, var(--surface-2) 70%, var(--water-wash));
}
.slot.waiting {
box-shadow: inset 3px 0 0 var(--water);
}
.title {
justify-self: start;
border: 0;
background: none;
padding: 0;
font-weight: 780;
text-align: left;
color: var(--ink);
}
.title:hover {
color: var(--water);
text-decoration: underline;
text-underline-offset: 3px;
}
.versions-note {
font-size: 0.8rem;
font-weight: 750;
color: var(--water);
}
.version {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.35rem 0.7rem;
}
.about {
display: grid;
min-width: 0;
}
.label {
font-size: 0.86rem;
font-weight: 700;
}
.small {
font-size: 0.8rem;
}
.version.held .label {
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);
color: var(--ochre);
font-size: 0.8rem;
font-weight: 750;
}
.send {
position: sticky;
bottom: -1.6rem;
margin: 0 -1.2rem -1.6rem;
padding: 0.8rem 1.2rem 1.2rem;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.3rem 0.8rem;
background: var(--surface);
box-shadow: 0 -10px 24px -16px rgb(0 0 0 / 0.35);
}
.count {
font-weight: 800;
}
.note-send,
.send .warn {
grid-column: 1 / -1;
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
</style>