forked from yvv/decision
- 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>
422 lines
12 KiB
Svelte
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>
|