Fusion du lot B : le vote déplié sous l'article, le panier, le vote par transaction Ğ1 (bulletin scellé)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 00:15:46 +01:00
co-authored by Claude Opus 5.5
59 changed files with 7228 additions and 370 deletions
+120
View File
@@ -0,0 +1,120 @@
// A simulated Ğ1 chain for the end-to-end tests (ADR 0008): never a real node,
// never a real transaction. Two doors:
//
// - `/rpc`, the « node »: the page, in test mode, sends what the fake wallet
// signed (JSON as hex, see `testExtrinsic` in src/lib/wallet/injected.ts);
// each one is included in a new block, as the transfer and the remark of the
// real `utility.batchAll` would be;
// - `/v1/graphql`, the « indexer »: answers the server's query
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
//
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
//
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { blake2b } from '@noble/hashes/blake2.js';
// Substrate's well-known development keys as Ğ1 addresses, with the identity each
// one has on this simulated chain. Bob has an identity, but is not a member.
const IDENTITIES = {
g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu: { index: 9001, name: 'Alice', isMember: true },
g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g: { index: 9002, name: 'Bob', isMember: false },
g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi: { index: 9003, name: 'Charlie', isMember: true },
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
};
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
function send(res, status, body, cors = false) {
const headers = { 'content-type': 'application/json' };
if (cors) Object.assign(headers, { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' });
res.writeHead(status, headers);
res.end(body === undefined ? '' : JSON.stringify(body));
}
async function read(req) {
let raw = '';
for await (const chunk of req) raw += chunk;
try {
return JSON.parse(raw || '{}');
} catch {
return {};
}
}
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
function include(fields) {
state.block += 1;
state.submitted += 1;
const comment = {
id: `${String(state.block).padStart(10, '0')}-${hex(Buffer.from(String(state.block))).slice(-5)}-000002`,
blockNumber: state.block,
remark: fields.remark,
author: { id: fields.address, identity: IDENTITIES[fields.address] ?? null },
transfersByCommentId: { nodes: [{ fromId: fields.address, toId: fields.dest, amount: String(fields.amount) }] },
hidden: state.hiding,
};
state.comments.push(comment);
return comment;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://mock');
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
if (url.pathname === '/rpc' && req.method === 'POST') {
const body = await read(req);
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
let fields;
try {
fields = JSON.parse(Buffer.from(String(body.params?.[0] ?? '').slice(2), 'hex').toString('utf8'));
} catch {
fields = null;
}
if (!fields?.signature || !fields.remark || !fields.address) {
return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: 1010, message: 'Invalid Transaction' } }, true);
}
const c = include(fields);
return send(res, 200, { jsonrpc: '2.0', id: body.id, result: `0x${hex(blake2b(Buffer.from(c.id), { dkLen: 32 }))}` }, true);
}
if (url.pathname === '/v1/graphql' && req.method === 'POST') {
const body = await read(req);
const hash = body.variables?.hash;
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
const nodes = state.comments
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
.sort((a, b) => a.blockNumber - b.blockNumber)
.map(({ hidden: _, ...c }) => c);
return send(res, 200, { data: { txComments: { nodes } } });
}
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
const what = url.pathname.slice('/control/'.length);
if (what === 'hide') state.hiding = true;
else if (what === 'show') state.hiding = false;
else if (what === 'release') {
state.hiding = false;
for (const c of state.comments) c.hidden = false;
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
}
send(res, 404, { error: 'not_found' }, true);
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const file = process.argv[2];
if (file) writeFileSync(file, String(port));
console.log(`chain mock on 127.0.0.1:${port}`);
});
+15 -7
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { DOC, person, signIn } from './helpers';
import { chain, CHARLIE, DOC, person, signIn } from './helpers';
const ML = '/g/monnaie-libre';
const path = (p: string) => (u: URL) => u.pathname === p;
@@ -145,15 +145,23 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 33 sans ton vote');
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
// counts as much, and leaves the previews of C9 unchanged (the server is shared).
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
data: { choice: 'against' },
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
// account) would send, on the simulated chain.
const { doc, node } = await chain(page);
const e3 = doc.units.find((u) => u.code === 'E3');
const r = await page.request.post(`/api/docs/${doc.doc.id}/ballots`, {
data: { lines: [{ proposalId: e3?.proposals[1]?.id, choice: 'against' }], author: CHARLIE.address },
headers: { 'x-requested-with': 'libredecision' },
});
expect(r.ok()).toBe(true);
const { ballot } = await r.json();
const fields = { address: CHARLIE.address, dest: ballot.account, amount: String(ballot.amount), remark: ballot.comment, signature: '0x01' };
const extrinsic = `0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`;
await page.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [extrinsic] } });
await expect
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
.toBe('confirmed');
await page.reload();
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 32 sans ton vote');
+65
View File
@@ -3,6 +3,13 @@ import type { Browser, Page } from '@playwright/test';
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
/** The vote account of the e2e server (serve.sh): Dave's development key. */
export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
/** Wallets of the simulated chain (chain-mock.mjs): Alice and Charlie are members, Bob is not. */
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
export async function signIn(page: Page, name: string, admin = false) {
const r = await page.request.post('/api/auth/dev', {
data: { name, admin },
@@ -17,3 +24,61 @@ export async function person(browser: Browser, name: string, admin = false): Pro
await signIn(page, name, admin);
return page;
}
/**
* A fake wallet extension, as duniter-connect offers itself to pages
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
* It signs nothing real: the page, in test mode, sends to the simulated node.
*/
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
await page.addInitScript((list) => {
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
w.__ldSigned = [];
w.injectedWeb3 = {
'duniter-connect': {
version: 'e2e',
enable: async () => ({
accounts: { get: async () => list, subscribe: () => () => undefined },
signer: {
signPayload: async (payload: Record<string, unknown>) => {
if (w.__ldRefuse) throw new Error('Cancelled');
w.__ldSigned.push(payload);
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
},
},
}),
},
};
}, accounts);
}
/** What the fake wallet signed, read back (the test extrinsic is JSON as hex). */
export async function signed(page: Page): Promise<{ address: string; dest: string; amount: string; remark: string }[]> {
return page.evaluate(() =>
((window as unknown as { __ldSigned: { method: string }[] }).__ldSigned ?? []).map((p) => {
const bytes = (p.method.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bytes)));
}),
);
}
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
export async function shot(page: Page, name: string) {
const dir = process.env.LD_SHOTS;
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/** The control door of the simulated chain, found where the page finds its node. */
export async function chain(page: Page, docPath = DOC) {
const [, , group] = docPath.split('/');
const g = await (await page.request.get(`/api/groups/${group}`)).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
const node: string = d.vote.chain.testNode;
const base = node.replace(/\/rpc$/, '');
return {
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
node,
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
};
}
+85 -9
View File
@@ -1,7 +1,31 @@
import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers';
import { expect, test, type Locator } from '@playwright/test';
import { DOC, shot, signIn } from './helpers';
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => {
/** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */
function contrast(a: string, b: string): number {
const lum = (c: string) => {
const [r, g, bl] = (c.match(/[\d.]+/g) ?? []).slice(0, 3).map((x) => {
const v = Number(x) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (bl ?? 0);
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
}
/** The colour the ring of a swatch is drawn on: the first opaque background around it. */
async function backdrop(el: Locator): Promise<string> {
return el.evaluate((node) => {
for (let n: Element | null = node.parentElement; n; n = n.parentElement) {
const bg = getComputedStyle(n).backgroundColor;
if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg;
}
return getComputedStyle(document.body).backgroundColor;
});
}
test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/');
const brand = page.locator('header.topbar a.brand');
@@ -13,15 +37,67 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1');
await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4);
const moods = drawer.getByRole('radiogroup', { name: 'Ambiance' });
await expect(moods.getByRole('radio')).toHaveCount(4);
// The swatches, by keyboard: one stop for Tab, the arrows move and choose; the focus ring
// reads at 3:1 or more on the page whatever the swatch, and is not the mark of the chosen one.
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const start = (await page.locator('html').getAttribute('data-mood')) ?? '';
await moods.getByRole('radio', { checked: true }).focus();
for (let i = 0; i < 4; i++) {
await page.keyboard.press('ArrowRight');
const now = moods.getByRole('radio', { checked: true });
await expect(now).toBeFocused();
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const ring = await now.evaluate((el) => {
const cs = getComputedStyle(el);
return { style: cs.outlineStyle, color: cs.outlineColor, width: parseFloat(cs.outlineWidth) };
});
expect(ring.style).toBe('dashed');
expect(ring.width).toBeGreaterThanOrEqual(2);
const mood = await page.locator('html').getAttribute('data-mood');
expect(contrast(ring.color, await backdrop(moods)), `${mood}: ${ring.color}`).toBeGreaterThanOrEqual(3);
if (i === 0) await shot(page, '7-pastilles-focus-mobile');
}
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowLeft');
await expect(page.locator('html')).not.toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowRight');
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
// The chosen one, without the focus: its own mark, a ring of ink, never the dashed one.
const chosen = moods.getByRole('radio', { checked: true });
await chosen.evaluate((el) => (el as HTMLElement).blur());
const mark = await chosen.evaluate((el) => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow }));
expect(mark.outline).toBe('none');
expect(mark.shadow).toContain('0px 0px 0px 2px');
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
// The vote unfolds under the article, on a phone too.
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(panel.locator('table tbody tr')).toHaveCount(5);
const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(fits).toBe(true);
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// The basket holds on a phone: its send button stays in sight, nothing overflows.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
await expect(panel.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
await panel.getByRole('group', { name: 'Ton choix pour « Unicité du compte »' }).getByRole('button', { name: 'Pour' }).click();
await panel.getByRole('group', { name: 'Ton choix pour « Réciprocité »' }).getByRole('button', { name: 'Contre' }).click();
const send = panel.getByRole('button', { name: 'Signer et envoyer (2)' });
await expect(send).toBeInViewport();
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(holds).toBe(true);
await shot(page, '3-panier-mobile');
// Taken back out of the basket: nothing was sent.
await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
});
+56 -33
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers';
import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers';
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
await page.goto('/');
@@ -49,24 +49,29 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
});
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible();
const toggle = first.getByRole('button', { name: 'Le vote' });
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible();
await fold.getByText('La formule du seuil').click();
await expect(fold.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
await expect(page.locator('aside.panel')).toHaveCount(0);
await expect(page).toHaveURL(/vote=/);
await toggle.click();
await expect(fold).toHaveCount(0);
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page).not.toHaveURL(/vote=/);
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
@@ -75,12 +80,13 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await chiara.keyboard.press('Escape');
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
// In force now: its vote stays unfolded, under the text.
await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre');
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
@@ -89,7 +95,10 @@ test('C6 — une coquille se corrige sans vote', async ({ page }) => {
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await unicity.hover();
await unicity.getByRole('button', { name: 'Le vote' }).click();
// What is not the vote lives in the panel, tab « Détails ».
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true });
// Typographic apostrophes: the letters and the signs stay, only their style changes.
@@ -151,7 +160,7 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br
await expect(yvv.locator('.link code')).toContainText('/invitation#');
});
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => {
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent, par la chaîne', async ({ browser }) => {
const yvv = await person(browser, 'Yvv', true);
const r = await yvv.request.post('/api/access/invitations', {
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
@@ -160,17 +169,28 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
const { link } = (await r.json()) as { link: string };
const ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage();
// Her wallet holds Bob's account: an identity, not a member.
await wallet(invited, [BOB]);
await invited.goto(`/invitation#${link.split('#')[1]}`);
await expect(invited).toHaveURL((u) => u.pathname === '/');
await invited.goto(DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
const panel = invited.locator('aside.panel');
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the test group, the same person votes.
const fold = invited.locator('section.fold');
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible();
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
const dialog = invited.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(
dialog.getByText("Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1."),
).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText("Ton dernier vote n'a pas été compté", { exact: false })).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// In the test group, the same person votes, directly.
await invited.goto(TEST_DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0);
});
test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => {
@@ -237,10 +257,11 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(versions).toHaveCount(2);
await expect(versions.nth(0)).toContainText('variante 1.0.0');
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
// A version opens its vote, as any proposal.
// A version unfolds its vote below it, as any proposal.
await versions.nth(1).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/version=/);
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/vote=/);
await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0);
@@ -273,8 +294,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('cet article');
@@ -287,15 +308,17 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await e2.getByText("Proposer un changement d'inertie").click();
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
// Its own vote unfolds, in the list of proposals.
const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' });
await expect(change).toBeVisible();
await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});
+14 -2
View File
@@ -1,11 +1,23 @@
#!/bin/sh
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface.
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface,
# and a simulated Ğ1 chain (e2e/chain-mock.mjs, an ephemeral local port): the vote account
# is a well-known development key, the page sends to the fake node, the server reads the
# fake indexer. Never a real transaction.
set -eu
here=$(cd "$(dirname "$0")/../.." && pwd)
data=/tmp/ld-e2e
rm -rf "$data"
mkdir -p "$data"
cd "$here"
node web/e2e/chain-mock.mjs "$data/chain-mock.port" > "$data/chain-mock.log" 2>&1 &
i=0
while [ ! -s "$data/chain-mock.port" ]; do
i=$((i + 1))
[ "$i" -gt 100 ] && { echo "chain mock did not start" >&2; exit 1; }
sleep 0.1
done
chain="http://127.0.0.1:$(cat "$data/chain-mock.port")"
LD_DATA_DIR="$data" LD_SEED_DIR=seeds LD_WEB_DIR=web/build LD_BIND=127.0.0.1:8092 LD_DEV_LOGIN=1 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS=http://127.0.0.1:9 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS="$chain/v1/graphql" \
LD_VOTE_ACCOUNT=g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM LD_VOTE_TEST_NODE="$chain/rpc" \
exec ./target/debug/ld-server
+210
View File
@@ -0,0 +1,210 @@
import { expect, test } from '@playwright/test';
import { ALICE, chain, CHARLIE, DOC, person, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a
// basket, and on the Ğ1 chain where the electorate is the web of trust. The chain
// here is simulated (e2e/chain-mock.mjs): never a real transaction.
test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
const toggle = e1.getByRole('button', { name: 'Le vote' });
await toggle.click();
const fold = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
// How far the threshold is: the water, the totals, the votes asked and those missing.
await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible();
await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible();
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// Her vote, at once.
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
await expect(fold.locator('.voted')).toHaveText('Ton vote : pour · enregistré');
await expect(fold.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(fold.getByText('Ton vote ne se montre à personne : seuls les totaux sont visibles.')).toBeVisible();
await shot(page, '1-vote-deplie');
// Someone else sees the totals, never who voted what.
const yvv = await person(browser, 'Yvv');
await yvv.goto(page.url());
const theirs = yvv.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(theirs.getByText('1 pour · 0 contre')).toBeVisible();
await expect(theirs.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(theirs.getByText('Chiara')).toHaveCount(0);
// Folded back, then her vote withdrawn: the group is left as found.
await toggle.click();
await expect(fold).toHaveCount(0);
await toggle.click();
await fold.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
});
test('C22 — le vote groupé : une section en un geste, et l’article à deux versions se choisit', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// From the list of proposals: « Voter cette section », on the technical commitments.
await page.locator('ul.deep li.section', { hasText: 'Engagements techniques' }).getByRole('button', { name: 'Voter cette section' }).click();
await expect(page).toHaveURL(/onglet=panier/);
await expect(page).toHaveURL(/section=technique/);
const panel = page.locator('aside.panel');
const tech = panel.getByRole('region', { name: 'Engagements techniques' });
await tech.getByRole('group', { name: 'Engagements techniques' }).getByRole('button', { name: 'Tout pour' }).click();
// E3 has two versions: the grouped gesture leaves it, and says so.
const e3 = tech.locator('li.slot', { hasText: 'Connaissance suffisante' });
await expect(e3.getByText('Plusieurs versions : choisis la tienne.')).toBeVisible();
await expect(e3.getByRole('button', { name: 'Pour', pressed: true })).toHaveCount(0);
await expect(panel.getByRole('status')).toContainText('plusieurs versions');
const filled = await tech.getByRole('button', { name: 'Pour', pressed: true }).count();
expect(filled).toBeGreaterThan(3);
// Her version of E3, chosen by hand.
await e3.getByRole('group', { name: /variante 1\.0\.0/ }).getByRole('button', { name: 'Pour' }).click();
const n = filled + 1;
await expect(panel.getByRole('button', { name: `Voter (${n})` })).toBeEnabled();
await shot(page, '2-panier-bureau');
// The basket stays for the tab: a reload keeps it.
await page.reload();
await expect(page.getByRole('button', { name: `Voter en lot · ${n}` })).toBeVisible();
// One gesture, one write.
const writes: string[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/')) writes.push(new URL(r.url()).pathname);
});
await panel.getByRole('button', { name: `Voter (${n})` }).click();
await expect(page.getByText(`${n} votes enregistrés.`)).toBeVisible();
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Each counted: her version of E3 has its voice, the other none.
await expect(e3.getByRole('group', { name: /variante 1\.0\.0/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
});
test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur ; le dernier fait foi', async ({ page, browser }) => {
// The chain is waited for: the server reads the indexer every 10 seconds.
test.setTimeout(90_000);
await signIn(page, 'Chiara');
await wallet(page, [ALICE]);
await page.goto(DOC);
const { control } = await chain(page);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
await shot(page, '0-entete-voter-en-lot');
// The basket: two articles in force, for, in one transaction.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const panel = page.locator('aside.panel');
for (const title of ['Unicité du compte', 'Réciprocité']) {
await panel.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Pour' }).click();
}
await expect(panel.getByText('Une seule transaction de 0,1 Ğ1, signée par ton portefeuille, pour tous tes choix.')).toBeVisible();
await panel.getByRole('button', { name: 'Signer et envoyer (2)' }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(dialog.getByText('2 votes, en une transaction : 0,1 Ğ1 vers le compte des votes, avec ton bulletin scellé.')).toBeVisible();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await expect(dialog.getByText("Compté pour l'identité Ğ1 « Alice ».")).toBeVisible();
// One signature: the amount to the vote account and the sealed ballot — nothing of the vote in clear.
const tx = await signed(page);
expect(tx).toHaveLength(1);
expect(tx[0]).toMatchObject({ address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10' });
expect(tx[0]?.remark).toMatch(/^ld1:[0-9a-f]{64}$/);
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// Under the article: her vote, from the chain, and the totals.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1');
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
// The last one stands, in the order of the chain: a « contre » included first, but seen late
// by the indexer, never overrides the withdrawal included after it.
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
await control('hide');
await fold2.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('La chaîne Ğ1 confirme ton vote', { exact: false })).toBeVisible();
await shot(page, '4-chaine-attente');
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
await control('show');
await fold2.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
await control('release');
await expect(fold2.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0, { timeout: 30_000 });
await expect(fold2.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
// One vote unfolded at a time (an accordion): back to the first, untouched.
await expect(fold1).toHaveCount(0);
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold2).toHaveCount(0);
// Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain.
const other = await person(browser, 'Maëlle');
await other.goto(DOC);
const raison = other.locator('section.text article.unit').first();
await raison.hover();
await raison.getByRole('button', { name: 'Le vote' }).click();
await raison.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible();
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
await expect(sheet.getByRole('img', { name: 'Code à scanner avec Ğecko' })).toBeVisible();
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
expect(link).toMatch(new RegExp(`^g1://${VOTE_ACCOUNT}\\?amount=0\\.1&comment=ld1:[0-9a-f]{64}$`));
await shot(other, '5-gecko-qr');
// Charlie pays it from Ğecko: the simulated node includes it, the page sees it counted.
const { node } = await chain(other);
const remark = link.split('comment=')[1] ?? '';
const fields = { address: CHARLIE.address, dest: VOTE_ACCOUNT, amount: '10', remark, signature: '0x01' };
await other.request.post(node, {
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
});
await expect(sheet.getByText("Compté pour l'identité Ğ1 « Charlie ».")).toBeVisible({ timeout: 20_000 });
});
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
await signIn(page, 'Chiara');
// The e2e server has its vote account: the page is shown what a server without one answers
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
if (body.vote?.way === 'chain') {
body.vote = { ...body.vote, open: false, reason: 'chain_off', chain: null };
body.canVote = false;
}
await route.fulfill({ response: res, json: body });
});
await page.goto(DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// The totals and the threshold stay in sight.
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
await shot(page, '6-vote-ferme');
// The test group still votes, directly.
await page.goto(TEST_DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
const unit = page.locator('section.text article.unit').first();
await unit.hover();
await unit.getByRole('button', { name: 'Le vote' }).click();
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
});
+1503
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -19,9 +19,11 @@
"@sveltejs/kit": "^2.70.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^22.20.5",
"polkadot-api": "3.3.0",
"svelte": "^5.57.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"uqr": "0.1.3",
"vite": "^7.0.0",
"vitest": "^3.2.0"
}
+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.',
@@ -395,13 +468,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.',
@@ -395,13 +468,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.",
@@ -398,13 +471,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));
}
+128 -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}
@@ -80,6 +144,13 @@
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p>
{#if canVote}
<div class="basket-row">
<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>
</div>
{/if}
<div class="controls">
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
@@ -102,14 +173,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 +193,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 +234,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 +308,16 @@
justify-content: space-between;
margin-top: 1.6rem;
}
.basket-row {
margin-top: 1.4rem;
}
.basket-row + .controls {
margin-top: 0.8rem;
}
.basket-btn {
background: var(--water);
color: var(--on-water);
}
.seg {
display: inline-flex;
flex-wrap: wrap;
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return {
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
function unit(id: string, section: string | null, inForce: Proposal | null, proposals: Proposal[]): Unit {
return { id, code: id, section, position: 0, inertia: 3, inertiaChanges: [], inForce, proposals };
}
// The certification act in small: a text in force, a seed with a rival and a removal, two
// open articles, one with two variants, a change of inertia.
const I1 = unit('I1', 'introduction', prop('i1', 'I1', { status: 'in_force' }), []);
const E1 = unit('E1', 'fondamental', prop('e1', 'E1', { status: 'in_force' }), [
prop('e1r', 'E1'),
prop('e1x', 'E1', { kind: 'remove' }),
prop('e1k', 'E1', { kind: 'inertia', inertiaTarget: 5 }),
]);
const E2 = unit('E2', 'fondamental', prop('e2', 'E2', { status: 'in_force' }), [prop('e2k', 'E2', { kind: 'inertia', inertiaTarget: 5 })]);
const E3 = unit('E3', 'technique', null, [prop('e3a', 'E3', { label: '1.0.0' }), prop('e3b', 'E3', { label: '0.4.0' })]);
const E4 = unit('E4', 'technique', null, [prop('e4', 'E4'), prop('e4d', 'E4', { status: 'draft' })]);
const EMPTY = unit('E9', 'technique', null, []);
const DOC = [I1, E1, E2, E3, E4, EMPTY];
describe('the basket of a vote', () => {
it('sections in the order of the document; an article holds its versions in competition', () => {
const s = sections(DOC);
expect(s.map((x) => x.key)).toEqual(['introduction', 'fondamental', 'technique']);
const [, fond, tech] = s;
// The text in force, its rival and its removal compete; a change of inertia is apart.
expect(fond?.slots[0]?.versions.map((p) => p.id)).toEqual(['e1', 'e1r', 'e1x']);
expect(fond?.slots[0]?.inertia.map((p) => p.id)).toEqual(['e1k']);
expect(fond?.slots[1]?.versions.map((p) => p.id)).toEqual(['e2']);
// Drafts are nobody's to vote; an empty slot is left out.
expect(tech?.slots.map((x) => x.unit.id)).toEqual(['E3', 'E4']);
expect(tech?.slots[1]?.versions.map((p) => p.id)).toEqual(['e4']);
});
it('a grouped gesture fills the articles of a single version, never one with several', () => {
const all = sections(DOC).flatMap((x) => x.slots);
const picks = fill(all, 'for', {});
expect(picks).toEqual({ i1: 'for', e2: 'for', e4: 'for' });
// Not the change of inertia of E2, not E1 (three versions), not E3 (two variants).
expect(waiting(all, picks).map((x) => x.unit.id)).toEqual(['E1', 'E3']);
// Choosing a version settles the article.
const chosen = { ...picks, e3b: 'against' as const };
expect(waiting(all, chosen).map((x) => x.unit.id)).toEqual(['E1']);
// A section only: the others stay as they were.
const tech = sections(DOC)[2]?.slots ?? [];
expect(fill(tech, 'against', { i1: 'for', e3a: 'for' })).toEqual({ i1: 'for', e3a: 'for', e4: 'against' });
});
it('emptying a section leaves the rest', () => {
const fond = sections(DOC)[1]?.slots ?? [];
expect(clearScope(fond, { i1: 'for', e2: 'for', e1k: 'against', e4: 'none' })).toEqual({ i1: 'for', e4: 'none' });
});
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']);
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null },
{ proposalId: 'e4', choice: 'against' },
]);
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
});
});
+2
View File
@@ -11,6 +11,8 @@ import { fr } from '../../src/lib/i18n/fr';
export const FORBIDDEN_UI_TERMS = [
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
'verdict', 'triage', 'oligarchie', 'peuple adulte',
// The Ğ1 chain and its wallets, in plain words (lot B): never the jargon.
'blockchain', 'extrinsic', 'extrinsèque',
];
const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
+1 -1
View File
@@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
const COMMENT = `ld1:${'ab'.repeat(32)}`;
describe('Ğecko', () => {
it('the link and the QR code carry the account, the amount in Ğ1 and the sealed comment', () => {
expect(geckoLink(ACCOUNT, 10, COMMENT)).toBe(`g1://${ACCOUNT}?amount=0.1&comment=${COMMENT}`);
// Ğ1nkgo keeps [A-Za-z0-9 .;:!?()-_@&<>%]: the comment passes untouched.
expect(/^[A-Za-z0-9 .;:!?()\-_@&<>%]+$/.test(COMMENT)).toBe(true);
});
it('amounts in cents read as Ğ1', () => {
expect([amountText(10), amountText(100), amountText(125), amountText(1)]).toEqual(['0.1', '1', '1.25', '0.01']);
});
});
describe('wallets of the page', () => {
it('every extension offering the standard interface is found, the one for Ğ1 first', () => {
const win = { injectedWeb3: { 'polkadot-js': { enable: async () => ({}) }, 'duniter-connect': { enable: async () => ({}) }, broken: {} } };
expect(injectedNames(win)).toEqual(['duniter-connect', 'polkadot-js']);
expect(injectedNames({})).toEqual([]);
});
it('what a wallet answers is told in words', () => {
expect(failure(new Error('Cancelled'))).toBe('cancelled');
expect(failure(new Error('Rejected by user'))).toBe('cancelled');
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
expect(failure(new Error('socket hang up'))).toBe('wallet');
});
it('in tests, the fake node receives what was signed, readable', () => {
const hex = testExtrinsic({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
expect(hex.startsWith('0x')).toBe(true);
const back = JSON.parse(new TextDecoder().decode(Uint8Array.from((hex.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16)))));
expect(back).toEqual({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
});
});