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:
@@ -310,6 +310,29 @@ pub async fn status(
|
|||||||
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/ballots/{id}` — the wallet refused to sign, or could not pay:
|
||||||
|
/// nothing left for the chain, so the ballot is let go at once rather than shown
|
||||||
|
/// as waiting for a day. Only a pending ballot, only by the session that asked.
|
||||||
|
pub async fn drop(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let row = load(&state, &id).await?;
|
||||||
|
if row.requested_by != user.id {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ballots SET status = 'expired', reason = 'dropped', settled_at = ? WHERE id = ? AND status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
let row = load(&state, &id).await?;
|
||||||
|
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
||||||
|
}
|
||||||
|
|
||||||
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
||||||
let voter: Option<String> = match &row.voter_id {
|
let voter: Option<String> = match &row.voter_id {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
@@ -579,6 +602,7 @@ pub struct MyBallot {
|
|||||||
|
|
||||||
/// Per version: the most recent pending ballot of `me`; else its most recent
|
/// Per version: the most recent pending ballot of `me`; else its most recent
|
||||||
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
|
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
|
||||||
|
/// A ballot the wallet never signed (dropped) says nothing.
|
||||||
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -591,7 +615,8 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
|||||||
let rows = sqlx::query_as::<_, Row>(
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
"SELECT l.proposal_id, b.id, b.status, b.reason, l.choice FROM ballots b
|
"SELECT l.proposal_id, b.id, b.status, b.reason, l.choice FROM ballots b
|
||||||
JOIN ballot_lines l ON l.ballot_id = b.id
|
JOIN ballot_lines l ON l.ballot_id = b.id
|
||||||
WHERE b.doc_id = ? AND b.requested_by = ? ORDER BY b.created_at, b.rowid",
|
WHERE b.doc_id = ? AND b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
||||||
|
ORDER BY b.created_at, b.rowid",
|
||||||
)
|
)
|
||||||
.bind(doc_id)
|
.bind(doc_id)
|
||||||
.bind(me)
|
.bind(me)
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/docs/{id}/proposals", post(proposals::create))
|
.route("/docs/{id}/proposals", post(proposals::create))
|
||||||
.route("/docs/{id}/votes", post(ballots::vote_many))
|
.route("/docs/{id}/votes", post(ballots::vote_many))
|
||||||
.route("/docs/{id}/ballots", post(ballots::create))
|
.route("/docs/{id}/ballots", post(ballots::create))
|
||||||
.route("/ballots/{id}", get(ballots::status))
|
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
|
||||||
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
||||||
.route("/proposals/{id}/submit", post(proposals::submit))
|
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||||
|
|||||||
@@ -568,6 +568,37 @@ async fn a_ballot_not_seen_in_time_is_let_go() {
|
|||||||
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired");
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_ballot_the_wallet_refused_to_sign_is_let_go_at_once() {
|
||||||
|
let indexer = Indexer::start().await;
|
||||||
|
let (mut c, state) = Client::new(chain_config(&indexer)).await;
|
||||||
|
c.login("Chiara").await;
|
||||||
|
let doc = c.doc("monnaie-libre").await;
|
||||||
|
let i1 = version(&doc, "I1", "inForce");
|
||||||
|
let first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
|
||||||
|
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
|
||||||
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]);
|
||||||
|
// Another session may not let it go.
|
||||||
|
let mut other = c.other();
|
||||||
|
other.login("Yvv").await;
|
||||||
|
let (s, _) = other.call("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
// Its own session does: no longer waiting, and it says nothing under the article.
|
||||||
|
let dropped = c.ok("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
|
||||||
|
assert_eq!(
|
||||||
|
(dropped["ballot"]["status"].as_str(), dropped["ballot"]["reason"].as_str()),
|
||||||
|
(Some("expired"), Some("dropped"))
|
||||||
|
);
|
||||||
|
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], first["id"]);
|
||||||
|
// A confirmed ballot is never let go.
|
||||||
|
indexer.pay(100, first["comment"].as_str().expect("c"), alice());
|
||||||
|
ballots::sweep(&state).await.expect("sweep");
|
||||||
|
let kept = c.ok("DELETE", &format!("/api/ballots/{}", first["id"].as_str().expect("id")), None).await;
|
||||||
|
assert_eq!(kept["ballot"]["status"], "confirmed");
|
||||||
|
let now = c.doc("monnaie-libre").await;
|
||||||
|
assert_eq!((find(&now, &i1)["myVote"].as_str(), find(&now, &i1)["myBallot"].is_null()), (Some("against"), true));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_session_sees_its_own_ballots_only() {
|
async fn a_session_sees_its_own_ballots_only() {
|
||||||
let indexer = Indexer::start().await;
|
let indexer = Indexer::start().await;
|
||||||
|
|||||||
@@ -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 { 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 ML = '/g/monnaie-libre';
|
||||||
const path = (p: string) => (u: URL) => u.pathname === p;
|
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('33 modules à voter');
|
||||||
await expect(rows.first()).toContainText('dont 33 sans ton vote');
|
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 »
|
// 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).
|
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
|
||||||
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
|
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
|
||||||
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
|
// account) would send, on the simulated chain.
|
||||||
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
|
const { doc, node } = await chain(page);
|
||||||
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
|
const e3 = doc.units.find((u) => u.code === 'E3');
|
||||||
data: { choice: 'against' },
|
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' },
|
headers: { 'x-requested-with': 'libredecision' },
|
||||||
});
|
});
|
||||||
expect(r.ok()).toBe(true);
|
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 page.reload();
|
||||||
await expect(rows.first()).toContainText('33 modules à voter');
|
await expect(rows.first()).toContainText('33 modules à voter');
|
||||||
await expect(rows.first()).toContainText('dont 32 sans ton vote');
|
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 DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
|
||||||
export const TEST_DOC = '/g/essai/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) {
|
export async function signIn(page: Page, name: string, admin = false) {
|
||||||
const r = await page.request.post('/api/auth/dev', {
|
const r = await page.request.post('/api/auth/dev', {
|
||||||
data: { name, admin },
|
data: { name, admin },
|
||||||
@@ -17,3 +24,61 @@ export async function person(browser: Browser, name: string, admin = false): Pro
|
|||||||
await signIn(page, name, admin);
|
await signIn(page, name, admin);
|
||||||
return page;
|
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 { expect, test, type Locator } from '@playwright/test';
|
||||||
import { DOC, signIn } from './helpers';
|
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 signIn(page, 'Chiara');
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const brand = page.locator('header.topbar a.brand');
|
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' });
|
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
|
||||||
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
|
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.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();
|
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();
|
const first = page.locator('section.text article.unit').first();
|
||||||
await first.getByRole('button', { name: 'Le vote' }).click();
|
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.
|
// 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 fold.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||||
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
|
const panel = page.locator('aside.panel');
|
||||||
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
|
await expect(panel.getByRole('tab', { name: 'Réglages' })).toBeInViewport();
|
||||||
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
|
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);
|
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 { 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 }) => {
|
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
|
||||||
await page.goto('/');
|
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);
|
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 signIn(page, 'Chiara');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
const first = page.locator('section.text article.unit').first();
|
const first = page.locator('section.text article.unit').first();
|
||||||
await first.hover();
|
await first.hover();
|
||||||
await first.getByRole('button', { name: 'Le vote' }).click();
|
const toggle = first.getByRole('button', { name: 'Le vote' });
|
||||||
const panel = page.locator('aside.panel');
|
await toggle.click();
|
||||||
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
|
const fold = first.locator('section.fold');
|
||||||
await panel.getByText('La formule du seuil').click();
|
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||||
await expect(panel.getByText(/W — électorat/)).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.
|
// 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(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
|
||||||
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
|
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
|
||||||
// The panel lives in the URL.
|
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
|
||||||
await expect(page).toHaveURL(/onglet=vote/);
|
await expect(page.locator('aside.panel')).toHaveCount(0);
|
||||||
await page.keyboard.press('Escape');
|
await expect(page).toHaveURL(/vote=/);
|
||||||
await expect(panel).toHaveCount(0);
|
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 }) => {
|
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]) {
|
for (const p of [yvv, chiara]) {
|
||||||
await p.goto(TEST_DOC);
|
await p.goto(TEST_DOC);
|
||||||
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
|
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(p.getByText('Vote enregistré.')).toBeVisible();
|
||||||
}
|
}
|
||||||
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).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);
|
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 }) => {
|
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' });
|
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||||
await unicity.hover();
|
await unicity.hover();
|
||||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
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');
|
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();
|
await panel.getByText('Corriger une coquille').click();
|
||||||
const body = panel.getByLabel('Texte', { exact: true });
|
const body = panel.getByLabel('Texte', { exact: true });
|
||||||
// Typographic apostrophes: the letters and the signs stay, only their style changes.
|
// 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#');
|
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 yvv = await person(browser, 'Yvv', true);
|
||||||
const r = await yvv.request.post('/api/access/invitations', {
|
const r = await yvv.request.post('/api/access/invitations', {
|
||||||
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
|
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 { link } = (await r.json()) as { link: string };
|
||||||
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||||||
const invited = await ctx.newPage();
|
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 invited.goto(`/invitation#${link.split('#')[1]}`);
|
||||||
await expect(invited).toHaveURL((u) => u.pathname === '/');
|
await expect(invited).toHaveURL((u) => u.pathname === '/');
|
||||||
await invited.goto(DOC);
|
await invited.goto(DOC);
|
||||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||||
const panel = invited.locator('aside.panel');
|
const fold = invited.locator('section.fold');
|
||||||
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
|
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
|
||||||
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible();
|
||||||
// In the test group, the same person votes.
|
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.goto(TEST_DOC);
|
||||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
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 }) => {
|
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).toHaveCount(2);
|
||||||
await expect(versions.nth(0)).toContainText('variante 1.0.0');
|
await expect(versions.nth(0)).toContainText('variante 1.0.0');
|
||||||
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
|
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 versions.nth(1).click();
|
||||||
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
|
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(page).toHaveURL(/version=/);
|
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/vote=/);
|
||||||
await e3.click();
|
await e3.click();
|
||||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||||
await expect(versions).toHaveCount(0);
|
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' });
|
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||||
await e1.hover();
|
await e1.hover();
|
||||||
await e1.getByRole('button', { name: 'Le vote' }).click();
|
await e1.getByRole('button', { name: 'Le vote' }).click();
|
||||||
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
|
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
|
||||||
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
|
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')).toHaveCount(1);
|
||||||
await expect(panel.locator('tr.current th')).toContainText('4');
|
await expect(panel.locator('tr.current th')).toContainText('4');
|
||||||
await expect(panel.locator('tr.current th')).toContainText('cet article');
|
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é' });
|
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
|
||||||
await e2.hover();
|
await e2.hover();
|
||||||
await e2.getByRole('button', { name: 'Le vote' }).click();
|
await e2.getByRole('button', { name: 'Le vote' }).click();
|
||||||
await panel.getByText("Proposer un changement d'inertie").click();
|
await e2.getByText("Proposer un changement d'inertie").click();
|
||||||
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
|
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
|
||||||
await expect(levels).toHaveCount(5);
|
await expect(levels).toHaveCount(5);
|
||||||
await expect(levels.nth(3)).toBeDisabled();
|
await expect(levels.nth(3)).toBeDisabled();
|
||||||
await expect(levels.nth(3)).toContainText('actuelle');
|
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 levels.nth(4).click();
|
||||||
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
||||||
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
|
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||||
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
|
// Its own vote unfolds, in the list of proposals.
|
||||||
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
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
|
#!/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
|
set -eu
|
||||||
here=$(cd "$(dirname "$0")/../.." && pwd)
|
here=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
data=/tmp/ld-e2e
|
data=/tmp/ld-e2e
|
||||||
rm -rf "$data"
|
rm -rf "$data"
|
||||||
mkdir -p "$data"
|
mkdir -p "$data"
|
||||||
cd "$here"
|
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_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
|
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/kit": "^2.70.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@types/node": "^22.20.5",
|
"@types/node": "^22.20.5",
|
||||||
|
"polkadot-api": "3.3.0",
|
||||||
"svelte": "^5.57.0",
|
"svelte": "^5.57.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
|
"uqr": "0.1.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vitest": "^3.2.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">
|
<script lang="ts">
|
||||||
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
|
// 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
|
// single version unfolds its vote below it; one with several shows the best of
|
||||||
// unfolds the others, each with its label, its water level and its marks — and
|
// them and unfolds the others, each with its label, its water level and its
|
||||||
// each opens its own vote.
|
// marks — and each unfolds its own vote.
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { plain } from '$lib/markdown';
|
import { plain } from '$lib/markdown';
|
||||||
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
|
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
|
||||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||||
@@ -13,16 +14,26 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
articles,
|
articles,
|
||||||
selected,
|
vote,
|
||||||
onopen,
|
ontoggle,
|
||||||
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
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 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 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>>({});
|
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));
|
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. */
|
/** The title of the article: its best text version, or what the best proposal is. */
|
||||||
@@ -61,11 +72,17 @@
|
|||||||
{@const unit = a.unit}
|
{@const unit = a.unit}
|
||||||
{@const best = a.versions[0]}
|
{@const best = a.versions[0]}
|
||||||
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
|
{#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}
|
||||||
{#if best && a.versions.length === 1}
|
{#if best && a.versions.length === 1}
|
||||||
<li class="article">
|
<li class="article" class:open={vote === best.id}>
|
||||||
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
|
<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} />
|
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||||
<span class="main">
|
<span class="main">
|
||||||
<span class="title">{titleOf(best, unit)}</span>
|
<span class="title">{titleOf(best, unit)}</span>
|
||||||
@@ -78,6 +95,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
|
||||||
</li>
|
</li>
|
||||||
{:else if best}
|
{:else if best}
|
||||||
{@const open = isOpen(a)}
|
{@const open = isOpen(a)}
|
||||||
@@ -105,7 +123,7 @@
|
|||||||
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
||||||
{#each a.versions as p (p.id)}
|
{#each a.versions as p (p.id)}
|
||||||
<li>
|
<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} />
|
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||||
<span class="main">
|
<span class="main">
|
||||||
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{#if vote === p.id}{@render fold(unit, p)}{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -234,9 +253,30 @@
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.section {
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
margin: 1.6rem 0 0.3rem;
|
margin: 1.6rem 0 0.3rem;
|
||||||
color: var(--ink-soft);
|
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 {
|
.section:first-child {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
<script lang="ts">
|
<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 type { DocState, PanelState } from '$lib/doc.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { relationOf, titleOf } from '$lib/labels';
|
import { relationOf, titleOf } from '$lib/labels';
|
||||||
|
import type { BasketState } from '$lib/picks.svelte';
|
||||||
import type { Lang } from '$lib/types';
|
import type { Lang } from '$lib/types';
|
||||||
|
import type { Line } from '$lib/vote';
|
||||||
|
import BallotPanel from './BallotPanel.svelte';
|
||||||
import FormulaSettings from './FormulaSettings.svelte';
|
import FormulaSettings from './FormulaSettings.svelte';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import ProposeView from './ProposeView.svelte';
|
import ProposeView from './ProposeView.svelte';
|
||||||
import VoteView from './VoteView.svelte';
|
import VersionDetails from './VersionDetails.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
import WaterLevel from './WaterLevel.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
doc,
|
doc,
|
||||||
panel,
|
panel,
|
||||||
lang,
|
lang,
|
||||||
|
basket,
|
||||||
|
vote,
|
||||||
onchange,
|
onchange,
|
||||||
onclose,
|
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 unit = $derived(doc.unit(panel.unitId));
|
||||||
const versions = $derived(
|
const versions = $derived(
|
||||||
@@ -25,9 +47,11 @@
|
|||||||
const tabs = $derived(
|
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],
|
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
|
||||||
['propose', t('panel.propose'), true],
|
['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.
|
// Reached from the head of the document or from a vote; shown while it is open.
|
||||||
['settings', t('panel.settings'), panel.tab === 'settings'],
|
['settings', t('panel.settings'), panel.tab === 'settings'],
|
||||||
] as const
|
] as const
|
||||||
@@ -49,7 +73,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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="bar">
|
||||||
<div class="tabs" role="tablist" bind:this={tablist}>
|
<div class="tabs" role="tablist" bind:this={tablist}>
|
||||||
{#each tabs as [key, label] (key)}
|
{#each tabs as [key, label] (key)}
|
||||||
@@ -60,25 +84,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="body" bind:this={body}>
|
<div class="body" bind:this={body}>
|
||||||
{#if panel.tab === 'vote' && voteId}
|
{#if panel.tab === 'basket'}
|
||||||
<VoteView
|
<BallotPanel {doc} {basket} section={panel.section ?? null} {onsign} onread={(id) => onvote(id, false)} />
|
||||||
|
{:else if panel.tab === 'details' && voteId}
|
||||||
|
<VersionDetails
|
||||||
{doc}
|
{doc}
|
||||||
proposalId={voteId}
|
proposalId={voteId}
|
||||||
{lang}
|
{lang}
|
||||||
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
|
onvote={(id) => onvote(id, true)}
|
||||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
||||||
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
|
|
||||||
/>
|
/>
|
||||||
{:else if panel.tab === 'proposals' && unit}
|
{:else if panel.tab === 'proposals' && unit}
|
||||||
<ul class="versions">
|
<ul class="versions">
|
||||||
{#each versions as v (v.id)}
|
{#each versions as v (v.id)}
|
||||||
<li>
|
<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} />
|
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
||||||
<span class="text">
|
<span class="text">
|
||||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||||
<span class="title">{titleOf(v, unit)}</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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -91,7 +116,10 @@
|
|||||||
unitId={panel.unitId}
|
unitId={panel.unitId}
|
||||||
draftId={panel.draftId ?? null}
|
draftId={panel.draftId ?? null}
|
||||||
prefill={panel.prefill ?? 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'}
|
{:else if panel.tab === 'settings'}
|
||||||
<FormulaSettings {doc} proposalId={voteId} />
|
<FormulaSettings {doc} proposalId={voteId} />
|
||||||
|
|||||||
@@ -31,6 +31,17 @@
|
|||||||
/** A tool is highlighted only when one is in it. */
|
/** 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}/`));
|
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() {
|
async function signOut() {
|
||||||
await session.logout();
|
await session.logout();
|
||||||
groups.reset();
|
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>
|
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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 MOODS as m (m)}
|
||||||
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
|
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
|
||||||
<button
|
<button
|
||||||
@@ -96,6 +107,7 @@
|
|||||||
aria-checked={mood.current === m}
|
aria-checked={mood.current === m}
|
||||||
aria-label={t(`mood.${m}`)}
|
aria-label={t(`mood.${m}`)}
|
||||||
title={t(`mood.${m}`)}
|
title={t(`mood.${m}`)}
|
||||||
|
tabindex={mood.current === m ? 0 : -1}
|
||||||
onclick={() => mood.set(m)}
|
onclick={() => mood.set(m)}
|
||||||
></button>
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -242,7 +254,15 @@
|
|||||||
.moods {
|
.moods {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 {
|
.swatch {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -262,10 +282,18 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--cle);
|
background: var(--cle);
|
||||||
}
|
}
|
||||||
|
/* Chosen: a ring of the page's ink, close to the swatch. */
|
||||||
.swatch[aria-checked='true'] {
|
.swatch[aria-checked='true'] {
|
||||||
/* currentColor is the ink of the page, inherited. */
|
box-shadow:
|
||||||
outline: 2px solid currentColor;
|
inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent),
|
||||||
outline-offset: 2px;
|
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 {
|
.who {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import type { Shown } from '$lib/doc.svelte';
|
import type { Shown } from '$lib/doc.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import Prose from './Prose.svelte';
|
import Prose from './Prose.svelte';
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
rivals = 0,
|
rivals = 0,
|
||||||
selected = false,
|
selected = false,
|
||||||
actions = true,
|
actions = true,
|
||||||
|
voteFor = null,
|
||||||
|
voteOpen = false,
|
||||||
|
fold,
|
||||||
onaction,
|
onaction,
|
||||||
}: {
|
}: {
|
||||||
text: Shown;
|
text: Shown;
|
||||||
@@ -20,11 +24,16 @@
|
|||||||
rivals?: number;
|
rivals?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
actions?: 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();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="unit {mark}" class:selected>
|
<article class="unit {mark}" class:selected class:unfolded={voteOpen}>
|
||||||
<header>
|
<header>
|
||||||
<h2 class="eyebrow">{text.title}</h2>
|
<h2 class="eyebrow">{text.title}</h2>
|
||||||
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
|
{#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}
|
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="links">
|
<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('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
|
||||||
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
|
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if voteOpen && fold}{@render fold()}{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -59,7 +69,8 @@
|
|||||||
}
|
}
|
||||||
.unit:hover,
|
.unit:hover,
|
||||||
.unit:focus-within,
|
.unit:focus-within,
|
||||||
.unit.selected {
|
.unit.selected,
|
||||||
|
.unit.unfolded {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -98,7 +109,8 @@
|
|||||||
}
|
}
|
||||||
.unit:hover .links,
|
.unit:hover .links,
|
||||||
.unit:focus-within .links,
|
.unit:focus-within .links,
|
||||||
.unit.selected .links {
|
.unit.selected .links,
|
||||||
|
.unit.unfolded .links {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.links button {
|
.links button {
|
||||||
@@ -110,7 +122,8 @@
|
|||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
}
|
}
|
||||||
.links button:hover {
|
.links button:hover,
|
||||||
|
.links button.on {
|
||||||
background: var(--water-wash);
|
background: var(--water-wash);
|
||||||
}
|
}
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
|
|||||||
+28
-110
@@ -1,30 +1,30 @@
|
|||||||
<script lang="ts">
|
<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 { api, ApiError } from '$lib/api';
|
||||||
import { shown, type DocState } from '$lib/doc.svelte';
|
import { shown, type DocState } from '$lib/doc.svelte';
|
||||||
import { i18n, t, type Key } from '$lib/i18n/index.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 { 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 DiffText from './DiffText.svelte';
|
||||||
import Formula from './Formula.svelte';
|
|
||||||
import InertiaPicker from './InertiaPicker.svelte';
|
|
||||||
import Prose from './Prose.svelte';
|
import Prose from './Prose.svelte';
|
||||||
import WaterLevel from './WaterLevel.svelte';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
doc,
|
doc,
|
||||||
proposalId,
|
proposalId,
|
||||||
lang,
|
lang,
|
||||||
onopen,
|
onvote,
|
||||||
onpropose,
|
onpropose,
|
||||||
onsettings,
|
|
||||||
}: {
|
}: {
|
||||||
doc: DocState;
|
doc: DocState;
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
lang: Lang;
|
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;
|
onpropose: (prefill: { title: string; body: string }) => void;
|
||||||
onsettings?: () => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
interface Detail {
|
interface Detail {
|
||||||
@@ -37,9 +37,6 @@
|
|||||||
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
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;
|
other: { id: string; title: string; status: string; code: string | null } | null;
|
||||||
}[];
|
}[];
|
||||||
electorate: number;
|
|
||||||
voteFloor: number;
|
|
||||||
levels: LevelInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
@@ -101,29 +98,6 @@
|
|||||||
await load();
|
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) {
|
async function saveTypo(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -179,7 +153,7 @@
|
|||||||
try {
|
try {
|
||||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
||||||
await refresh();
|
await refresh();
|
||||||
announce(r.entered);
|
announce(doc, r.entered);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = message(err);
|
error = message(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,7 +176,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if detail && p && text && view}
|
{#if detail && p && text && view}
|
||||||
<article class="vote">
|
<article class="details">
|
||||||
<header>
|
<header>
|
||||||
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||||
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
|
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
|
||||||
@@ -228,39 +202,12 @@
|
|||||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="box">
|
{#if votable}
|
||||||
<div class="level">
|
<p class="tally faint">
|
||||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
||||||
<div class="numbers">
|
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
||||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
</p>
|
||||||
{#if p.evaluation}
|
{/if}
|
||||||
<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 p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||||
<section class="sub">
|
<section class="sub">
|
||||||
@@ -351,7 +298,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.vote {
|
.details {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.1rem;
|
gap: 1.1rem;
|
||||||
}
|
}
|
||||||
@@ -382,48 +329,19 @@
|
|||||||
.removed {
|
.removed {
|
||||||
opacity: 0.6;
|
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 {
|
.tally {
|
||||||
font-size: 1.15rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.state {
|
.linkish {
|
||||||
font-weight: 700;
|
border: 0;
|
||||||
}
|
background: none;
|
||||||
.choice {
|
padding: 0;
|
||||||
display: flex;
|
color: var(--water);
|
||||||
flex-wrap: wrap;
|
font-weight: 750;
|
||||||
align-items: center;
|
text-decoration: underline;
|
||||||
gap: 0.45rem;
|
text-underline-offset: 3px;
|
||||||
}
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
.warn {
|
.warn {
|
||||||
color: var(--ochre);
|
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
|
// 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,
|
// 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 { api, ApiError } from './api';
|
||||||
import type { GroupView } from './group';
|
import type { GroupView } from './group';
|
||||||
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
||||||
|
|
||||||
export type View = 'inforce' | 'wellrated' | 'all';
|
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 {
|
export interface PanelState {
|
||||||
unitId: string | null;
|
unitId: string | null;
|
||||||
proposalId: string | null;
|
proposalId: string | null;
|
||||||
tab: Tab;
|
tab: Tab;
|
||||||
draftId?: string | null;
|
draftId?: string | null;
|
||||||
|
/** The basket, opened on one section. */
|
||||||
|
section?: string | null;
|
||||||
prefill?: { title: string; body: string } | null;
|
prefill?: { title: string; body: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-2
@@ -122,6 +122,8 @@ export const en: Messages = {
|
|||||||
'doc.action.vote': 'Voting',
|
'doc.action.vote': 'Voting',
|
||||||
'doc.action.proposals': 'Proposals',
|
'doc.action.proposals': 'Proposals',
|
||||||
'doc.action.propose': 'Propose a change',
|
'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.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.published': 'Published to the text repository',
|
||||||
'doc.publish.pending': 'Publication pending',
|
'doc.publish.pending': 'Publication pending',
|
||||||
@@ -176,6 +178,8 @@ export const en: Messages = {
|
|||||||
'panel.proposals': 'Proposals',
|
'panel.proposals': 'Proposals',
|
||||||
'panel.propose': 'Propose',
|
'panel.propose': 'Propose',
|
||||||
'panel.settings': 'Settings',
|
'panel.settings': 'Settings',
|
||||||
|
'panel.basket': 'Basket',
|
||||||
|
'panel.details': 'Details',
|
||||||
'panel.close': 'Close panel',
|
'panel.close': 'Close panel',
|
||||||
'panel.inforce': 'In force',
|
'panel.inforce': 'In force',
|
||||||
'panel.proposal': 'Proposal',
|
'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.remove': '“{title}” has been removed from the text in force.',
|
||||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||||
'vote.closed': 'This version is no longer open for voting.',
|
'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.title': 'Inertia',
|
||||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
'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.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.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.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.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': '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.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.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
|
||||||
'error.groups': 'Choose at least one group.',
|
'error.groups': 'Choose at least one group.',
|
||||||
'error.publication_off': 'Publication is not configured on this server.',
|
'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',
|
'error.retry': 'Try again',
|
||||||
|
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
|
|||||||
+83
-2
@@ -122,6 +122,8 @@ export const es: Messages = {
|
|||||||
'doc.action.vote': 'Votación',
|
'doc.action.vote': 'Votación',
|
||||||
'doc.action.proposals': 'Propuestas',
|
'doc.action.proposals': 'Propuestas',
|
||||||
'doc.action.propose': 'Proponer una modificación',
|
'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.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.published': 'Publicado en el repositorio de textos',
|
||||||
'doc.publish.pending': 'Publicación pendiente',
|
'doc.publish.pending': 'Publicación pendiente',
|
||||||
@@ -176,6 +178,8 @@ export const es: Messages = {
|
|||||||
'panel.proposals': 'Propuestas',
|
'panel.proposals': 'Propuestas',
|
||||||
'panel.propose': 'Proponer',
|
'panel.propose': 'Proponer',
|
||||||
'panel.settings': 'Ajustes',
|
'panel.settings': 'Ajustes',
|
||||||
|
'panel.basket': 'Cesta',
|
||||||
|
'panel.details': 'Detalles',
|
||||||
'panel.close': 'Cerrar el panel',
|
'panel.close': 'Cerrar el panel',
|
||||||
'panel.inforce': 'En vigor',
|
'panel.inforce': 'En vigor',
|
||||||
'panel.proposal': 'Propuesta',
|
'panel.proposal': 'Propuesta',
|
||||||
@@ -199,7 +203,76 @@ export const es: Messages = {
|
|||||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||||
'vote.closed': 'Esta versión ya no está en votación.',
|
'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.title': 'Inercia',
|
||||||
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
|
'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.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.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.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.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': '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.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.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.groups': 'Elige al menos un grupo.',
|
||||||
'error.publication_off': 'La publicación no está configurada en este servidor.',
|
'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',
|
'error.retry': 'Reintentar',
|
||||||
|
|
||||||
'common.cancel': 'Cancelar',
|
'common.cancel': 'Cancelar',
|
||||||
|
|||||||
+83
-2
@@ -125,6 +125,8 @@ export const fr = {
|
|||||||
'doc.action.vote': 'Le vote',
|
'doc.action.vote': 'Le vote',
|
||||||
'doc.action.proposals': 'Propositions',
|
'doc.action.proposals': 'Propositions',
|
||||||
'doc.action.propose': 'Proposer une modification',
|
'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.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.published': 'Publié sur le dépôt des textes',
|
||||||
'doc.publish.pending': 'Publication en attente',
|
'doc.publish.pending': 'Publication en attente',
|
||||||
@@ -179,6 +181,8 @@ export const fr = {
|
|||||||
'panel.proposals': 'Propositions',
|
'panel.proposals': 'Propositions',
|
||||||
'panel.propose': 'Proposer',
|
'panel.propose': 'Proposer',
|
||||||
'panel.settings': 'Réglages',
|
'panel.settings': 'Réglages',
|
||||||
|
'panel.basket': 'Panier',
|
||||||
|
'panel.details': 'Détails',
|
||||||
'panel.close': 'Fermer le panneau',
|
'panel.close': 'Fermer le panneau',
|
||||||
'panel.inforce': 'En vigueur',
|
'panel.inforce': 'En vigueur',
|
||||||
'panel.proposal': 'Proposition',
|
'panel.proposal': 'Proposition',
|
||||||
@@ -202,7 +206,76 @@ export const fr = {
|
|||||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||||
'vote.closed': 'Cette version ne se vote plus.',
|
'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.title': 'Inertie',
|
||||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
'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.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.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.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.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': "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.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.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
|
||||||
'error.groups': 'Choisis au moins un groupe.',
|
'error.groups': 'Choisis au moins un groupe.',
|
||||||
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
|
'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',
|
'error.retry': 'Réessayer',
|
||||||
|
|
||||||
'common.cancel': 'Annuler',
|
'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;
|
signals: number;
|
||||||
myVote: 'for' | 'against' | null;
|
myVote: 'for' | 'against' | null;
|
||||||
myVoteStale: boolean;
|
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>>;
|
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 {
|
export interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -124,8 +174,9 @@ export interface Publication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DocView {
|
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;
|
canVote: boolean;
|
||||||
|
vote: VoteAccess;
|
||||||
doc: {
|
doc: {
|
||||||
id: string;
|
id: string;
|
||||||
slug: 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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import ChainSign from '$lib/components/ChainSign.svelte';
|
||||||
import DeepList from '$lib/components/DeepList.svelte';
|
import DeepList from '$lib/components/DeepList.svelte';
|
||||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||||
import Guard from '$lib/components/Guard.svelte';
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||||
|
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||||
import WaveRule from '$lib/components/WaveRule.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 { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||||
import { i18n, LANGS, t } from '$lib/i18n/index.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 { Lang, Proposal, Unit } from '$lib/types';
|
||||||
|
import type { Line } from '$lib/vote';
|
||||||
import { articles, type Filter } from '$lib/versions';
|
import { articles, type Filter } from '$lib/versions';
|
||||||
|
|
||||||
const doc = new DocState();
|
const doc = new DocState();
|
||||||
|
const basket = new BasketState();
|
||||||
const group = $derived(page.params.group ?? '');
|
const group = $derived(page.params.group ?? '');
|
||||||
const slug = $derived(page.params.doc ?? '');
|
const slug = $derived(page.params.doc ?? '');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void doc.load(group, slug);
|
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.
|
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||||
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
|
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||||
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
|
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||||
let prefill = $state<PanelState['prefill']>(null);
|
let prefill = $state<PanelState['prefill']>(null);
|
||||||
const panel = $derived.by((): PanelState | null => {
|
const panel = $derived.by((): PanelState | null => {
|
||||||
const q = page.url.searchParams;
|
const q = page.url.searchParams;
|
||||||
const tab = TABS[q.get('onglet') ?? ''];
|
const tab = TABS[q.get('onglet') ?? ''];
|
||||||
if (!tab) return null;
|
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) {
|
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||||
prefill = next?.prefill ?? null;
|
prefill = nextPanel?.prefill ?? null;
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (next) {
|
if (nextVote) q.set('vote', nextVote);
|
||||||
q.set('onglet', TAB_NAMES[next.tab]);
|
if (nextPanel) {
|
||||||
if (next.unitId) q.set('article', next.unitId);
|
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
||||||
if (next.proposalId) q.set('version', next.proposalId);
|
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
||||||
if (next.draftId) q.set('brouillon', next.draftId);
|
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();
|
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');
|
let filter = $state<Filter>('all');
|
||||||
const view = $derived(doc.view);
|
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 rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||||
|
|
||||||
function onUnit(u: Unit, tab: Tab) {
|
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
||||||
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
|
if (action === 'vote') {
|
||||||
}
|
if (version) toggleVote(version);
|
||||||
function onProposal(u: Unit, p: Proposal) {
|
return;
|
||||||
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
|
}
|
||||||
|
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>
|
</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>
|
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
||||||
|
|
||||||
{#if doc.error}
|
{#if doc.error}
|
||||||
@@ -81,6 +145,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="controls">
|
<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')}>
|
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
|
||||||
{#each VIEWS as v (v)}
|
{#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>
|
<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}
|
{#if doc.mode === 'inforce' || !doc.preview}
|
||||||
{#each doc.inForce as u (u.id)}
|
{#each doc.inForce as u (u.id)}
|
||||||
{#if u.inForce}
|
{#if u.inForce}
|
||||||
|
{@const p = u.inForce}
|
||||||
<UnitBlock
|
<UnitBlock
|
||||||
text={shown(u.inForce, lang, source)}
|
text={shown(p, lang, source)}
|
||||||
seedMinimal={u.inForce.seedMinimal}
|
seedMinimal={p.seedMinimal}
|
||||||
enteredAt={u.inForce.enteredAt}
|
enteredAt={p.enteredAt}
|
||||||
rivals={rivalsOf(u)}
|
rivals={rivalsOf(u)}
|
||||||
selected={panel?.unitId === u.id}
|
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}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted">{t('doc.empty')}</p>
|
<p class="muted">{t('doc.empty')}</p>
|
||||||
@@ -117,12 +191,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each doc.preview as pu (pu.unitId)}
|
{#each doc.preview as pu (pu.unitId)}
|
||||||
{@const u = doc.unit(pu.unitId)}
|
{@const u = doc.unit(pu.unitId)}
|
||||||
|
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
||||||
|
{@const p = doc.proposal(id)}
|
||||||
<UnitBlock
|
<UnitBlock
|
||||||
text={shown(pu, lang, source)}
|
text={shown(pu, lang, source)}
|
||||||
mark={pu.mark}
|
mark={pu.mark}
|
||||||
selected={panel?.unitId === pu.unitId}
|
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}
|
{/each}
|
||||||
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -152,17 +232,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if panel}
|
{#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}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.reader {
|
.reader {
|
||||||
@@ -217,6 +306,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 1.6rem;
|
margin-top: 1.6rem;
|
||||||
}
|
}
|
||||||
|
.basket-btn {
|
||||||
|
flex-basis: 100%;
|
||||||
|
justify-self: start;
|
||||||
|
max-width: max-content;
|
||||||
|
background: var(--water);
|
||||||
|
color: var(--on-water);
|
||||||
|
}
|
||||||
.seg {
|
.seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
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 = [
|
export const FORBIDDEN_UI_TERMS = [
|
||||||
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
|
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
|
||||||
'verdict', 'triage', 'oligarchie', 'peuple adulte',
|
'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();
|
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,
|
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
||||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
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,
|
...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