Files
decision/web/src/lib/components/BallotPanel.svelte
T
YvvandClaude Opus 5.5 b260596cfd v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:47:26 +01:00

307 lines
9.6 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.
import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
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 SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
const view = $derived(doc.view);
const access = $derived(view?.vote);
const all = $derived(sections(view?.units ?? []));
const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
const toSend = $derived(lines(basket.picks, known));
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
const left = $derived(waiting(slots, basket.picks));
const open = $derived(!!access?.open);
let busy = $state(false);
let error = $state('');
let list: HTMLElement | undefined = $state();
onMount(() => {
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
});
function headline(s: Slot): string {
const text = s.versions.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');
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 ?? []));
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={() => 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>
</div>
{/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>
{#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>
{/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]}
<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}
</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>
<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 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;
}
.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>