Files
decision/web/src/routes/bulletins/+page.svelte
T
YvvandClaude Opus 5.5 9c8f4cc568 v3 : relecture de 7ffa4fd — un refus du nœud lâche le bulletin, le reçu ne dit vérifié que ce que scelle l'empreinte
polkadot-api lève InvalidTxError (JSON indenté) quand le nœud refuse la transaction :
elle est désormais lue comme « la chaîne a dit non » (rien n'est parti, le bulletin est
lâché), au lieu de rester en attente 24 h. La page /bulletins ne présente comme vérifiés
que la version, la révision et le choix ; l'article, le titre et le bloc du reçu sont dits
hors du sceau. ADR 0008 : trois affirmations corrigées (indexeur sans filtre, débit de la
tâche de fond, lectures qui n'utilisent pas encore shown_tally).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:46:28 +01:00

217 lines
6.6 KiB
Svelte

<script lang="ts">
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
// note promises everyone can check their own vote with its salt: the receipt is
// the ballot itself — its lines and its salt —, for the one who asked only, and
// it is checked here, in the browser, without asking the server anything.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
import { session } from '$lib/session.svelte';
import type { BallotEntry, LangMap } from '$lib/types';
/** A receipt weighs a few kilobytes: a larger file is not one. */
const MAX_FILE = 1_000_000;
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
onMount(() => {
if (session.me) void data.load();
});
let pasted = $state('');
let check = $state<ReceiptCheck | null>(null);
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
function standing(b: BallotEntry): string {
if (b.status === 'pending') return t('ballots.status.pending');
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
return t('ballots.status.expired');
}
async function fromFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
pasted = '';
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
}
function fromText(e: SubmitEvent) {
e.preventDefault();
check = checkReceipt(pasted);
}
</script>
<div class="page">
<h1>{t('ballots.title')}</h1>
<p class="lead">{t('ballots.intro')}</p>
<section aria-labelledby="ballots-mine">
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
{#if !session.me}
<p class="muted">
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
</p>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<ul class="list">
{#each data.value.ballots as b (b.id)}
<li class="card block ballot">
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
<p class="state {b.status}">
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
</p>
<code class="comment">{b.comment}</code>
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
</li>
{:else}
<li class="faint">{t('ballots.none')}</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="ballots-verify">
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
<div class="card block">
<p class="muted">{t('ballots.verify.intro')}</p>
<label class="field">
<span class="label">{t('ballots.verify.file')}</span>
<input type="file" accept="application/json,.json" onchange={fromFile} />
</label>
<form class="paste" onsubmit={fromText}>
<label class="field">
<span class="label">{t('ballots.verify.paste')}</span>
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
</label>
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
</form>
<div class="result" aria-live="polite">
{#if check?.kind === 'ok'}
<p class="ok">{t('ballots.verify.ok')}</p>
<code class="comment">{check.comment}</code>
<ul class="lines">
{#each check.lines as l (l.proposalId)}
<li>
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
</li>
{/each}
</ul>
<p class="faint small">
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
</p>
{:else if check?.kind === 'mismatch'}
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
{:else if check?.kind === 'unreadable'}
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
{/if}
</div>
</div>
</section>
</div>
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
.lead {
color: var(--ink-soft);
}
section {
display: grid;
gap: 0.7rem;
}
.list,
.lines {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.block {
display: grid;
gap: 0.6rem;
padding: 1rem 1.1rem;
}
.ballot {
justify-items: start;
}
.state {
font-weight: 750;
}
.state.confirmed {
color: var(--moss);
}
.state.pending {
color: var(--water);
}
.state.refused,
.state.expired {
color: var(--ochre);
}
.comment {
font-size: 0.8rem;
word-break: break-all;
}
.field {
display: grid;
gap: 0.3rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
}
textarea {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.paste {
display: grid;
gap: 0.6rem;
justify-items: start;
}
.paste .field,
.paste textarea {
width: 100%;
}
.result {
display: grid;
gap: 0.5rem;
}
.lines {
gap: 0.25rem;
font-size: 0.9rem;
}
.unsealed {
display: block;
}
.ok {
font-weight: 800;
color: var(--moss);
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
</style>