Files
decision/web/src/lib/components/ChainSign.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

422 lines
12 KiB
Svelte

<script lang="ts">
// A vote on the Ğ1 chain, in one gesture (Yvv, 2026-10-30): the server seals
// the ballot, the person's wallet signs one transaction — the amount to the
// vote account and the seal of the ballot —, the page waits for the chain to
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Ballot, ChainSettings } from '$lib/types';
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
import { geckoLink, qrPath } from '$lib/wallet/gecko';
import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
let {
doc,
chain,
lines,
onsent,
onclose,
}: {
doc: DocState;
chain: ChainSettings;
lines: Line[];
/** The ballot is on its way (or confirmed): the basket may be emptied. */
onsent: () => void;
onclose: () => void;
} = $props();
type Step =
| { kind: 'start' }
| { kind: 'none' }
| { kind: 'wallets'; list: Wallet[] }
| { kind: 'accounts'; wallet: Wallet; list: WalletAccount[] }
| { kind: 'opening' }
| { kind: 'signing' }
| { kind: 'waiting' }
| { kind: 'gecko'; link: string; qr: { size: number; d: string } }
| { kind: 'confirmed'; voter: string | null }
| { kind: 'refused'; reason: Ballot['reason'] }
| { kind: 'expired' }
| { kind: 'failed'; text: string };
const REMEMBER = 'ld:wallet';
let dialog: HTMLDialogElement | undefined = $state();
let step = $state<Step>({ kind: 'start' });
let ballot = $state<Ballot | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
let closed = false;
const amount = $derived(g1Amount(chain.amount));
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
/** Ğecko is offered where nothing is being signed nor waited for. */
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
onMount(() => {
dialog?.showModal();
void start();
});
onDestroy(stop);
function stop() {
clearInterval(timer);
timer = undefined;
}
function close() {
closed = true;
stop();
dialog?.close();
onclose();
}
function remembered(): { id: string; address: string } | null {
try {
return JSON.parse(localStorage.getItem(REMEMBER) ?? 'null');
} catch {
return null;
}
}
function remember(wallet: Wallet | null, account?: WalletAccount) {
try {
if (wallet && account) localStorage.setItem(REMEMBER, JSON.stringify({ id: wallet.id, address: account.address }));
else localStorage.removeItem(REMEMBER);
} catch {
/* private mode */
}
}
async function start() {
stop();
ballot = null;
const list = wallets(chain);
if (!list.length) {
step = { kind: 'none' };
return;
}
const known = remembered();
const usual = list.find((w) => w.id === known?.id);
if (usual) return choose(usual, known?.address);
const [only] = list;
if (only && list.length === 1) return choose(only);
step = { kind: 'wallets', list };
}
async function choose(wallet: Wallet, address?: string) {
step = { kind: 'opening' };
let accounts: WalletAccount[];
try {
accounts = await wallet.accounts();
} catch (e) {
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
return;
}
const usual = accounts.find((a) => a.address === address);
if (usual) return go(wallet, usual);
const [only] = accounts;
if (only && accounts.length === 1) return go(wallet, only);
if (!only) {
step = { kind: 'failed', text: t('sign.error.account') };
return;
}
step = { kind: 'accounts', wallet, list: accounts };
}
async function seal(author: string | null): Promise<Ballot | null> {
try {
const r = await api.post<{ ballot: Ballot }>(`/docs/${doc.view?.doc.id}/ballots`, { lines, author });
return r.ballot;
} catch (e) {
step = { kind: 'failed', text: errorText(e) };
return null;
}
}
async function go(wallet: Wallet, account: WalletAccount) {
remember(wallet, account);
step = { kind: 'opening' };
const b = await seal(account.address);
if (!b) return;
ballot = b;
let sent = false;
try {
step = { kind: 'signing' };
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
if (s === 'signing') step = { kind: 'signing' };
else if (!sent) {
sent = true;
onsent();
wait(b);
}
});
if (!sent) {
sent = true;
onsent();
wait(b);
}
} catch (e) {
const f: Failure = failure(e);
// Once on its way, the chain may still take it: keep waiting.
if (sent && f === 'node') return;
if (nothingSent(f)) {
if (f === 'account') remember(null);
await api.del(`/ballots/${b.id}`).catch(() => undefined);
}
stop();
step = { kind: 'failed', text: t(`sign.error.${f}`) };
}
}
async function gecko() {
step = { kind: 'opening' };
const b = await seal(null);
if (!b) return;
ballot = b;
const link = geckoLink(b.account, b.amount, b.comment);
step = { kind: 'gecko', link, qr: await qrPath(link) };
wait(b);
}
function wait(b: Ballot) {
if (step.kind !== 'gecko') step = { kind: 'waiting' };
// Under the article, the vote now shows as waiting for the chain.
void doc.reload();
poll(b);
}
function poll(b: Ballot) {
stop();
timer = setInterval(async () => {
let now: Ballot;
try {
now = (await api.get<{ ballot: Ballot }>(`/ballots/${b.id}`)).ballot;
} catch {
return;
}
if (now.status === 'pending' || closed) return;
stop();
ballot = now;
await doc.reload();
if (now.status === 'confirmed') {
onsent();
step = { kind: 'confirmed', voter: now.voter };
announce(doc, now.entered);
} else if (now.status === 'refused') {
step = { kind: 'refused', reason: now.reason };
} else {
step = { kind: 'expired' };
}
}, 3000);
}
</script>
<dialog bind:this={dialog} class="sign card" aria-labelledby="sign-title" onclose={() => !closed && close()}>
<h2 id="sign-title">{t('sign.title')}</h2>
<p class="summary">{i18n.plural('sign.summary', lines.length, { amount })}</p>
<div class="step" aria-live="polite">
{#if step.kind === 'start' || step.kind === 'opening'}
<p class="muted">{t('sign.opening')}</p>
{:else if step.kind === 'none'}
<p>{t('sign.none')}</p>
{:else if step.kind === 'wallets'}
<p class="label">{t('sign.wallet')}</p>
<ul class="choices">
{#each step.list as w (w.id)}
<li><button class="btn" type="button" onclick={() => choose(w)}>{w.id}</button></li>
{/each}
</ul>
{:else if step.kind === 'accounts'}
{@const wallet = step.wallet}
<p class="label">{t('sign.account')}</p>
<ul class="choices">
{#each step.list as a (a.address)}
<li>
<button class="btn account" type="button" onclick={() => go(wallet, a)}>
{#if a.name}<span>{a.name}</span>{/if}<code>{short(a.address)}</code>
</button>
</li>
{/each}
</ul>
{:else if step.kind === 'signing'}
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.signing')}</p>
{:else if step.kind === 'waiting'}
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
<p class="faint small">{t('sign.later')}</p>
{:else if step.kind === 'gecko' && ballot}
<p>{t('sign.gecko.scan')}</p>
<div class="gecko">
<svg class="qr" viewBox="0 0 {step.qr.size} {step.qr.size}" role="img" aria-label={t('sign.gecko.qr')} shape-rendering="crispEdges">
<path d={step.qr.d} fill="currentColor" />
</svg>
<dl>
<dt>{t('sign.gecko.amount')}</dt>
<dd>{amount} Ğ1</dd>
<dt>{t('sign.gecko.comment')}</dt>
<dd><code class="comment">{ballot.comment}</code></dd>
</dl>
</div>
<a class="btn primary" href={step.link}>{t('sign.gecko.open')}</a>
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
<p class="faint small">{t('sign.later')}</p>
{:else if step.kind === 'confirmed'}
<p class="ok">{t('sign.confirmed')}</p>
{#if step.voter}<p class="muted">{t('sign.confirmed.as', { name: step.voter })}</p>{/if}
{:else if step.kind === 'refused'}
<p class="warn" role="alert">{t('sign.refused', { reason: t(`ballot.reason.${step.reason ?? 'transfer'}`) })}</p>
{:else if step.kind === 'expired'}
<p class="warn" role="alert">{t('sign.expired')}</p>
{:else if step.kind === 'failed'}
<p class="warn" role="alert">{step.text}</p>
{/if}
</div>
<div class="actions">
{#if geckoOffered}
<button class="btn quiet" type="button" onclick={gecko}>{t('sign.gecko')}</button>
{/if}
{#if step.kind === 'failed' || step.kind === 'expired' || step.kind === 'refused'}
<button class="btn" type="button" onclick={start}>{t('sign.retry')}</button>
{/if}
<button class="btn {step.kind === 'confirmed' ? 'primary' : 'quiet'}" type="button" onclick={close}>{t('sign.close')}</button>
</div>
</dialog>
<style>
.sign {
border: 0;
padding: 1.5rem;
width: min(94vw, 30rem);
max-height: 92dvh;
overflow-y: auto;
color: var(--ink);
display: grid;
gap: 1rem;
}
.sign:not([open]) {
display: none;
}
.sign::backdrop {
background: var(--veil);
}
h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
.summary {
color: var(--ink-soft);
}
.step {
display: grid;
gap: 0.7rem;
}
.label {
font-weight: 800;
font-size: 0.9rem;
}
.choices {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.choices .btn {
width: 100%;
justify-content: flex-start;
}
.account {
gap: 0.7rem;
}
code {
font-size: 0.82rem;
}
.now {
display: flex;
align-items: center;
gap: 0.55rem;
font-weight: 750;
color: var(--water);
}
.dot {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: currentColor;
animation: breathe 1.6s ease-in-out infinite;
}
.gecko {
display: grid;
grid-template-columns: minmax(9rem, 12rem) 1fr;
gap: 1rem;
align-items: center;
}
/* A code to scan reads dark on light, whatever the mood. */
.qr {
width: 100%;
height: auto;
padding: 0.4rem;
border-radius: var(--r-input);
background: #fff;
color: #111;
}
dl {
margin: 0;
display: grid;
gap: 0.15rem;
}
dt {
font-size: 0.78rem;
font-weight: 800;
color: var(--ink-soft);
}
dd {
margin: 0 0 0.5rem;
font-weight: 750;
}
.comment {
word-break: break-all;
font-weight: 600;
}
.ok {
font-weight: 800;
color: var(--moss);
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
@keyframes breathe {
50% {
opacity: 0.35;
}
}
@media (max-width: 480px) {
.gecko {
grid-template-columns: 1fr;
justify-items: center;
}
.qr {
max-width: 13rem;
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
animation: none;
}
}
</style>