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>
This commit is contained in:
Yvv
2026-10-30 22:47:26 +01:00
co-authored by Claude Opus 5.5
parent 3541e1b1a6
commit b260596cfd
36 changed files with 4353 additions and 234 deletions
+83
View File
@@ -0,0 +1,83 @@
// 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 »). One chooses
// article by article; « tout pour » or « tout contre » fills a section or the
// whole document — but never an article whose versions compete: there, one
// chooses one's version. Then one gesture sends it all: one write on the
// server, or one transaction on the Ğ1 chain.
import type { Choice, Proposal, Unit } from './types';
/** A choice in the basket: for, against, or withdrawing one's vote. Absent: nothing. */
export type Picks = Record<string, Choice>;
/** An article as the basket shows it. */
export interface Slot {
unit: Unit;
/** The versions in competition: the text in force, the open texts and removals. */
versions: Proposal[];
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
inertia: Proposal[];
}
export interface Section {
/** The section of the seed (introduction, fondamental…), or null. */
key: string | null;
slots: Slot[];
}
/** The articles of the document with something to vote, by section, in document order. */
export function sections(units: Unit[]): Section[] {
const out: Section[] = [];
for (const unit of units) {
const open = unit.proposals.filter((p) => p.status === 'open');
const versions = [
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
...open.filter((p) => p.kind !== 'inertia'),
];
const inertia = open.filter((p) => p.kind === 'inertia');
if (!versions.length && !inertia.length) continue;
const last = out.at(-1);
const slot = { unit, versions, inertia };
if (last && last.key === unit.section) last.slots.push(slot);
else out.push({ key: unit.section, slots: [slot] });
}
return out;
}
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
const next = { ...picks };
for (const s of slots) {
const [only] = s.versions;
if (only && s.versions.length === 1) next[only.id] = choice;
}
return next;
}
/** Articles of several versions where no version is chosen yet: the interface shows them. */
export function waiting(slots: Slot[], picks: Picks): Slot[] {
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
}
/** Empties the basket of these articles only. */
export function clearScope(slots: Slot[], picks: Picks): Picks {
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)));
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
}
/** What is sent: one line per version still in the document; withdrawing is a null choice. */
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(picks)
.filter(([id]) => known.has(id))
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
}
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
const all = Object.values(picks);
return {
for: all.filter((c) => c === 'for').length,
against: all.filter((c) => c === 'against').length,
none: all.filter((c) => c === 'none').length,
total: all.length,
};
}
+306
View File
@@ -0,0 +1,306 @@
<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>
+421
View File
@@ -0,0 +1,421 @@
<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>
@@ -0,0 +1,73 @@
<script lang="ts">
// One choice of the basket: for, against, or withdraw one's vote (when there is
// one). Pressing the chosen one again takes the version out of the basket.
import { t } from '$lib/i18n/index.svelte';
import type { Choice } from '$lib/types';
let {
value,
voted,
label,
disabled = false,
onpick,
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props();
</script>
<span class="picker" role="group" aria-label={label}>
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
{#if voted || value === 'none'}
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
{/if}
</span>
<style>
.picker {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
}
button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.3rem 0.75rem;
min-height: 2.25rem;
min-width: 2.75rem;
font-weight: 750;
font-size: 0.84rem;
color: var(--ink-soft);
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
}
button:hover:not(:disabled) {
color: var(--ink);
background: var(--surface);
}
.for[aria-pressed='true'],
.for[aria-pressed='true']:hover {
background: var(--moss);
color: var(--on-water);
}
.against[aria-pressed='true'],
.against[aria-pressed='true']:hover {
background: var(--ochre);
color: var(--on-water);
}
.none[aria-pressed='true'],
.none[aria-pressed='true']:hover {
background: var(--ink-soft);
color: var(--surface);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
button {
transition: none;
}
}
</style>
+52 -12
View File
@@ -1,8 +1,9 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version opens its vote; one with several shows the best of them and
// unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote.
// single version unfolds its vote below it; one with several shows the best of
// them and unfolds the others, each with its label, its water level and its
// marks — and each unfolds its own vote.
import type { Snippet } from 'svelte';
import { plain } from '$lib/markdown';
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
@@ -13,16 +14,26 @@
let {
articles,
selected,
onopen,
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
vote,
ontoggle,
fold,
onsection,
}: {
articles: Article[];
/** The version whose vote is unfolded. */
vote: string | null;
ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: 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);
// Unfolded by hand, or because the version open in the panel is inside.
// Unfolded by hand, or because the version whose vote is unfolded is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
@@ -61,11 +72,17 @@
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
<li class="section">
<span class="eyebrow">{sectionLabel(unit.section)}</span>
{#if onsection && unit.section}
{@const key = unit.section}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</li>
{/if}
{#if best && a.versions.length === 1}
<li class="article">
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
@@ -78,6 +95,7 @@
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const open = isOpen(a)}
@@ -105,7 +123,7 @@
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
@@ -116,6 +134,7 @@
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
@@ -234,9 +253,30 @@
color: var(--ink-soft);
}
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.3rem 1rem;
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.fold {
padding: 0 1rem 1rem;
}
.section:first-child {
margin-top: 0.4rem;
}
+39 -11
View File
@@ -1,21 +1,43 @@
<script lang="ts">
// The side panel: the basket of the vote, the versions of an article, a new
// proposal, the details of a version, the settings of the formula. The vote of
// one version unfolds under its article, not here.
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import type { BasketState } from '$lib/picks.svelte';
import type { Lang } from '$lib/types';
import type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte';
import VersionDetails from './VersionDetails.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
panel,
lang,
basket,
vote,
onchange,
onclose,
}: { doc: DocState; panel: PanelState; lang: Lang; onchange: (p: PanelState) => void; onclose: () => void } = $props();
onvote,
onsign,
}: {
doc: DocState;
panel: PanelState;
lang: Lang;
basket: BasketState;
/** The version whose vote is unfolded under its article. */
vote: string | null;
onchange: (p: PanelState) => void;
onclose: () => void;
/** Unfolds the vote of a version under its article; `close` closes the panel. */
onvote: (proposalId: string, close: boolean) => void;
onsign: (lines: Line[]) => void;
} = $props();
const unit = $derived(doc.unit(panel.unitId));
const versions = $derived(
@@ -25,9 +47,11 @@
const tabs = $derived(
(
[
['vote', t('panel.vote'), !!voteId],
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true],
// A version: reached from its vote, or from the list of versions.
['details', t('panel.details'), !!panel.proposalId],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const
@@ -49,7 +73,7 @@
});
</script>
<aside class="panel card" aria-label={t('panel.vote')}>
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
<div class="bar">
<div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)}
@@ -60,25 +84,26 @@
</div>
<div class="body" bind:this={body}>
{#if panel.tab === 'vote' && voteId}
<VoteView
{#if panel.tab === 'basket'}
<BallotPanel {doc} {basket} section={panel.section ?? null} {onsign} onread={(id) => onvote(id, false)} />
{:else if panel.tab === 'details' && voteId}
<VersionDetails
{doc}
proposalId={voteId}
{lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onvote={(id) => onvote(id, true)}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
{#each versions as v (v.id)}
<li>
<button type="button" class="version" class:current={v.id === voteId} onclick={() => onchange({ ...panel, proposalId: v.id, tab: 'vote' })}>
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="text">
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<span class="title">{titleOf(v, unit)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {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>
</button>
</li>
@@ -91,7 +116,10 @@
unitId={panel.unitId}
draftId={panel.draftId ?? null}
prefill={panel.prefill ?? null}
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
ondone={(r) =>
r.proposalId && r.submitted
? onvote(r.proposalId, true)
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
/>
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
+33 -5
View File
@@ -31,6 +31,17 @@
/** A tool is highlighted only when one is in it. */
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
// A radio group: one stop for Tab, the arrows move to the next mood and choose it.
function onMoodKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key];
if (!step) return;
e.preventDefault();
const next = MOODS[(MOODS.indexOf(mood.current) + step + MOODS.length) % MOODS.length];
if (!next) return;
mood.set(next);
(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-mood="${next}"]`)?.focus();
}
async function signOut() {
await session.logout();
groups.reset();
@@ -85,7 +96,7 @@
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each}
</div>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')} tabindex="-1" onkeydown={onMoodKey}>
{#each MOODS as m (m)}
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
<button
@@ -96,6 +107,7 @@
aria-checked={mood.current === m}
aria-label={t(`mood.${m}`)}
title={t(`mood.${m}`)}
tabindex={mood.current === m ? 0 : -1}
onclick={() => mood.set(m)}
></button>
{/each}
@@ -242,7 +254,15 @@
.moods {
display: inline-flex;
align-items: center;
gap: 0.3rem;
gap: 0.6rem;
padding-inline: 0.2rem;
/* Taken here, in the mood of the page, before each swatch carries its own:
the rings read against the page whatever the colour of the swatch. */
--page-ink: var(--ink);
--page-ring: var(--water);
}
.moods:focus {
outline: none;
}
.swatch {
position: relative;
@@ -262,10 +282,18 @@
border-radius: 50%;
background: var(--cle);
}
/* Chosen: a ring of the page's ink, close to the swatch. */
.swatch[aria-checked='true'] {
/* currentColor is the ink of the page, inherited. */
outline: 2px solid currentColor;
outline-offset: 2px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent),
0 0 0 2px var(--page-ink);
}
/* Focused: further out, in the page's accent (3:1 or more on the page in every
mood), and dashed: never mistaken for the chosen one. */
.swatch:focus-visible {
outline: 2px dashed var(--page-ring);
outline-offset: 4px;
border-radius: 50%;
}
.who {
display: flex;
+19 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import Prose from './Prose.svelte';
@@ -11,6 +12,9 @@
rivals = 0,
selected = false,
actions = true,
voteFor = null,
voteOpen = false,
fold,
onaction,
}: {
text: Shown;
@@ -20,11 +24,16 @@
rivals?: number;
selected?: boolean;
actions?: boolean;
onaction: (tab: 'vote' | 'proposals' | 'propose') => void;
/** The version « Le vote » unfolds, and whether it is unfolded. */
voteFor?: string | null;
voteOpen?: boolean;
/** The vote, unfolded under the text. */
fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
} = $props();
</script>
<article class="unit {mark}" class:selected>
<article class="unit {mark}" class:selected class:unfolded={voteOpen}>
<header>
<h2 class="eyebrow">{text.title}</h2>
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
@@ -38,12 +47,13 @@
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
</span>
<span class="links">
<button type="button" onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
</span>
</footer>
{/if}
{#if voteOpen && fold}{@render fold()}{/if}
</article>
<style>
@@ -59,7 +69,8 @@
}
.unit:hover,
.unit:focus-within,
.unit.selected {
.unit.selected,
.unit.unfolded {
background: var(--surface);
box-shadow: var(--shadow);
}
@@ -98,7 +109,8 @@
}
.unit:hover .links,
.unit:focus-within .links,
.unit.selected .links {
.unit.selected .links,
.unit.unfolded .links {
opacity: 1;
}
.links button {
@@ -110,7 +122,8 @@
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.links button:hover {
.links button:hover,
.links button.on {
background: var(--water-wash);
}
@media (hover: none) {
@@ -1,30 +1,30 @@
<script lang="ts">
// A version in the panel, tab « Détails »: its text and what changed, then
// what is not the vote — typos, translations, flags of contradiction, history.
// The vote itself unfolds under the article (VoteFold).
import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { inertiaBadge, titleOf } from '$lib/labels';
import { titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types';
import { announce } from '$lib/vote';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposalId,
lang,
onopen,
onvote,
onpropose,
onsettings,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
onopen: (proposalId: string) => void;
/** Unfolds the vote of this version under its article. */
onvote: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
onsettings?: () => void;
} = $props();
interface Detail {
@@ -37,9 +37,6 @@
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
voteFloor: number;
levels: LevelInfo[];
}
let detail = $state<Detail | null>(null);
@@ -101,29 +98,6 @@
await load();
}
function announce(entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
async function vote(choice: 'for' | 'against' | null) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
await refresh();
announce(r.entered);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) {
e.preventDefault();
busy = true;
@@ -179,7 +153,7 @@
try {
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
await refresh();
announce(r.entered);
announce(doc, r.entered);
} catch (err) {
error = message(err);
} finally {
@@ -202,7 +176,7 @@
</script>
{#if detail && p && text && view}
<article class="vote">
<article class="details">
<header>
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
@@ -228,39 +202,12 @@
<del class="removed"><Prose text={detail.inForce.body} /></del>
{/if}
<section class="box">
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if votable && !view?.canVote}
<p class="muted">{t('vote.wotOnly')}</p>
{:else if votable}
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<span class="label">{t('vote.yours')}</span>
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
</div>
<p class="faint small">{t('vote.private')}</p>
{:else}
<p class="muted">{t('vote.closed')}</p>
{/if}
</section>
{#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} levels={detail.levels} floor={detail.voteFloor} base={view.doc.formula.base} {onsettings} />
{#if votable}
<p class="tally faint">
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
</p>
{/if}
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub">
@@ -351,7 +298,7 @@
{/if}
<style>
.vote {
.details {
display: grid;
gap: 1.1rem;
}
@@ -382,48 +329,19 @@
.removed {
opacity: 0.6;
}
.box {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
margin-right: 0.3rem;
}
.btn.on {
background: var(--moss);
color: var(--on-water);
}
.btn.on-against {
background: var(--ochre);
color: var(--on-water);
.linkish {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
text-decoration: underline;
text-underline-offset: 3px;
}
.warn {
color: var(--ochre);
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
// The vote, unfolded under the article (Yvv, 2026-10-30: « il faut voir son
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
// never who voted what —, how far the threshold is, your vote and where it
// stands, then the inertia and the formula. The rest of a version (typos,
// translations, flags, history) lives in the panel, tab « Détails ».
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { LevelInfo, Proposal, Unit } from '$lib/types';
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposal: p,
unit,
onopen,
onsettings,
ondetails,
onsign,
}: {
doc: DocState;
proposal: Proposal;
unit: Unit;
/** Unfolds the vote of another version (a change of inertia). */
onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void;
ondetails: (proposalId: string) => void;
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
onsign: (lines: Line[]) => void;
} = $props();
interface Detail {
electorate: number;
voteFloor: number;
levels: LevelInfo[];
}
let detail = $state<Detail | null>(null);
let busy = $state(false);
let error = $state('');
let root: HTMLElement | undefined = $state();
const view = $derived(doc.view);
const access = $derived(view?.vote);
const chain = $derived(access?.way === 'chain');
const votable = $derived(p.status === 'open' || p.status === 'in_force');
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
const voted = $derived(p.myVote !== null && !p.myVoteStale);
const ballot = $derived(p.myBallot);
$effect(() => {
const id = p.id;
void p.tally.for;
void p.tally.against;
api
.get<Detail>(`/proposals/${id}`)
.then((d) => {
if (p.id === id) detail = d;
})
.catch(() => (detail = null));
});
onMount(() => {
// Opened by a link: brought into sight, without a jump if it already is.
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
});
async function cast(choice: 'for' | 'against' | null) {
error = '';
const lines: Line[] = [{ proposalId: p.id, choice }];
if (chain) {
onsign(lines);
return;
}
busy = true;
try {
await voteDirect(doc, lines);
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
</script>
<section class="fold" id="vote-{p.id}" aria-label={t('vote.fold', { title })} bind:this={root}>
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
<div class="mine" aria-live="polite">
{#if ballot?.status === 'pending'}
<p class="pending"><span class="dot" aria-hidden="true"></span>{t('vote.mine.pending', { choice: t(`vote.choice.${ballot.choice}`) })}</p>
{/if}
{#if p.myVoteStale}
<p class="warn">{t('vote.stale')}</p>
{:else if p.myVote}
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
{:else if ballot?.status !== 'pending'}
<p class="muted">{t('vote.mine.none')}</p>
{/if}
{#if ballot?.status === 'refused'}
<p class="warn">{t('vote.mine.refused', { reason: t(`ballot.reason.${ballot.reason ?? 'transfer'}`) })}</p>
{:else if ballot?.status === 'expired'}
<p class="warn">{t('vote.mine.expired')}</p>
{/if}
</div>
{#if !votable}
<p class="muted">{t('vote.closed')}</p>
{:else if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{:else if access}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
</div>
{#if chain && access.chain}
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
<p class="faint small">{t('vote.chain.counts')}</p>
{/if}
{/if}
<p class="faint small">{t('vote.private')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="more">
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if detail && view}
<Formula
electorate={detail.electorate}
source={view.doc.electorateSource}
tally={p.tally}
evaluation={p.evaluation}
inertia={p.votedAtInertia}
levels={detail.levels}
floor={detail.voteFloor}
base={view.doc.formula.base}
onsettings={() => onsettings(p.id)}
/>
{/if}
<button class="linkish" type="button" onclick={() => ondetails(p.id)}>{t('vote.details')}</button>
</div>
</section>
<style>
.fold {
display: grid;
gap: 0.8rem;
margin-top: 0.8rem;
padding: 1.1rem 1.1rem 1.2rem;
border-radius: var(--r-card);
background: var(--water-wash);
animation: unfold 0.22s var(--ease);
scroll-margin: 1.5rem;
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
.mine {
display: grid;
gap: 0.3rem;
}
.voted {
font-weight: 800;
}
.voted.for {
color: var(--moss);
}
.voted.against {
color: var(--ochre);
}
.how {
font-weight: 650;
color: var(--ink-soft);
}
.pending {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 750;
color: var(--water);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: currentColor;
animation: breathe 1.6s ease-in-out infinite;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.btn.for.on {
background: var(--moss);
color: var(--on-water);
}
.btn.against.on {
background: var(--ochre);
color: var(--on-water);
}
.closed {
font-weight: 700;
color: var(--ink-soft);
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
.more {
display: grid;
gap: 0.9rem;
padding-top: 0.4rem;
}
.linkish {
justify-self: start;
border: 0;
background: none;
padding: 0.2rem 0;
min-height: 2.25rem;
color: var(--water);
font-weight: 750;
text-align: left;
text-decoration: underline;
text-underline-offset: 3px;
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes breathe {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.fold,
.dot {
animation: none;
}
}
</style>
+6 -2
View File
@@ -1,19 +1,23 @@
// State of the reader: the document, the view (in force / previews), the
// language the text is read in, and the panel (which unit, which version,
// which tab) — the panel lives in the URL so a link reopens it.
// which tab) — the panel lives in the URL so a link reopens it, as does the
// vote unfolded under an article (`?vote=`).
import { api, ApiError } from './api';
import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all';
export type Tab = 'vote' | 'proposals' | 'propose' | 'settings';
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
export interface PanelState {
unitId: string | null;
proposalId: string | null;
tab: Tab;
draftId?: string | null;
/** The basket, opened on one section. */
section?: string | null;
prefill?: { title: string; body: string } | null;
}
+83 -2
View File
@@ -122,6 +122,8 @@ export const en: Messages = {
'doc.action.vote': 'Voting',
'doc.action.proposals': 'Proposals',
'doc.action.propose': 'Propose a change',
'doc.basket': 'Vote in bulk',
'doc.basket.count': 'Vote in bulk · {n}',
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
'doc.published': 'Published to the text repository',
'doc.publish.pending': 'Publication pending',
@@ -176,6 +178,8 @@ export const en: Messages = {
'panel.proposals': 'Proposals',
'panel.propose': 'Propose',
'panel.settings': 'Settings',
'panel.basket': 'Basket',
'panel.details': 'Details',
'panel.close': 'Close panel',
'panel.inforce': 'In force',
'panel.proposal': 'Proposal',
@@ -199,7 +203,76 @@ export const en: Messages = {
'vote.entered.remove': '“{title}” has been removed from the text in force.',
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
'vote.closed': 'This version is no longer open for voting.',
'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.',
'vote.fold': 'The vote on “{title}”',
'vote.mine.none': 'You have not voted on this version.',
'vote.mine.for': 'Your vote: for',
'vote.mine.against': 'Your vote: against',
'vote.mine.chain': 'recorded on the Ğ1 chain',
'vote.mine.saved': 'saved',
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
'vote.mine.refused': 'Your last vote was not counted: {reason}',
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
'vote.choice.for': 'for',
'vote.choice.against': 'against',
'vote.choice.none': 'withdrawing your vote',
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
'vote.members': 'Only members of this group vote here.',
'vote.details': 'Typos, translations, flags, history',
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
'basket.title': 'Voting basket',
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
'basket.doc': 'The whole document',
'basket.for': 'All for',
'basket.against': 'All against',
'basket.clear': 'Empty',
'basket.pick': 'Your choice for “{title}”',
'basket.pick.retract': 'Withdraw',
'basket.versions': 'Several versions: choose yours.',
'basket.waiting.one': '{n} article has several versions: a grouped gesture does not fill it, choose your version.',
'basket.waiting.other': '{n} articles have several versions: a grouped gesture does not fill them, choose your version.',
'basket.inforce': 'in force',
'basket.count.one': '{n} choice',
'basket.count.other': '{n} choices',
'basket.send.server': 'Vote ({n})',
'basket.send.chain': 'Sign and send ({n})',
'basket.note.server': 'Your votes count as soon as they are sent, all at once.',
'basket.note.chain': 'A single transaction of {amount} Ğ1, signed by your wallet, for all your choices.',
'basket.empty': 'Nothing in the basket yet.',
'basket.sent.one': '{n} vote saved.',
'basket.sent.other': '{n} votes saved.',
'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}',
'sign.title': 'Vote with your Ğ1 wallet',
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.wallet': 'With which wallet?',
'sign.account': 'With which account?',
'sign.opening': 'Your wallet is opening…',
'sign.signing': 'Confirm in your wallet.',
'sign.waiting': 'The Ğ1 chain is confirming your vote, usually in less than a minute.',
'sign.confirmed': 'Your vote counts: it is recorded on the Ğ1 chain.',
'sign.confirmed.as': 'Counted for the Ğ1 identity “{name}”.',
'sign.refused': 'Your vote was not counted: {reason}',
'sign.expired': 'Your vote was not seen on the Ğ1 chain in time: it does not count. Start again whenever you like.',
'sign.error.cancelled': 'You declined to sign: nothing was sent.',
'sign.error.funds': 'Your wallet does not have enough Ğ1: at least 1 Ğ1 must remain after the vote.',
'sign.error.node': 'The Ğ1 chain is not answering. Try again in a moment.',
'sign.error.account': 'This account is no longer in your wallet.',
'sign.error.wallet': 'Your wallet could not sign. Try again, or use Ğecko.',
'sign.none': 'No wallet is connected to this browser (duniter-connect, for example). On your phone, Ğecko does the job.',
'sign.gecko': 'Use Ğecko',
'sign.gecko.scan': 'Scan this code with Ğecko, or tap the link on your phone. Check the amount and the comment, then confirm.',
'sign.gecko.open': 'Open in Ğecko',
'sign.gecko.amount': 'Amount',
'sign.gecko.comment': 'Comment',
'sign.gecko.qr': 'Code to scan with Ğecko',
'sign.close': 'Close',
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
'sign.retry': 'Try again',
'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
@@ -394,13 +467,21 @@ export const en: Messages = {
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.',
'error.changed': 'The text has changed in the meantime: reload it, then try again.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.label': 'The name is empty or too long (60 characters at most).',
'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.',
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
'error.groups': 'Choose at least one group.',
'error.publication_off': 'Publication is not configured on this server.',
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
'error.server_vote': 'Here, voting is direct, without a wallet.',
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
'error.lines': 'This vote no longer holds: reload the page, then start again.',
'error.choice': 'This choice does not exist.',
'error.author': 'This account is not a Ğ1 address.',
'error.not_votable': 'This version is no longer open for voting.',
'error.members_only': 'Only members of this group vote here.',
'error.retry': 'Try again',
'common.cancel': 'Cancel',
+83 -2
View File
@@ -122,6 +122,8 @@ export const es: Messages = {
'doc.action.vote': 'Votación',
'doc.action.proposals': 'Propuestas',
'doc.action.propose': 'Proponer una modificación',
'doc.basket': 'Votar en bloque',
'doc.basket.count': 'Votar en bloque · {n}',
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
'doc.published': 'Publicado en el repositorio de textos',
'doc.publish.pending': 'Publicación pendiente',
@@ -176,6 +178,8 @@ export const es: Messages = {
'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer',
'panel.settings': 'Ajustes',
'panel.basket': 'Cesta',
'panel.details': 'Detalles',
'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta',
@@ -199,7 +203,76 @@ export const es: Messages = {
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
'vote.closed': 'Esta versión ya no está en votación.',
'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.',
'vote.fold': 'La votación de «{title}»',
'vote.mine.none': 'No has votado esta versión.',
'vote.mine.for': 'Tu voto: a favor',
'vote.mine.against': 'Tu voto: en contra',
'vote.mine.chain': 'inscrito en la cadena Ğ1',
'vote.mine.saved': 'registrado',
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
'vote.choice.for': 'a favor',
'vote.choice.against': 'en contra',
'vote.choice.none': 'retirar tu voto',
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
'vote.members': 'Aquí solo votan los miembros de este grupo.',
'vote.details': 'Erratas, traducciones, señalamientos, historial',
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
'basket.title': 'Cesta de votación',
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
'basket.doc': 'Todo el documento',
'basket.for': 'Todo a favor',
'basket.against': 'Todo en contra',
'basket.clear': 'Vaciar',
'basket.pick': 'Tu elección para «{title}»',
'basket.pick.retract': 'Retirar',
'basket.versions': 'Varias versiones: elige la tuya.',
'basket.waiting.one': '{n} artículo tiene varias versiones: un gesto agrupado no lo rellena, elige tu versión.',
'basket.waiting.other': '{n} artículos tienen varias versiones: un gesto agrupado no los rellena, elige tu versión.',
'basket.inforce': 'en vigor',
'basket.count.one': '{n} elección',
'basket.count.other': '{n} elecciones',
'basket.send.server': 'Votar ({n})',
'basket.send.chain': 'Firmar y enviar ({n})',
'basket.note.server': 'Tus votos cuentan en cuanto se envían, de una sola vez.',
'basket.note.chain': 'Una sola transacción de {amount} Ğ1, firmada por tu monedero, para todas tus elecciones.',
'basket.empty': 'Nada en la cesta por ahora.',
'basket.sent.one': '{n} voto registrado.',
'basket.sent.other': '{n} votos registrados.',
'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}',
'sign.title': 'Votar con tu monedero Ğ1',
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.wallet': '¿Con qué monedero?',
'sign.account': '¿Con qué cuenta?',
'sign.opening': 'Tu monedero se abre…',
'sign.signing': 'Confirma en tu monedero.',
'sign.waiting': 'La cadena Ğ1 confirma tu voto, en general en menos de un minuto.',
'sign.confirmed': 'Tu voto cuenta: está inscrito en la cadena Ğ1.',
'sign.confirmed.as': 'Contado para la identidad Ğ1 «{name}».',
'sign.refused': 'Tu voto no se contó: {reason}',
'sign.expired': 'Tu voto no se vio a tiempo en la cadena Ğ1: no cuenta. Vuelve a empezar cuando quieras.',
'sign.error.cancelled': 'Rechazaste la firma: no se envió nada.',
'sign.error.funds': 'Tu monedero no tiene suficientes Ğ1: debe quedarle al menos 1 Ğ1 después del voto.',
'sign.error.node': 'La cadena Ğ1 no responde. Vuelve a intentarlo en un momento.',
'sign.error.account': 'Esta cuenta ya no está en tu monedero.',
'sign.error.wallet': 'Tu monedero no pudo firmar. Vuelve a intentarlo, o usa Ğecko.',
'sign.none': 'Ningún monedero está conectado a este navegador (duniter-connect, por ejemplo). En tu teléfono, Ğecko sirve.',
'sign.gecko': 'Usar Ğecko',
'sign.gecko.scan': 'Escanea este código con Ğecko, o toca el enlace en tu teléfono. Comprueba el importe y el comentario, y luego valida.',
'sign.gecko.open': 'Abrir en Ğecko',
'sign.gecko.amount': 'Importe',
'sign.gecko.comment': 'Comentario',
'sign.gecko.qr': 'Código para escanear con Ğecko',
'sign.close': 'Cerrar',
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
'sign.retry': 'Reintentar',
'inertia.title': 'Inercia',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
@@ -394,13 +467,21 @@ export const es: Messages = {
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.',
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.',
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
'error.groups': 'Elige al menos un grupo.',
'error.publication_off': 'La publicación no está configurada en este servidor.',
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
'error.choice': 'Esta elección no existe.',
'error.author': 'Esta cuenta no es una dirección Ğ1.',
'error.not_votable': 'Esta versión ya no está en votación.',
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
'error.retry': 'Reintentar',
'common.cancel': 'Cancelar',
+83 -2
View File
@@ -125,6 +125,8 @@ export const fr = {
'doc.action.vote': 'Le vote',
'doc.action.proposals': 'Propositions',
'doc.action.propose': 'Proposer une modification',
'doc.basket': 'Voter en lot',
'doc.basket.count': 'Voter en lot · {n}',
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
'doc.published': 'Publié sur le dépôt des textes',
'doc.publish.pending': 'Publication en attente',
@@ -179,6 +181,8 @@ export const fr = {
'panel.proposals': 'Propositions',
'panel.propose': 'Proposer',
'panel.settings': 'Réglages',
'panel.basket': 'Panier',
'panel.details': 'Détails',
'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition',
@@ -202,7 +206,76 @@ export const fr = {
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
'vote.closed': 'Cette version ne se vote plus.',
'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.',
'vote.fold': 'Le vote de « {title} »',
'vote.mine.none': "Tu n'as pas voté sur cette version.",
'vote.mine.for': 'Ton vote : pour',
'vote.mine.against': 'Ton vote : contre',
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
'vote.mine.saved': 'enregistré',
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
'vote.choice.for': 'pour',
'vote.choice.against': 'contre',
'vote.choice.none': 'retrait de ton vote',
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
'vote.members': 'Seuls les membres de ce groupe votent ici.',
'vote.details': 'Coquilles, traductions, signalements, historique',
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
'basket.title': 'Panier de vote',
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
'basket.doc': 'Tout le document',
'basket.for': 'Tout pour',
'basket.against': 'Tout contre',
'basket.clear': 'Vider',
'basket.pick': 'Ton choix pour « {title} »',
'basket.pick.retract': 'Retirer',
'basket.versions': 'Plusieurs versions : choisis la tienne.',
'basket.waiting.one': '{n} article a plusieurs versions : un geste groupé ne le remplit pas, choisis ta version.',
'basket.waiting.other': '{n} articles ont plusieurs versions : un geste groupé ne les remplit pas, choisis ta version.',
'basket.inforce': 'en vigueur',
'basket.count.one': '{n} choix',
'basket.count.other': '{n} choix',
'basket.send.server': 'Voter ({n})',
'basket.send.chain': 'Signer et envoyer ({n})',
'basket.note.server': "Tes votes comptent dès l'envoi, en une seule fois.",
'basket.note.chain': 'Une seule transaction de {amount} Ğ1, signée par ton portefeuille, pour tous tes choix.',
'basket.empty': "Rien dans le panier pour l'instant.",
'basket.sent.one': '{n} vote enregistré.',
'basket.sent.other': '{n} votes enregistrés.',
'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}',
'sign.title': 'Voter avec ton portefeuille Ğ1',
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.wallet': 'Avec quel portefeuille ?',
'sign.account': 'Avec quel compte ?',
'sign.opening': "Ton portefeuille s'ouvre…",
'sign.signing': 'Confirme dans ton portefeuille.',
'sign.waiting': "La chaîne Ğ1 confirme ton vote, en général en moins d'une minute.",
'sign.confirmed': 'Ton vote compte : il est inscrit sur la chaîne Ğ1.',
'sign.confirmed.as': "Compté pour l'identité Ğ1 « {name} ».",
'sign.refused': "Ton vote n'a pas été compté : {reason}",
'sign.expired': "Ton vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas. Recommence quand tu veux.",
'sign.error.cancelled': "Tu as refusé la signature : rien n'est parti.",
'sign.error.funds': "Ton portefeuille n'a pas assez de Ğ1 : il doit lui rester au moins 1 Ğ1 après le vote.",
'sign.error.node': 'La chaîne Ğ1 ne répond pas. Réessaie dans un moment.',
'sign.error.account': "Ce compte n'est plus dans ton portefeuille.",
'sign.error.wallet': "Ton portefeuille n'a pas pu signer. Réessaie, ou passe par Ğecko.",
'sign.none': "Aucun portefeuille n'est branché à ce navigateur (duniter-connect, par exemple). Sur ton téléphone, Ğecko fait l'affaire.",
'sign.gecko': 'Passer par Ğecko',
'sign.gecko.scan': 'Scanne ce code avec Ğecko, ou touche le lien sur ton téléphone. Vérifie le montant et le commentaire, puis valide.',
'sign.gecko.open': 'Ouvrir dans Ğecko',
'sign.gecko.amount': 'Montant',
'sign.gecko.comment': 'Commentaire',
'sign.gecko.qr': 'Code à scanner avec Ğecko',
'sign.close': 'Fermer',
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
'sign.retry': 'Réessayer',
'inertia.title': 'Inertie',
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
@@ -397,13 +470,21 @@ export const fr = {
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.",
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.",
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
'error.groups': 'Choisis au moins un groupe.',
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
'error.choice': "Ce choix n'existe pas.",
'error.author': "Ce compte n'est pas une adresse Ğ1.",
'error.not_votable': 'Cette version ne se vote plus.',
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
'error.retry': 'Réessayer',
'common.cancel': 'Annuler',
+61
View File
@@ -0,0 +1,61 @@
// The basket of the reader: the choices not sent yet, kept for the tab (a reload
// or a look at another article does not lose them), one basket per document.
import type { Picks } from './basket';
import type { Choice } from './types';
export class BasketState {
picks = $state<Picks>({});
#key = '';
/** Takes back the basket of this document, keeping only the versions still open to vote. */
load(docId: string, known: Set<string>) {
const key = `ld:basket:${docId}`;
if (key === this.#key) {
this.#keep(known);
return;
}
this.#key = key;
let saved: Picks = {};
try {
saved = JSON.parse(sessionStorage.getItem(key) ?? '{}') as Picks;
} catch {
saved = {};
}
this.picks = {};
this.set(saved);
this.#keep(known);
}
#keep(known: Set<string>) {
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id)));
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept);
}
set(picks: Picks) {
const clean: Picks = {};
for (const [id, c] of Object.entries(picks)) if (c === 'for' || c === 'against' || c === 'none') clean[id] = c;
this.picks = clean;
try {
sessionStorage.setItem(this.#key, JSON.stringify(clean));
} catch {
/* private mode: the basket lives as long as the page */
}
}
/** Picks a choice; the same choice again takes it out of the basket. */
pick(id: string, choice: Choice) {
const next = { ...this.picks };
if (next[id] === choice) delete next[id];
else next[id] = choice;
this.set(next);
}
clear() {
this.set({});
}
get size(): number {
return Object.keys(this.picks).length;
}
}
+52 -1
View File
@@ -100,9 +100,59 @@ export interface Proposal {
signals: number;
myVote: 'for' | 'against' | null;
myVoteStale: boolean;
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
myVoteChain: boolean;
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
myBallot: MyBallot | null;
translations: Partial<Record<Lang, Translation>>;
}
export type Choice = 'for' | 'against' | 'none';
export interface MyBallot {
id: string;
status: 'pending' | 'refused' | 'expired';
reason: 'not_member' | 'no_identity' | 'transfer' | null;
choice: Choice;
}
/** The vote on the Ğ1 chain, as the server sets it (ADR 0008). */
export interface ChainSettings {
/** The vote account, as a Ğ1 address. */
account: string;
/** Cents of Ğ1 per vote. */
amount: number;
/** WebSocket endpoints of Ğ1 nodes. */
nodes: string[];
/** Tests only: the page sends to a fake node, without building a real transaction. */
test: boolean;
testNode: string | null;
expiresHours: number;
}
/** How this session votes in the group of the document, and whether it may now. */
export interface VoteAccess {
way: 'server' | 'chain';
open: boolean;
reason: 'members_only' | 'chain_off' | null;
chain: ChainSettings | null;
}
/** A ballot of the chain, as `GET /api/ballots/{id}` tells it to the session that asked. */
export interface Ballot {
id: string;
status: 'pending' | 'confirmed' | 'refused' | 'expired';
reason: MyBallot['reason'];
comment: string;
account: string;
amount: number;
author: string | null;
block: number | null;
voter: string | null;
entered: Entered[];
expiresAt: string;
}
export interface Unit {
id: string;
code: string;
@@ -124,8 +174,9 @@ export interface Publication {
}
export interface DocView {
/** trustinWallet: where the electorate is the web of trust, only its members vote. */
/** This session may vote now (`vote.open`). */
canVote: boolean;
vote: VoteAccess;
doc: {
id: string;
slug: string;
+43
View File
@@ -0,0 +1,43 @@
// What the vote under an article and the basket share: one way to send votes
// to the server (managed groups), and the news of what entered into force.
import { api, ApiError } from './api';
import type { DocState } from './doc.svelte';
import { i18n, t, type Key } from './i18n/index.svelte';
import { toasts } from './toast.svelte';
import type { Entered } from './types';
/** A line of a vote: one version, for or against, or null to withdraw one's vote. */
export interface Line {
proposalId: string;
choice: 'for' | 'against' | null;
}
/** Cents of Ğ1, as the person reads them: « 0,1 » in French. */
export function g1Amount(cents: number): string {
return i18n.number(cents / 100);
}
export function errorText(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
/** Tells, one by one, the texts that just entered into force. */
export function announce(doc: DocState, entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
/** In a managed group: every line in one write on the server. Returns how many were written. */
export async function voteDirect(doc: DocState, lines: Line[]): Promise<number> {
const id = doc.view?.doc.id;
if (!id) return 0;
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
await doc.reload();
announce(doc, r.entered);
return r.count;
}
+32
View File
@@ -0,0 +1,32 @@
// Ğecko, on a phone (or without a wallet in the browser): a link and a QR code
// that open the transfer with its amount and its comment already written. Ğecko
// does not hand back to the page: the page waits for the indexer to see it.
/** Cents of Ğ1 as Ğ1, with a dot: 10 → « 0.1 ». */
export function amountText(cents: number): string {
const whole = Math.trunc(cents / 100);
const rest = cents % 100;
if (!rest) return String(whole);
return `${whole}.${String(rest).padStart(2, '0').replace(/0$/, '')}`;
}
/**
* `g1://<account>?amount=0.1&comment=ld1:<hex>` — the format Ğecko and Ğ1nkgo read.
* The comment is plain ASCII (`ld1:` and hex digits): nothing to escape.
*/
export function geckoLink(account: string, cents: number, comment: string): string {
return `g1://${account}?amount=${amountText(cents)}&comment=${comment}`;
}
/** The dark modules of a QR code as one SVG path, `size` modules wide (border included). */
export async function qrPath(text: string): Promise<{ size: number; d: string }> {
const { encode } = await import('uqr');
const qr = encode(text, { ecc: 'M', border: 2 });
let d = '';
qr.data.forEach((row, y) => {
row.forEach((dark, x) => {
if (dark) d += `M${x} ${y}h1v1h-1z`;
});
});
return { size: qr.data.length, d };
}
+206
View File
@@ -0,0 +1,206 @@
// The wallets of the page: every browser extension that offers the standard
// interface of Substrate wallets (`window.injectedWeb3`, `signPayload`) —
// duniter-connect today; Ğ1Companion and trustinWallet the day they offer it.
// One adapter, one interface: the vote never knows which wallet signs.
//
// The transaction is one gesture (Yvv, 2026-10-30): `utility.batchAll` of the
// transfer to the vote account and the remark that seals the ballot, built by
// polkadot-api — loaded only now, at the moment of signing — signed by the
// extension (its own confirmation window), sent to a Ğ1 node. The server then
// finds it on the indexer.
import type { ChainSettings } from '../types';
export interface WalletAccount {
address: string;
name: string | null;
}
export interface VoteTransfer {
/** The vote account. */
dest: string;
/** Cents of Ğ1. */
amount: bigint;
/** `ld1:` + the seal of the ballot. */
remark: string;
}
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
export type SendStatus = 'signing' | 'sent' | 'included';
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
export class WalletError extends Error {
constructor(public code: Failure) {
super(code);
}
}
export interface Wallet {
/** The name the extension gives itself in `window.injectedWeb3`. */
id: string;
accounts(): Promise<WalletAccount[]>;
send(account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void): Promise<void>;
}
interface Enabled {
accounts: { get: () => Promise<{ address: string; name?: string }[]> };
signer: { signPayload?: (payload: Record<string, unknown>) => Promise<{ signature: string }> };
}
interface Injected {
enable?: (dapp: string) => Promise<Enabled>;
}
type Page = { injectedWeb3?: Record<string, unknown> };
const DAPP = 'libreDecision';
/** Wallets of the Ğ1 first, then the others, in the order they were found. */
const PREFERRED = ['duniter-connect', 'g1companion', 'trustinwallet'];
const rank = (name: string) => {
const i = PREFERRED.indexOf(name.toLowerCase());
return i < 0 ? PREFERRED.length : i;
};
export function injectedNames(page: Page): string[] {
return Object.entries(page.injectedWeb3 ?? {})
.filter(([, v]) => typeof (v as Injected | null)?.enable === 'function')
.map(([name]) => name)
.sort((a, b) => rank(a) - rank(b));
}
/** What went wrong, in one of a few words the interface translates. */
export function failure(e: unknown): Failure {
if (e instanceof WalletError) return e.code;
let text: string;
if (e instanceof Error) text = e.message;
else {
try {
text = JSON.stringify(e) ?? String(e);
} catch {
text = String(e);
}
}
text = text.toLowerCase();
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
return 'wallet';
}
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
export function nothingSent(f: Failure): boolean {
return f === 'cancelled' || f === 'funds' || f === 'account';
}
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
export function testExtrinsic(fields: Record<string, string>): string {
return `0x${hex(new TextEncoder().encode(JSON.stringify(fields)))}`;
}
async function enable(name: string): Promise<Enabled> {
const injected = (window as Page).injectedWeb3?.[name] as Injected | undefined;
if (!injected?.enable) throw new WalletError('wallet');
try {
return await injected.enable(DAPP);
} catch (e) {
throw new WalletError(failure(e));
}
}
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const ext = await enable(name);
if (!ext.signer.signPayload) throw new WalletError('wallet');
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
on('signing');
let signature: string;
try {
({ signature } = await ext.signer.signPayload({ address: account.address, method: testExtrinsic(fields), version: 4 }));
} catch (e) {
throw new WalletError(failure(e));
}
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) throw new WalletError('node');
on('sent');
}
/** The real thing: polkadot-api builds the transaction from the chain's own description. */
async function sendChain(name: string, nodes: string[], account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const [{ createClient, Binary }, { getWsProvider }, { connectInjectedExtension }] = await Promise.all([
import('polkadot-api'),
import('polkadot-api/ws'),
import('polkadot-api/pjs-signer'),
]);
const ext = await connectInjectedExtension(name, DAPP).catch((e: unknown) => {
throw new WalletError(failure(e));
});
const client = createClient(getWsProvider(nodes));
try {
const signer = ext.getAccounts().find((a) => a.address === account.address);
if (!signer) throw new WalletError('account');
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
const { tx: calls } = client.getUnsafeApi();
const call = calls.Utility!.batch_all!({
calls: [
calls.Balances!.transfer_keep_alive!({ dest: { type: 'Id', value: tx.dest }, value: tx.amount }).decodedCall,
calls.System!.remark_with_event!({ remark: Binary.fromText(tx.remark) }).decodedCall,
],
});
on('signing');
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
next: (ev) => {
if (ev.type === 'broadcasted') {
on('sent');
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
timer ??= setTimeout(() => {
sub.unsubscribe();
reject(new WalletError('node'));
}, 120_000);
}
if (ev.type === 'inBestBlock') {
clearTimeout(timer);
sub.unsubscribe();
if (ev.ok) {
on('included');
resolve();
} else reject(new WalletError(failure(ev.dispatchError)));
}
},
error: (e: unknown) => {
clearTimeout(timer);
reject(new WalletError(failure(e)));
},
});
});
} finally {
client.destroy();
ext.disconnect();
}
}
function injected(name: string, chain: ChainSettings): Wallet {
return {
id: name,
async accounts() {
const ext = await enable(name);
const list = await ext.accounts.get().catch((e: unknown) => {
throw new WalletError(failure(e));
});
return list.map((a) => ({ address: a.address, name: a.name ?? null }));
},
send(account, tx, on) {
return chain.test && chain.testNode
? sendTest(name, chain.testNode, account, tx, on)
: sendChain(name, chain.nodes, account, tx, on);
},
};
}
/** The wallets this page can talk to, the ones for Ğ1 first. */
export function wallets(chain: ChainSettings): Wallet[] {
return injectedNames(window as Page).map((name) => injected(name, chain));
}
+123 -27
View File
@@ -1,47 +1,88 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte';
import DeepList from '$lib/components/DeepList.svelte';
import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { sections } from '$lib/basket';
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { BasketState } from '$lib/picks.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote';
import { articles, type Filter } from '$lib/versions';
const doc = new DocState();
const basket = new BasketState();
const group = $derived(page.params.group ?? '');
const slug = $derived(page.params.doc ?? '');
$effect(() => {
void doc.load(group, slug);
});
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
$effect(() => {
const v = doc.view;
if (!v) return;
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
basket.load(v.doc.id, known);
});
// The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams;
const tab = TABS[q.get('onglet') ?? ''];
if (!tab) return null;
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), prefill };
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
const vote = $derived.by(() => {
const q = page.url.searchParams;
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
});
function openPanel(next: PanelState | null) {
prefill = next?.prefill ?? null;
function go(nextPanel: PanelState | null, nextVote: string | null) {
prefill = nextPanel?.prefill ?? null;
const q = new URLSearchParams();
if (next) {
q.set('onglet', TAB_NAMES[next.tab]);
if (next.unitId) q.set('article', next.unitId);
if (next.proposalId) q.set('version', next.proposalId);
if (next.draftId) q.set('brouillon', next.draftId);
if (nextVote) q.set('vote', nextVote);
if (nextPanel) {
q.set('onglet', TAB_NAMES[nextPanel.tab]);
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
if (nextPanel.section) q.set('section', nextPanel.section);
}
const search = q.toString();
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
noScroll: true,
keepFocus: true,
});
}
const openPanel = (next: PanelState | null) => go(next, vote);
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
// On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines);
// While a ballot waits for the chain, the page looks again every ten seconds.
const waitingChain = $derived(
(doc.view?.units ?? []).some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
);
$effect(() => {
if (!waitingChain) return;
const every = setInterval(() => void doc.reload(), 10_000);
return () => clearInterval(every);
});
let filter = $state<Filter>('all');
const view = $derived(doc.view);
@@ -54,14 +95,37 @@
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, tab: Tab) {
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
}
function onProposal(u: Unit, p: Proposal) {
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
if (action === 'vote') {
if (version) toggleVote(version);
return;
}
openPanel({ unitId: u.id, proposalId: null, tab: action });
}
// The text shows the vote of its own versions; the list below, that of the others.
const textIds = $derived(
new Set(
doc.mode === 'inforce' || !doc.preview
? doc.inForce.map((u) => u.inForce?.id)
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
),
);
const inText = $derived(!!vote && textIds.has(vote));
const canVote = $derived(!!view?.vote.open);
</script>
{#snippet foldOf(unit: Unit, p: Proposal)}
<VoteFold
{doc}
proposal={p}
{unit}
onopen={(id) => go(panel, id)}
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
onsign={sign}
/>
{/snippet}
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
@@ -81,6 +145,11 @@
</p>
<div class="controls">
{#if canVote}
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
</button>
{/if}
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
@@ -102,14 +171,19 @@
{#if doc.mode === 'inforce' || !doc.preview}
{#each doc.inForce as u (u.id)}
{#if u.inForce}
{@const p = u.inForce}
<UnitBlock
text={shown(u.inForce, lang, source)}
seedMinimal={u.inForce.seedMinimal}
enteredAt={u.inForce.enteredAt}
text={shown(p, lang, source)}
seedMinimal={p.seedMinimal}
enteredAt={p.enteredAt}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
onaction={(tab) => onUnit(u, tab)}
/>
voteFor={p.id}
voteOpen={inText && vote === p.id}
onaction={(action) => onUnit(u, action)}
>
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
</UnitBlock>
{/if}
{:else}
<p class="muted">{t('doc.empty')}</p>
@@ -117,12 +191,18 @@
{:else}
{#each doc.preview as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
{@const p = doc.proposal(id)}
<UnitBlock
text={shown(pu, lang, source)}
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
/>
voteFor={id}
voteOpen={inText && !!id && vote === id}
onaction={(action) => u && onUnit(u, action, id)}
>
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
</UnitBlock>
{/each}
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{/if}
@@ -152,17 +232,26 @@
{/each}
</div>
</div>
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
<DeepList
articles={deep}
vote={inText ? null : vote}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
/>
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
</section>
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
{/if}
{#if signing && view.vote.chain}
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
{/if}
{/if}
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
<style>
.reader {
@@ -217,6 +306,13 @@
justify-content: space-between;
margin-top: 1.6rem;
}
.basket-btn {
flex-basis: 100%;
justify-self: start;
max-width: max-content;
background: var(--water);
color: var(--on-water);
}
.seg {
display: inline-flex;
flex-wrap: wrap;