forked from yvv/decision
v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
(enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
inertie et formule. Le reste d'une version (coquilles, traductions,
signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
pour tout le document, jamais pour un article à plusieurs versions ; un seul
geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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 }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await wallet(page, [ALICE]);
|
||||
await page.goto(DOC);
|
||||
const { control } = await chain(page);
|
||||
// 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();
|
||||
});
|
||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+28
-110
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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
@@ -122,6 +122,8 @@ export const en: Messages = {
|
||||
'doc.action.vote': 'Voting',
|
||||
'doc.action.proposals': 'Proposals',
|
||||
'doc.action.propose': 'Propose a change',
|
||||
'doc.basket': 'Vote in bulk',
|
||||
'doc.basket.count': 'Vote in bulk · {n}',
|
||||
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
|
||||
'doc.published': 'Published to the text repository',
|
||||
'doc.publish.pending': 'Publication pending',
|
||||
@@ -176,6 +178,8 @@ export const en: Messages = {
|
||||
'panel.proposals': 'Proposals',
|
||||
'panel.propose': 'Propose',
|
||||
'panel.settings': 'Settings',
|
||||
'panel.basket': 'Basket',
|
||||
'panel.details': 'Details',
|
||||
'panel.close': 'Close panel',
|
||||
'panel.inforce': 'In force',
|
||||
'panel.proposal': 'Proposal',
|
||||
@@ -199,7 +203,76 @@ export const en: Messages = {
|
||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||
'vote.closed': 'This version is no longer open for voting.',
|
||||
'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.',
|
||||
'vote.fold': 'The vote on “{title}”',
|
||||
'vote.mine.none': 'You have not voted on this version.',
|
||||
'vote.mine.for': 'Your vote: for',
|
||||
'vote.mine.against': 'Your vote: against',
|
||||
'vote.mine.chain': 'recorded on the Ğ1 chain',
|
||||
'vote.mine.saved': 'saved',
|
||||
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
|
||||
'vote.mine.refused': 'Your last vote was not counted: {reason}',
|
||||
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
|
||||
'vote.choice.for': 'for',
|
||||
'vote.choice.against': 'against',
|
||||
'vote.choice.none': 'withdrawing your vote',
|
||||
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
|
||||
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
|
||||
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
|
||||
'vote.members': 'Only members of this group vote here.',
|
||||
'vote.details': 'Typos, translations, flags, history',
|
||||
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
|
||||
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
|
||||
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
|
||||
'basket.title': 'Voting basket',
|
||||
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
|
||||
'basket.doc': 'The whole document',
|
||||
'basket.for': 'All for',
|
||||
'basket.against': 'All against',
|
||||
'basket.clear': 'Empty',
|
||||
'basket.pick': 'Your choice for “{title}”',
|
||||
'basket.pick.retract': 'Withdraw',
|
||||
'basket.versions': 'Several versions: choose yours.',
|
||||
'basket.waiting.one': '{n} article has several versions: a grouped gesture does not fill it, choose your version.',
|
||||
'basket.waiting.other': '{n} articles have several versions: a grouped gesture does not fill them, choose your version.',
|
||||
'basket.inforce': 'in force',
|
||||
'basket.count.one': '{n} choice',
|
||||
'basket.count.other': '{n} choices',
|
||||
'basket.send.server': 'Vote ({n})',
|
||||
'basket.send.chain': 'Sign and send ({n})',
|
||||
'basket.note.server': 'Your votes count as soon as they are sent, all at once.',
|
||||
'basket.note.chain': 'A single transaction of {amount} Ğ1, signed by your wallet, for all your choices.',
|
||||
'basket.empty': 'Nothing in the basket yet.',
|
||||
'basket.sent.one': '{n} vote saved.',
|
||||
'basket.sent.other': '{n} votes saved.',
|
||||
'basket.section': 'Vote on this section',
|
||||
'basket.mine': 'your vote: {choice}',
|
||||
'sign.title': 'Vote with your Ğ1 wallet',
|
||||
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
|
||||
'sign.wallet': 'With which wallet?',
|
||||
'sign.account': 'With which account?',
|
||||
'sign.opening': 'Your wallet is opening…',
|
||||
'sign.signing': 'Confirm in your wallet.',
|
||||
'sign.waiting': 'The Ğ1 chain is confirming your vote, usually in less than a minute.',
|
||||
'sign.confirmed': 'Your vote counts: it is recorded on the Ğ1 chain.',
|
||||
'sign.confirmed.as': 'Counted for the Ğ1 identity “{name}”.',
|
||||
'sign.refused': 'Your vote was not counted: {reason}',
|
||||
'sign.expired': 'Your vote was not seen on the Ğ1 chain in time: it does not count. Start again whenever you like.',
|
||||
'sign.error.cancelled': 'You declined to sign: nothing was sent.',
|
||||
'sign.error.funds': 'Your wallet does not have enough Ğ1: at least 1 Ğ1 must remain after the vote.',
|
||||
'sign.error.node': 'The Ğ1 chain is not answering. Try again in a moment.',
|
||||
'sign.error.account': 'This account is no longer in your wallet.',
|
||||
'sign.error.wallet': 'Your wallet could not sign. Try again, or use Ğecko.',
|
||||
'sign.none': 'No wallet is connected to this browser (duniter-connect, for example). On your phone, Ğecko does the job.',
|
||||
'sign.gecko': 'Use Ğecko',
|
||||
'sign.gecko.scan': 'Scan this code with Ğecko, or tap the link on your phone. Check the amount and the comment, then confirm.',
|
||||
'sign.gecko.open': 'Open in Ğecko',
|
||||
'sign.gecko.amount': 'Amount',
|
||||
'sign.gecko.comment': 'Comment',
|
||||
'sign.gecko.qr': 'Code to scan with Ğecko',
|
||||
'sign.close': 'Close',
|
||||
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
|
||||
'sign.retry': 'Try again',
|
||||
|
||||
'inertia.title': 'Inertia',
|
||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||
@@ -394,13 +467,21 @@ export const en: Messages = {
|
||||
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
|
||||
'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.',
|
||||
'error.changed': 'The text has changed in the meantime: reload it, then try again.',
|
||||
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
|
||||
'error.label': 'The name is empty or too long (60 characters at most).',
|
||||
'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.',
|
||||
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
|
||||
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
|
||||
'error.groups': 'Choose at least one group.',
|
||||
'error.publication_off': 'Publication is not configured on this server.',
|
||||
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
|
||||
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
|
||||
'error.server_vote': 'Here, voting is direct, without a wallet.',
|
||||
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
|
||||
'error.lines': 'This vote no longer holds: reload the page, then start again.',
|
||||
'error.choice': 'This choice does not exist.',
|
||||
'error.author': 'This account is not a Ğ1 address.',
|
||||
'error.not_votable': 'This version is no longer open for voting.',
|
||||
'error.members_only': 'Only members of this group vote here.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
+83
-2
@@ -122,6 +122,8 @@ export const es: Messages = {
|
||||
'doc.action.vote': 'Votación',
|
||||
'doc.action.proposals': 'Propuestas',
|
||||
'doc.action.propose': 'Proponer una modificación',
|
||||
'doc.basket': 'Votar en bloque',
|
||||
'doc.basket.count': 'Votar en bloque · {n}',
|
||||
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
|
||||
'doc.published': 'Publicado en el repositorio de textos',
|
||||
'doc.publish.pending': 'Publicación pendiente',
|
||||
@@ -176,6 +178,8 @@ export const es: Messages = {
|
||||
'panel.proposals': 'Propuestas',
|
||||
'panel.propose': 'Proponer',
|
||||
'panel.settings': 'Ajustes',
|
||||
'panel.basket': 'Cesta',
|
||||
'panel.details': 'Detalles',
|
||||
'panel.close': 'Cerrar el panel',
|
||||
'panel.inforce': 'En vigor',
|
||||
'panel.proposal': 'Propuesta',
|
||||
@@ -199,7 +203,76 @@ export const es: Messages = {
|
||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||
'vote.closed': 'Esta versión ya no está en votación.',
|
||||
'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.',
|
||||
'vote.fold': 'La votación de «{title}»',
|
||||
'vote.mine.none': 'No has votado esta versión.',
|
||||
'vote.mine.for': 'Tu voto: a favor',
|
||||
'vote.mine.against': 'Tu voto: en contra',
|
||||
'vote.mine.chain': 'inscrito en la cadena Ğ1',
|
||||
'vote.mine.saved': 'registrado',
|
||||
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
|
||||
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
|
||||
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
|
||||
'vote.choice.for': 'a favor',
|
||||
'vote.choice.against': 'en contra',
|
||||
'vote.choice.none': 'retirar tu voto',
|
||||
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
|
||||
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
|
||||
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
|
||||
'vote.members': 'Aquí solo votan los miembros de este grupo.',
|
||||
'vote.details': 'Erratas, traducciones, señalamientos, historial',
|
||||
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
|
||||
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
|
||||
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
|
||||
'basket.title': 'Cesta de votación',
|
||||
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
|
||||
'basket.doc': 'Todo el documento',
|
||||
'basket.for': 'Todo a favor',
|
||||
'basket.against': 'Todo en contra',
|
||||
'basket.clear': 'Vaciar',
|
||||
'basket.pick': 'Tu elección para «{title}»',
|
||||
'basket.pick.retract': 'Retirar',
|
||||
'basket.versions': 'Varias versiones: elige la tuya.',
|
||||
'basket.waiting.one': '{n} artículo tiene varias versiones: un gesto agrupado no lo rellena, elige tu versión.',
|
||||
'basket.waiting.other': '{n} artículos tienen varias versiones: un gesto agrupado no los rellena, elige tu versión.',
|
||||
'basket.inforce': 'en vigor',
|
||||
'basket.count.one': '{n} elección',
|
||||
'basket.count.other': '{n} elecciones',
|
||||
'basket.send.server': 'Votar ({n})',
|
||||
'basket.send.chain': 'Firmar y enviar ({n})',
|
||||
'basket.note.server': 'Tus votos cuentan en cuanto se envían, de una sola vez.',
|
||||
'basket.note.chain': 'Una sola transacción de {amount} Ğ1, firmada por tu monedero, para todas tus elecciones.',
|
||||
'basket.empty': 'Nada en la cesta por ahora.',
|
||||
'basket.sent.one': '{n} voto registrado.',
|
||||
'basket.sent.other': '{n} votos registrados.',
|
||||
'basket.section': 'Votar esta sección',
|
||||
'basket.mine': 'tu voto: {choice}',
|
||||
'sign.title': 'Votar con tu monedero Ğ1',
|
||||
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
|
||||
'sign.wallet': '¿Con qué monedero?',
|
||||
'sign.account': '¿Con qué cuenta?',
|
||||
'sign.opening': 'Tu monedero se abre…',
|
||||
'sign.signing': 'Confirma en tu monedero.',
|
||||
'sign.waiting': 'La cadena Ğ1 confirma tu voto, en general en menos de un minuto.',
|
||||
'sign.confirmed': 'Tu voto cuenta: está inscrito en la cadena Ğ1.',
|
||||
'sign.confirmed.as': 'Contado para la identidad Ğ1 «{name}».',
|
||||
'sign.refused': 'Tu voto no se contó: {reason}',
|
||||
'sign.expired': 'Tu voto no se vio a tiempo en la cadena Ğ1: no cuenta. Vuelve a empezar cuando quieras.',
|
||||
'sign.error.cancelled': 'Rechazaste la firma: no se envió nada.',
|
||||
'sign.error.funds': 'Tu monedero no tiene suficientes Ğ1: debe quedarle al menos 1 Ğ1 después del voto.',
|
||||
'sign.error.node': 'La cadena Ğ1 no responde. Vuelve a intentarlo en un momento.',
|
||||
'sign.error.account': 'Esta cuenta ya no está en tu monedero.',
|
||||
'sign.error.wallet': 'Tu monedero no pudo firmar. Vuelve a intentarlo, o usa Ğecko.',
|
||||
'sign.none': 'Ningún monedero está conectado a este navegador (duniter-connect, por ejemplo). En tu teléfono, Ğecko sirve.',
|
||||
'sign.gecko': 'Usar Ğecko',
|
||||
'sign.gecko.scan': 'Escanea este código con Ğecko, o toca el enlace en tu teléfono. Comprueba el importe y el comentario, y luego valida.',
|
||||
'sign.gecko.open': 'Abrir en Ğecko',
|
||||
'sign.gecko.amount': 'Importe',
|
||||
'sign.gecko.comment': 'Comentario',
|
||||
'sign.gecko.qr': 'Código para escanear con Ğecko',
|
||||
'sign.close': 'Cerrar',
|
||||
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
|
||||
'sign.retry': 'Reintentar',
|
||||
|
||||
'inertia.title': 'Inercia',
|
||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
||||
@@ -394,13 +467,21 @@ export const es: Messages = {
|
||||
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
|
||||
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.',
|
||||
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
|
||||
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
|
||||
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
|
||||
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.',
|
||||
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
|
||||
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
|
||||
'error.groups': 'Elige al menos un grupo.',
|
||||
'error.publication_off': 'La publicación no está configurada en este servidor.',
|
||||
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
|
||||
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
|
||||
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
|
||||
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
|
||||
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
|
||||
'error.choice': 'Esta elección no existe.',
|
||||
'error.author': 'Esta cuenta no es una dirección Ğ1.',
|
||||
'error.not_votable': 'Esta versión ya no está en votación.',
|
||||
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
|
||||
+83
-2
@@ -125,6 +125,8 @@ export const fr = {
|
||||
'doc.action.vote': 'Le vote',
|
||||
'doc.action.proposals': 'Propositions',
|
||||
'doc.action.propose': 'Proposer une modification',
|
||||
'doc.basket': 'Voter en lot',
|
||||
'doc.basket.count': 'Voter en lot · {n}',
|
||||
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
|
||||
'doc.published': 'Publié sur le dépôt des textes',
|
||||
'doc.publish.pending': 'Publication en attente',
|
||||
@@ -179,6 +181,8 @@ export const fr = {
|
||||
'panel.proposals': 'Propositions',
|
||||
'panel.propose': 'Proposer',
|
||||
'panel.settings': 'Réglages',
|
||||
'panel.basket': 'Panier',
|
||||
'panel.details': 'Détails',
|
||||
'panel.close': 'Fermer le panneau',
|
||||
'panel.inforce': 'En vigueur',
|
||||
'panel.proposal': 'Proposition',
|
||||
@@ -202,7 +206,76 @@ export const fr = {
|
||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||
'vote.closed': 'Cette version ne se vote plus.',
|
||||
'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.',
|
||||
'vote.fold': 'Le vote de « {title} »',
|
||||
'vote.mine.none': "Tu n'as pas voté sur cette version.",
|
||||
'vote.mine.for': 'Ton vote : pour',
|
||||
'vote.mine.against': 'Ton vote : contre',
|
||||
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
|
||||
'vote.mine.saved': 'enregistré',
|
||||
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
|
||||
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
|
||||
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
|
||||
'vote.choice.for': 'pour',
|
||||
'vote.choice.against': 'contre',
|
||||
'vote.choice.none': 'retrait de ton vote',
|
||||
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
|
||||
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
|
||||
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
|
||||
'vote.members': 'Seuls les membres de ce groupe votent ici.',
|
||||
'vote.details': 'Coquilles, traductions, signalements, historique',
|
||||
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
|
||||
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
|
||||
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
|
||||
'basket.title': 'Panier de vote',
|
||||
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
|
||||
'basket.doc': 'Tout le document',
|
||||
'basket.for': 'Tout pour',
|
||||
'basket.against': 'Tout contre',
|
||||
'basket.clear': 'Vider',
|
||||
'basket.pick': 'Ton choix pour « {title} »',
|
||||
'basket.pick.retract': 'Retirer',
|
||||
'basket.versions': 'Plusieurs versions : choisis la tienne.',
|
||||
'basket.waiting.one': '{n} article a plusieurs versions : un geste groupé ne le remplit pas, choisis ta version.',
|
||||
'basket.waiting.other': '{n} articles ont plusieurs versions : un geste groupé ne les remplit pas, choisis ta version.',
|
||||
'basket.inforce': 'en vigueur',
|
||||
'basket.count.one': '{n} choix',
|
||||
'basket.count.other': '{n} choix',
|
||||
'basket.send.server': 'Voter ({n})',
|
||||
'basket.send.chain': 'Signer et envoyer ({n})',
|
||||
'basket.note.server': "Tes votes comptent dès l'envoi, en une seule fois.",
|
||||
'basket.note.chain': 'Une seule transaction de {amount} Ğ1, signée par ton portefeuille, pour tous tes choix.',
|
||||
'basket.empty': "Rien dans le panier pour l'instant.",
|
||||
'basket.sent.one': '{n} vote enregistré.',
|
||||
'basket.sent.other': '{n} votes enregistrés.',
|
||||
'basket.section': 'Voter cette section',
|
||||
'basket.mine': 'ton vote : {choice}',
|
||||
'sign.title': 'Voter avec ton portefeuille Ğ1',
|
||||
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
|
||||
'sign.wallet': 'Avec quel portefeuille ?',
|
||||
'sign.account': 'Avec quel compte ?',
|
||||
'sign.opening': "Ton portefeuille s'ouvre…",
|
||||
'sign.signing': 'Confirme dans ton portefeuille.',
|
||||
'sign.waiting': "La chaîne Ğ1 confirme ton vote, en général en moins d'une minute.",
|
||||
'sign.confirmed': 'Ton vote compte : il est inscrit sur la chaîne Ğ1.',
|
||||
'sign.confirmed.as': "Compté pour l'identité Ğ1 « {name} ».",
|
||||
'sign.refused': "Ton vote n'a pas été compté : {reason}",
|
||||
'sign.expired': "Ton vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas. Recommence quand tu veux.",
|
||||
'sign.error.cancelled': "Tu as refusé la signature : rien n'est parti.",
|
||||
'sign.error.funds': "Ton portefeuille n'a pas assez de Ğ1 : il doit lui rester au moins 1 Ğ1 après le vote.",
|
||||
'sign.error.node': 'La chaîne Ğ1 ne répond pas. Réessaie dans un moment.',
|
||||
'sign.error.account': "Ce compte n'est plus dans ton portefeuille.",
|
||||
'sign.error.wallet': "Ton portefeuille n'a pas pu signer. Réessaie, ou passe par Ğecko.",
|
||||
'sign.none': "Aucun portefeuille n'est branché à ce navigateur (duniter-connect, par exemple). Sur ton téléphone, Ğecko fait l'affaire.",
|
||||
'sign.gecko': 'Passer par Ğecko',
|
||||
'sign.gecko.scan': 'Scanne ce code avec Ğecko, ou touche le lien sur ton téléphone. Vérifie le montant et le commentaire, puis valide.',
|
||||
'sign.gecko.open': 'Ouvrir dans Ğecko',
|
||||
'sign.gecko.amount': 'Montant',
|
||||
'sign.gecko.comment': 'Commentaire',
|
||||
'sign.gecko.qr': 'Code à scanner avec Ğecko',
|
||||
'sign.close': 'Fermer',
|
||||
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
|
||||
'sign.retry': 'Réessayer',
|
||||
|
||||
'inertia.title': 'Inertie',
|
||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||
@@ -397,13 +470,21 @@ export const fr = {
|
||||
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
|
||||
'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.",
|
||||
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
|
||||
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
|
||||
'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
|
||||
'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.",
|
||||
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
|
||||
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
|
||||
'error.groups': 'Choisis au moins un groupe.',
|
||||
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
|
||||
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
|
||||
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
|
||||
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
|
||||
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
|
||||
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
|
||||
'error.choice': "Ce choix n'existe pas.",
|
||||
'error.author': "Ce compte n'est pas une adresse Ğ1.",
|
||||
'error.not_votable': 'Cette version ne se vote plus.',
|
||||
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,47 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ChainSign from '$lib/components/ChainSign.svelte';
|
||||
import DeepList from '$lib/components/DeepList.svelte';
|
||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
import { sections } from '$lib/basket';
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { BasketState } from '$lib/picks.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
|
||||
const doc = new DocState();
|
||||
const basket = new BasketState();
|
||||
const group = $derived(page.params.group ?? '');
|
||||
const slug = $derived(page.params.doc ?? '');
|
||||
$effect(() => {
|
||||
void doc.load(group, slug);
|
||||
});
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
||||
$effect(() => {
|
||||
const v = doc.view;
|
||||
if (!v) return;
|
||||
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
basket.load(v.doc.id, known);
|
||||
});
|
||||
|
||||
// The panel lives in the URL: a link (or a feedback report) reopens it.
|
||||
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
|
||||
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
let prefill = $state<PanelState['prefill']>(null);
|
||||
const panel = $derived.by((): PanelState | null => {
|
||||
const q = page.url.searchParams;
|
||||
const tab = TABS[q.get('onglet') ?? ''];
|
||||
if (!tab) return null;
|
||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), prefill };
|
||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
|
||||
});
|
||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||
const vote = $derived.by(() => {
|
||||
const q = page.url.searchParams;
|
||||
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
|
||||
});
|
||||
|
||||
function openPanel(next: PanelState | null) {
|
||||
prefill = next?.prefill ?? null;
|
||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||
prefill = nextPanel?.prefill ?? null;
|
||||
const q = new URLSearchParams();
|
||||
if (next) {
|
||||
q.set('onglet', TAB_NAMES[next.tab]);
|
||||
if (next.unitId) q.set('article', next.unitId);
|
||||
if (next.proposalId) q.set('version', next.proposalId);
|
||||
if (next.draftId) q.set('brouillon', next.draftId);
|
||||
if (nextVote) q.set('vote', nextVote);
|
||||
if (nextPanel) {
|
||||
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
||||
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
||||
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
||||
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
||||
if (nextPanel.section) q.set('section', nextPanel.section);
|
||||
}
|
||||
const search = q.toString();
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
||||
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
}
|
||||
const openPanel = (next: PanelState | null) => go(next, vote);
|
||||
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
|
||||
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
|
||||
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
|
||||
|
||||
// On the Ğ1 chain: one signing at a time, over the page.
|
||||
let signing = $state<Line[] | null>(null);
|
||||
const sign = (lines: Line[]) => (signing = lines);
|
||||
// While a ballot waits for the chain, the page looks again every ten seconds.
|
||||
const waitingChain = $derived(
|
||||
(doc.view?.units ?? []).some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!waitingChain) return;
|
||||
const every = setInterval(() => void doc.reload(), 10_000);
|
||||
return () => clearInterval(every);
|
||||
});
|
||||
|
||||
let filter = $state<Filter>('all');
|
||||
const view = $derived(doc.view);
|
||||
@@ -54,14 +95,37 @@
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
function onUnit(u: Unit, tab: Tab) {
|
||||
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
|
||||
}
|
||||
function onProposal(u: Unit, p: Proposal) {
|
||||
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
|
||||
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
||||
if (action === 'vote') {
|
||||
if (version) toggleVote(version);
|
||||
return;
|
||||
}
|
||||
openPanel({ unitId: u.id, proposalId: null, tab: action });
|
||||
}
|
||||
// The text shows the vote of its own versions; the list below, that of the others.
|
||||
const textIds = $derived(
|
||||
new Set(
|
||||
doc.mode === 'inforce' || !doc.preview
|
||||
? doc.inForce.map((u) => u.inForce?.id)
|
||||
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
|
||||
),
|
||||
);
|
||||
const inText = $derived(!!vote && textIds.has(vote));
|
||||
const canVote = $derived(!!view?.vote.open);
|
||||
</script>
|
||||
|
||||
{#snippet foldOf(unit: Unit, p: Proposal)}
|
||||
<VoteFold
|
||||
{doc}
|
||||
proposal={p}
|
||||
{unit}
|
||||
onopen={(id) => go(panel, id)}
|
||||
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
|
||||
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
|
||||
onsign={sign}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
||||
|
||||
{#if doc.error}
|
||||
@@ -81,6 +145,11 @@
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
{#if canVote}
|
||||
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
|
||||
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
|
||||
{#each VIEWS as v (v)}
|
||||
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
|
||||
@@ -102,14 +171,19 @@
|
||||
{#if doc.mode === 'inforce' || !doc.preview}
|
||||
{#each doc.inForce as u (u.id)}
|
||||
{#if u.inForce}
|
||||
{@const p = u.inForce}
|
||||
<UnitBlock
|
||||
text={shown(u.inForce, lang, source)}
|
||||
seedMinimal={u.inForce.seedMinimal}
|
||||
enteredAt={u.inForce.enteredAt}
|
||||
text={shown(p, lang, source)}
|
||||
seedMinimal={p.seedMinimal}
|
||||
enteredAt={p.enteredAt}
|
||||
rivals={rivalsOf(u)}
|
||||
selected={panel?.unitId === u.id}
|
||||
onaction={(tab) => onUnit(u, tab)}
|
||||
/>
|
||||
voteFor={p.id}
|
||||
voteOpen={inText && vote === p.id}
|
||||
onaction={(action) => onUnit(u, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">{t('doc.empty')}</p>
|
||||
@@ -117,12 +191,18 @@
|
||||
{:else}
|
||||
{#each doc.preview as pu (pu.unitId)}
|
||||
{@const u = doc.unit(pu.unitId)}
|
||||
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
||||
{@const p = doc.proposal(id)}
|
||||
<UnitBlock
|
||||
text={shown(pu, lang, source)}
|
||||
mark={pu.mark}
|
||||
selected={panel?.unitId === pu.unitId}
|
||||
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
|
||||
/>
|
||||
voteFor={id}
|
||||
voteOpen={inText && !!id && vote === id}
|
||||
onaction={(action) => u && onUnit(u, action, id)}
|
||||
>
|
||||
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
|
||||
</UnitBlock>
|
||||
{/each}
|
||||
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
||||
{/if}
|
||||
@@ -152,17 +232,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<DeepList
|
||||
articles={deep}
|
||||
vote={inText ? null : vote}
|
||||
ontoggle={(_, p) => toggleVote(p.id)}
|
||||
fold={foldOf}
|
||||
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
||||
/>
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if panel}
|
||||
<DocPanel {doc} {panel} {lang} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
|
||||
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
|
||||
{/if}
|
||||
{#if signing && view.vote.chain}
|
||||
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
|
||||
|
||||
<style>
|
||||
.reader {
|
||||
@@ -217,6 +306,13 @@
|
||||
justify-content: space-between;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
.basket-btn {
|
||||
flex-basis: 100%;
|
||||
justify-self: start;
|
||||
max-width: max-content;
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user