forked from yvv/decision
Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers';
|
||||
|
||||
// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for
|
||||
// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms
|
||||
// it without the page; the receipt goes to the one who asked, who checks it here.
|
||||
// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go
|
||||
// to open proposals no other control reads, and only « contre »: nothing enters into
|
||||
// force, nothing becomes well rated, the other controls find the text as they expect.
|
||||
|
||||
type Entry = { id: string; status: string; comment: string };
|
||||
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
|
||||
|
||||
/** Unfolds the vote of an open proposal, from « Propositions en cours ». */
|
||||
async function unfold(page: Page, title: string) {
|
||||
// By its title only: the excerpt of another proposal may name it.
|
||||
await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click();
|
||||
return page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
}
|
||||
|
||||
const REVOCATION = 'Document de révocation';
|
||||
const NEVER_ALONE = 'Ne jamais certifier seul';
|
||||
|
||||
const answer = (page: Page, yes: boolean) =>
|
||||
page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes);
|
||||
|
||||
test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => {
|
||||
// The server reads the indexer every 10 seconds.
|
||||
test.setTimeout(120_000);
|
||||
await signIn(page, 'Rosa');
|
||||
await wallet(page, [EVE]);
|
||||
await page.goto(DOC);
|
||||
const { control } = await chain(page);
|
||||
const asked: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) {
|
||||
asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`);
|
||||
}
|
||||
});
|
||||
const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length;
|
||||
const fold = await unfold(page, REVOCATION);
|
||||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
|
||||
// The node does not answer before anything is signed: nothing left, the ballot is let go.
|
||||
await control('node?down=1');
|
||||
try {
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible();
|
||||
await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']);
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0);
|
||||
await expect.poll(() => mine(page)).toEqual([]);
|
||||
} finally {
|
||||
// The next controls need the node.
|
||||
await control('node?down=0');
|
||||
}
|
||||
|
||||
// The window closed while the wallet still asks: kept until it answers; refused, let go.
|
||||
await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true));
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
expect(deleted()).toBe(1);
|
||||
await answer(page, false);
|
||||
await expect.poll(deleted).toBe(2);
|
||||
await expect.poll(() => mine(page)).toEqual([]);
|
||||
|
||||
// Signed once the window is closed: it may land, so it is kept; the server confirms it.
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await answer(page, true);
|
||||
await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||
expect(deleted()).toBe(2);
|
||||
await page.goto(DOC);
|
||||
await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||
|
||||
// Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server.
|
||||
const ugo = await person(browser, 'Ugo');
|
||||
await ugo.goto(DOC);
|
||||
const theirs = await unfold(ugo, REVOCATION);
|
||||
await theirs.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
|
||||
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
|
||||
await sheet.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
|
||||
const { node } = await chain(ugo);
|
||||
const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' };
|
||||
await ugo.request.post(node, {
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
|
||||
});
|
||||
await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||
await ugo.goto(DOC);
|
||||
await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||
});
|
||||
|
||||
test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => {
|
||||
test.setTimeout(90_000);
|
||||
await signIn(page, 'Ines');
|
||||
await wallet(page, [ALICE]);
|
||||
await page.goto(DOC);
|
||||
const fold = await unfold(page, NEVER_ALONE);
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/);
|
||||
const file = await download.path();
|
||||
const receipt = JSON.parse(readFileSync(file, 'utf8'));
|
||||
expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address });
|
||||
expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/);
|
||||
expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]);
|
||||
|
||||
// The same ballot, in her ballots.
|
||||
await dialog.getByRole('link', { name: 'Tes bulletins' }).click();
|
||||
await expect(page).toHaveURL(/\/bulletins$/);
|
||||
await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible();
|
||||
const entry = page.locator('li.ballot');
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »");
|
||||
await expect(entry).toContainText(receipt.comment);
|
||||
|
||||
// Checked here, in the browser, without the server.
|
||||
await page.getByLabel('Fichier du reçu').setInputFiles(file);
|
||||
await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible();
|
||||
// Only what the seal covers is said verified: the version, its revision and the choice.
|
||||
await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`);
|
||||
// The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd).
|
||||
await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).toBeVisible();
|
||||
// One choice changed: it no longer holds.
|
||||
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) };
|
||||
await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
|
||||
await page.getByRole('button', { name: 'Vérifier', exact: true }).click();
|
||||
await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible();
|
||||
await shot(page, '7-bulletins-recu');
|
||||
|
||||
// Nobody else gets it: neither in their ballots, nor by its address.
|
||||
const other = await person(browser, 'Lou');
|
||||
await other.goto('/bulletins');
|
||||
await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible();
|
||||
expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404);
|
||||
});
|
||||
+37
-8
@@ -6,11 +6,15 @@
|
||||
// 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.
|
||||
// `squidStatus { height } txComments(condition: {hash}, filter, first, after)`
|
||||
// in the shape of indexer.duniter.org: the last final block (here, every
|
||||
// block is final), the comments in the order of the chain, filtered (author,
|
||||
// or a transfer to an account of at least an amount) and served page by page.
|
||||
//
|
||||
// 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`.
|
||||
// the indexer, as an indexer behind the chain), `show` (stop hiding), `release`
|
||||
// (the indexer catches up), `silent` (the indexer stops answering), `node`
|
||||
// (`?down=1`: the node stops answering, before anything is sent) and `state`.
|
||||
//
|
||||
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
|
||||
|
||||
@@ -27,7 +31,7 @@ const IDENTITIES = {
|
||||
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
|
||||
};
|
||||
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 };
|
||||
|
||||
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
|
||||
@@ -49,6 +53,20 @@ async function read(req) {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the real indexer's `filter` keeps, for the two filters the server asks. */
|
||||
function kept(c, filter) {
|
||||
if (!filter) return true;
|
||||
const author = filter.authorId?.equalTo;
|
||||
if (author && c.author.id !== author) return false;
|
||||
const some = filter.transfersByCommentId?.some;
|
||||
if (!some) return true;
|
||||
const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0');
|
||||
return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min);
|
||||
}
|
||||
|
||||
/** The size of a page: `first: N` in the query, as the server writes it. */
|
||||
const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100);
|
||||
|
||||
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
|
||||
function include(fields) {
|
||||
state.block += 1;
|
||||
@@ -70,7 +88,9 @@ const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
|
||||
|
||||
if (url.pathname === '/rpc' && req.method === 'POST') {
|
||||
if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true);
|
||||
const body = await read(req);
|
||||
if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true);
|
||||
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 {
|
||||
@@ -90,11 +110,19 @@ const server = createServer(async (req, res) => {
|
||||
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)
|
||||
const all = state.comments
|
||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter))
|
||||
.sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id))
|
||||
.map(({ hidden: _, ...c }) => c);
|
||||
return send(res, 200, { data: { txComments: { nodes } } });
|
||||
const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0);
|
||||
const nodes = all.slice(start, start + pageSize(String(body.query)));
|
||||
const end = start + nodes.length;
|
||||
return send(res, 200, {
|
||||
data: {
|
||||
squidStatus: { height: state.block },
|
||||
txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
|
||||
@@ -105,6 +133,7 @@ const server = createServer(async (req, res) => {
|
||||
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 === 'node') state.nodeDown = url.searchParams.get('down') === '1';
|
||||
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);
|
||||
}
|
||||
|
||||
+59
-3
@@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
||||
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
|
||||
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
|
||||
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
|
||||
export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' };
|
||||
|
||||
export async function signIn(page: Page, name: string, admin = false) {
|
||||
const r = await page.request.post('/api/auth/dev', {
|
||||
@@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro
|
||||
/**
|
||||
* 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.
|
||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse;
|
||||
* `window.__ldHold = true` makes it wait, as a person who has not answered yet,
|
||||
* until `window.__ldAnswer(true | false)` (sign, or 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 };
|
||||
const w = window as unknown as Record<string, unknown> & {
|
||||
__ldSigned: unknown[];
|
||||
__ldRefuse?: boolean;
|
||||
__ldHold?: boolean;
|
||||
__ldAnswer?: (yes: boolean) => void;
|
||||
};
|
||||
w.__ldSigned = [];
|
||||
w.injectedWeb3 = {
|
||||
'duniter-connect': {
|
||||
@@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri
|
||||
accounts: { get: async () => list, subscribe: () => () => undefined },
|
||||
signer: {
|
||||
signPayload: async (payload: Record<string, unknown>) => {
|
||||
if (w.__ldHold) {
|
||||
const yes = await new Promise<boolean>((resolve) => (w.__ldAnswer = resolve));
|
||||
if (!yes) throw new Error('Cancelled');
|
||||
}
|
||||
if (w.__ldRefuse) throw new Error('Cancelled');
|
||||
w.__ldSigned.push(payload);
|
||||
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
|
||||
@@ -69,6 +81,49 @@ export async function shot(page: Page, name: string) {
|
||||
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
|
||||
}
|
||||
|
||||
const CSRF = { 'x-requested-with': 'libredecision' };
|
||||
|
||||
/** The document of the test group, as the server gives it. */
|
||||
export async function testDoc(page: Page): Promise<{
|
||||
id: string;
|
||||
units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[];
|
||||
}> {
|
||||
const g = await (await page.request.get('/api/groups/essai')).json();
|
||||
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
|
||||
return { id: d.doc.id, units: d.units };
|
||||
}
|
||||
|
||||
/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */
|
||||
export async function propose(page: Page, title: string, body: string): Promise<string> {
|
||||
const doc = await testDoc(page);
|
||||
const r = await page.request.post(`/api/docs/${doc.id}/proposals`, {
|
||||
data: { kind: 'text', unitId: null, title, body, submit: true },
|
||||
headers: CSRF,
|
||||
});
|
||||
if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`);
|
||||
return (await r.json()).id;
|
||||
}
|
||||
|
||||
/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */
|
||||
export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> {
|
||||
const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF });
|
||||
if (!r.ok()) throw new Error(`clock ${r.status()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */
|
||||
export async function shots(page: Page, name: string) {
|
||||
if (!process.env.LD_SHOTS) return;
|
||||
const size = page.viewportSize();
|
||||
await page.waitForTimeout(400);
|
||||
await shot(page, `${name}-bureau`);
|
||||
await page.setViewportSize({ width: 412, height: 915 });
|
||||
await page.waitForTimeout(200);
|
||||
await shot(page, `${name}-mobile`);
|
||||
if (size) await page.setViewportSize(size);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/** 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('/');
|
||||
@@ -79,6 +134,7 @@ export async function chain(page: Page, docPath = DOC) {
|
||||
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(),
|
||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') =>
|
||||
(await page.request.post(`${base}/control/${what}`)).json(),
|
||||
};
|
||||
}
|
||||
|
||||
+35
-19
@@ -1,4 +1,4 @@
|
||||
import { devices, expect, test, type Page } from '@playwright/test';
|
||||
import { devices, expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
|
||||
|
||||
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
||||
@@ -17,6 +17,31 @@ async function voteFor(page: Page, row: string) {
|
||||
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
}
|
||||
|
||||
/** Whether a version of a module of the test group is in force now. */
|
||||
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
||||
const g = await (await page.request.get('/api/groups/essai')).json();
|
||||
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
|
||||
return [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The voices that bring a module version of the test group into force: hers, then a
|
||||
* second one if hers was not enough. Every dev sign-in joins the test group: from 16
|
||||
* members B^W vanishes and, C being 1, one voice may suffice (ADR 0007). Returns the
|
||||
* number of voices for it.
|
||||
*/
|
||||
async function secondVoice(page: Page, browser: Browser, proposalId: string, row: string, news: string): Promise<number> {
|
||||
if (await inForce(page, proposalId)) {
|
||||
await expect(page.getByText(news)).toBeVisible();
|
||||
return 1;
|
||||
}
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, row);
|
||||
await expect(yvv.getByText(news)).toBeVisible();
|
||||
return 2;
|
||||
}
|
||||
|
||||
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
@@ -31,17 +56,14 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-1-ordre-lecture');
|
||||
|
||||
// Its vote unfolds under it, like an article's; the mark tells the real number of votes.
|
||||
// Its vote unfolds under it, like an article's, the list whole (the mark's real number of
|
||||
// votes is seen in Monnaie libre, below: here one voice may confirm a seed).
|
||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||
await fond.hover();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
||||
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
|
||||
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
||||
await fondVote.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||||
await fondVote.getByRole('button', { name: 'Retirer mon vote' }).click();
|
||||
await expect(fond.getByText('seed a minima · 0 vote')).toBeVisible();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
|
||||
// Another order: the article, then its new place.
|
||||
@@ -82,25 +104,22 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
||||
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
|
||||
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
||||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
await shot(page, 'c25-3-ordre-vote');
|
||||
// Into force (her voice, or a second one): the section reads in its new order.
|
||||
const voices = await secondVoice(page, browser, pid, 'Ordre des articles · Conclusion', 'Le nouvel ordre de « Conclusion » est entré en vigueur.');
|
||||
|
||||
// In the basket too: at the end of its section.
|
||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||
|
||||
// A second voice brings it into force: the section reads in its new order.
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, 'Ordre des articles · Conclusion');
|
||||
await expect(yvv.getByText('Le nouvel ordre de « Conclusion » est entré en vigueur.')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
||||
// In force now: its vote stays unfolded, under the module, the list whole.
|
||||
await expect(order.locator('section.fold')).toContainText('2 pour · 0 contre');
|
||||
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||
await expect(order).toContainText(/adopté le/);
|
||||
@@ -131,13 +150,10 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
||||
const fold = page.locator('ul.deep section.fold');
|
||||
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
||||
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
|
||||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, next);
|
||||
await expect(yvv.getByText(`Le titre « ${next} » est entré en vigueur.`)).toBeVisible();
|
||||
const voices = await secondVoice(page, browser, pid, next, `Le titre « ${next} » est entré en vigueur.`);
|
||||
|
||||
// The document changes title; its translations stay editorial, to review.
|
||||
await page.reload();
|
||||
@@ -148,7 +164,7 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
||||
const vote = bar.locator('section.fold');
|
||||
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
||||
await expect(vote.getByText('2 pour · 0 contre')).toBeVisible();
|
||||
await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible();
|
||||
await shot(page, 'c26-2-titre-vote');
|
||||
await bar.getByRole('button', { name: 'Le vote' }).click();
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers';
|
||||
|
||||
// Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit »
|
||||
// holds back the later of two versions until it is lifted; the author answers,
|
||||
// the objector lifts or reiterates from the box « Messages », and with no answer
|
||||
// within 3 days it is lifted. A typo fix to someone else's text waits for its
|
||||
// author (an administrator for a seed text) or for 3 days. Each control has its
|
||||
// own people and its own proposals, in the test group.
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
/** The menu's « Messages », with its number of unread messages. */
|
||||
async function openBox(page: Page, unread: number) {
|
||||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||
const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` });
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link.locator('.unread')).toHaveText(String(unread));
|
||||
await link.click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible();
|
||||
}
|
||||
|
||||
/** The details of a version, in the panel. */
|
||||
async function details(page: Page, id: string) {
|
||||
await page.goto(`${TEST_DOC}?onglet=details&version=${id}`);
|
||||
const panel = page.locator('aside.panel');
|
||||
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** From the details of a version: an objection against an article in force. */
|
||||
async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) {
|
||||
const panel = await details(page, id);
|
||||
await panel.getByText('Faire une objection').click();
|
||||
await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click();
|
||||
await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked();
|
||||
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
|
||||
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
|
||||
await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
|
||||
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible();
|
||||
return panel;
|
||||
}
|
||||
|
||||
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const oscar = await person(browser, 'Oscar');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Une seule clé par personne';
|
||||
const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain.");
|
||||
|
||||
// Oscar: it repeats « Unicité du compte ».
|
||||
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
|
||||
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
|
||||
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
|
||||
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
|
||||
|
||||
// Vera, who votes: held back, said so, nothing to press; the list marks it.
|
||||
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible();
|
||||
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
|
||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection');
|
||||
await fold.scrollIntoViewIfNeeded();
|
||||
await shots(vera, 'f2-objection-vue-par-qui-vote');
|
||||
|
||||
// Aude: a message, and the way to answer — modify the article repeated.
|
||||
await aude.goto(TEST_DOC);
|
||||
await openBox(aude, 1);
|
||||
const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' });
|
||||
await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`);
|
||||
await expect(raised).toContainText("Pour répondre, modifie l'article visé.");
|
||||
await shots(aude, 'f3-messages-auteur');
|
||||
await raised.getByRole('button', { name: "Modifier l'article visé" }).click();
|
||||
await expect(aude).toHaveURL(/onglet=proposer/);
|
||||
await expect(aude).toHaveURL(/objection=/);
|
||||
const sheet = aude.locator('aside.panel');
|
||||
await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible();
|
||||
const body = sheet.getByLabel('Texte', { exact: true });
|
||||
await expect(body).toHaveValue(/Je m.engage sur l.honneur/);
|
||||
await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`);
|
||||
await shots(aude, 'f4-reponse-auteur');
|
||||
await sheet.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||
await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible();
|
||||
|
||||
// Oscar: the answer, to lift or reiterate, with its deadline.
|
||||
await oscar.goto(TEST_DOC);
|
||||
await openBox(oscar, 1);
|
||||
const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' });
|
||||
await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`);
|
||||
await expect(answered.getByText(/^échéance : /)).toBeVisible();
|
||||
await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible();
|
||||
await shots(oscar, 'f5-messages-qui-objecte');
|
||||
await answered.getByRole('button', { name: "Lever l'objection" }).click();
|
||||
await expect(oscar.getByText('Objection levée.')).toBeVisible();
|
||||
// Dealt with: no gesture any more, and read.
|
||||
await expect(answered.getByText('objection levée', { exact: true })).toBeVisible();
|
||||
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
|
||||
|
||||
// Vera: it is voted on again.
|
||||
await vera.reload();
|
||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const oscar = await person(browser, 'Oscar');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Certification à sens unique';
|
||||
const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour.");
|
||||
await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.');
|
||||
|
||||
// Aude, from the vote under the article: reword it.
|
||||
await aude.goto(`${TEST_DOC}?vote=${id}`);
|
||||
const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible();
|
||||
await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible();
|
||||
await fold.scrollIntoViewIfNeeded();
|
||||
await shots(aude, 'f8-objection-vue-par-l-auteur');
|
||||
await fold.getByRole('button', { name: 'Reformuler' }).click();
|
||||
await expect(aude).toHaveURL(/modifier=1/);
|
||||
const panel = aude.locator('aside.panel');
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await expect(body).toBeVisible();
|
||||
await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité.");
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible();
|
||||
await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible();
|
||||
|
||||
// Oscar says nothing; three days pass on the server's clock.
|
||||
const swept = await advanceClock(oscar, 3 * DAY + 60);
|
||||
expect(swept.lifted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Vera: votable again; the objection says why it was lifted.
|
||||
const panelV = await details(vera, id);
|
||||
await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite');
|
||||
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||
await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
|
||||
// Oscar's message: dealt with, by the deadline.
|
||||
await oscar.goto('/messages');
|
||||
const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` });
|
||||
await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible();
|
||||
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Clé unique et vivante';
|
||||
const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul.");
|
||||
|
||||
// Vera fixes the apostrophes: it waits for Aude, the text stays as it was.
|
||||
const panel = await details(vera, id);
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible();
|
||||
const fix = panel.locator('li.fix');
|
||||
await expect(fix).toContainText('proposée par Vera');
|
||||
await expect(fix).toContainText("en attente : s'applique d'elle-même le");
|
||||
await expect(fix.locator('ins')).toHaveCount(3);
|
||||
await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser");
|
||||
await shots(vera, 'f6-coquille-en-attente');
|
||||
|
||||
// Aude validates it from her box.
|
||||
await aude.goto(TEST_DOC);
|
||||
await openBox(aude, 1);
|
||||
const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` });
|
||||
await expect(msg.locator('ins')).toHaveCount(3);
|
||||
await expect(msg.getByText(/^échéance : /)).toBeVisible();
|
||||
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||
await expect(aude.getByText('Correction appliquée.')).toBeVisible();
|
||||
await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible();
|
||||
await expect(msg.locator('.btn')).toHaveCount(0);
|
||||
|
||||
// The text carries the fix; its history says who wrote it and who let it in.
|
||||
const after = await details(vera, id);
|
||||
await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser');
|
||||
await expect(after.locator('.history')).toContainText('coquille corrigée');
|
||||
await expect(after.locator('.history')).toContainText('par Vera');
|
||||
await expect(after.locator('.history')).toContainText('validée par Aude');
|
||||
await expect(after.locator('li.fix')).toContainText('appliquée · par Aude');
|
||||
});
|
||||
|
||||
test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => {
|
||||
// An administrator opens the group first: the message goes to them (a seed text has no author).
|
||||
const ada = await person(browser, 'Ada', true);
|
||||
await ada.goto(TEST_DOC);
|
||||
const vera = await person(browser, 'Vera');
|
||||
await vera.goto(TEST_DOC);
|
||||
const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" });
|
||||
await purpose.hover();
|
||||
await purpose.getByRole('button', { name: 'Le vote' }).click();
|
||||
await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||
const panel = vera.locator('aside.panel');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible();
|
||||
// In force, only a validated fix changes the text.
|
||||
await expect(purpose).toContainText("C'est le seul et unique propos");
|
||||
await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le");
|
||||
|
||||
await ada.reload();
|
||||
await openBox(ada, 1);
|
||||
const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" });
|
||||
await shots(ada, 'f7-messages-administrateur');
|
||||
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||
await expect(ada.getByText('Correction appliquée.')).toBeVisible();
|
||||
|
||||
await vera.reload();
|
||||
await expect(purpose).toContainText('C’est le seul et unique propos');
|
||||
const doc = await testDoc(vera);
|
||||
const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être");
|
||||
const after = await details(vera, unit?.inForce?.id ?? '');
|
||||
await expect(after.locator('.history')).toContainText('validée par Ada');
|
||||
});
|
||||
@@ -92,13 +92,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
|
||||
});
|
||||
|
||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
// A seed text has no author: an administrator's fix applies at once (someone
|
||||
// else's waits for an administrator, or for 3 days: C31).
|
||||
await signIn(page, 'Yvv', true);
|
||||
await page.goto(TEST_DOC);
|
||||
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||
await unicity.hover();
|
||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
||||
// What is not the vote lives in the panel, tab « Détails ».
|
||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
|
||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
|
||||
+10
-2
@@ -79,8 +79,10 @@ test('C22 — le vote groupé : une section en un geste, et l’article à deux
|
||||
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');
|
||||
// Each counted: her version of E3 has its voice, the other none. Here every
|
||||
// dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1,
|
||||
// her voice alone may put her version in force (ADR 0007).
|
||||
await expect(e3.getByRole('group', { name: /variante 1\.0\.0|en vigueur/ }).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');
|
||||
});
|
||||
|
||||
@@ -200,6 +202,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
||||
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 basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
||||
await page.goto(`${DOC}?onglet=panier`);
|
||||
const basket = page.locator('aside.panel');
|
||||
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
|
||||
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
|
||||
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
|
||||
// The test group still votes, directly.
|
||||
await page.goto(TEST_DOC);
|
||||
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
||||
|
||||
Generated
+1
@@ -10,6 +10,7 @@
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/literata": "^5.3.0",
|
||||
"@fontsource-variable/nunito": "^5.3.0",
|
||||
"@noble/hashes": "2.4.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.70.0",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/literata": "^5.3.0",
|
||||
"@fontsource-variable/nunito": "^5.3.0",
|
||||
"@noble/hashes": "2.4.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.70.0",
|
||||
|
||||
+15
-4
@@ -17,6 +17,8 @@ export interface Slot {
|
||||
versions: Proposal[];
|
||||
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
|
||||
inertia: Proposal[];
|
||||
/** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */
|
||||
held: Proposal[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
@@ -25,15 +27,19 @@ export interface Section {
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
/** What a unit offers to vote: its version in force and open versions, its open changes of inertia. */
|
||||
/**
|
||||
* What a unit offers to vote: its version in force and open versions, its open changes
|
||||
* of inertia — and, apart, its versions held back by an objection (ADR 0010).
|
||||
*/
|
||||
function slotOf(unit: Unit): Slot | null {
|
||||
const open = unit.proposals.filter((p) => p.status === 'open');
|
||||
const versions = [
|
||||
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
||||
...open.filter((p) => p.kind !== 'inertia'),
|
||||
...open.filter((p) => p.kind !== 'inertia' && p.votable !== false),
|
||||
];
|
||||
const inertia = open.filter((p) => p.kind === 'inertia');
|
||||
return versions.length || inertia.length ? { unit, versions, inertia } : null;
|
||||
const held = open.filter((p) => p.kind !== 'inertia' && p.votable === false);
|
||||
return versions.length || inertia.length || held.length ? { unit, versions, inertia, held } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,9 +83,14 @@ export function waiting(slots: Slot[], picks: Picks): Slot[] {
|
||||
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
|
||||
}
|
||||
|
||||
/** The versions one may vote now: those a choice of the basket is sent for. */
|
||||
export function votableIds(all: Section[]): Set<string> {
|
||||
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
}
|
||||
|
||||
/** 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)));
|
||||
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia, ...s.held].map((p) => p.id)));
|
||||
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// 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.
|
||||
// A version held back by an objection is shown, with nothing to choose.
|
||||
import { onMount } from 'svelte';
|
||||
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
|
||||
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
||||
@@ -35,7 +36,7 @@
|
||||
// The modules too (ADR 0009): the title first, each order at the end of its section.
|
||||
const all = $derived(sections(view?.units ?? [], view?.modules));
|
||||
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 known = $derived(votableIds(all));
|
||||
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));
|
||||
@@ -50,7 +51,7 @@
|
||||
|
||||
function headline(s: Slot): string {
|
||||
if (s.unit.kind) return moduleName(s.unit);
|
||||
const text = s.versions.find((p) => p.kind === 'text');
|
||||
const text = [...s.versions, ...s.held].find((p) => p.kind === 'text');
|
||||
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@
|
||||
{#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]}
|
||||
{@const first = slot.versions[0] ?? slot.inertia[0] ?? slot.held[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}
|
||||
@@ -147,6 +148,15 @@
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#each slot.held as v (v.id)}
|
||||
<div class="version held">
|
||||
<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 })}</span>
|
||||
</span>
|
||||
<span class="held-note">{t('basket.held')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -161,9 +171,11 @@
|
||||
<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 open}
|
||||
<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}
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</footer>
|
||||
</section>
|
||||
@@ -281,6 +293,17 @@
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.version.held .label {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.held-note {
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--ochre-wash);
|
||||
color: var(--ochre);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.send {
|
||||
position: sticky;
|
||||
bottom: -1.6rem;
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// 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.
|
||||
//
|
||||
// A ballot nothing left for is let go at once (`DELETE`): the wallet refused,
|
||||
// the node did not answer before signing, the window was closed before it.
|
||||
// Once signed, the transaction may land: the ballot is kept, and so is a
|
||||
// Ğecko code — the server reads the indexer for them, the page open or not.
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
@@ -10,7 +15,7 @@
|
||||
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';
|
||||
import { failure, mayLetGo, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
|
||||
|
||||
let {
|
||||
doc,
|
||||
@@ -47,11 +52,19 @@
|
||||
let ballot = $state<Ballot | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let closed = false;
|
||||
/** The transaction of the current ballot is signed: it may reach the chain. */
|
||||
let left = false;
|
||||
/** A wallet is asked to sign: its own window may stay open after this one closes. */
|
||||
let asking = false;
|
||||
/** The current ballot was let go: nothing left for it. */
|
||||
let dropped = 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));
|
||||
/** The receipt of a ballot that left, or may still. */
|
||||
const receiptShown = $derived(['waiting', 'gecko', 'confirmed', 'refused'].includes(step.kind));
|
||||
|
||||
onMount(() => {
|
||||
dialog?.showModal();
|
||||
@@ -64,9 +77,19 @@
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
/** Nothing left for this ballot: it is let go, rather than shown as waiting for a day. */
|
||||
async function letGo(b: Ballot) {
|
||||
if (dropped || ballot?.id !== b.id) return;
|
||||
dropped = true;
|
||||
await api.del(`/ballots/${b.id}`).catch(() => undefined);
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
stop();
|
||||
// Closed before anything left (and no Ğecko code shown): let it go. A wallet still
|
||||
// asking keeps it until it answers — signed, it may land; refused, `go` lets it go.
|
||||
if (ballot?.status === 'pending' && !left && !asking && step.kind !== 'gecko') void letGo(ballot);
|
||||
dialog?.close();
|
||||
onclose();
|
||||
}
|
||||
@@ -113,6 +136,7 @@
|
||||
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
const usual = accounts.find((a) => a.address === address);
|
||||
if (usual) return go(wallet, usual);
|
||||
const [only] = accounts;
|
||||
@@ -134,38 +158,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Signed: from now on the transaction may reach the chain. */
|
||||
function onTheWay(b: Ballot) {
|
||||
if (left) return;
|
||||
left = true;
|
||||
onsent();
|
||||
if (!closed) wait(b);
|
||||
}
|
||||
|
||||
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;
|
||||
left = false;
|
||||
dropped = false;
|
||||
if (closed) return letGo(b);
|
||||
asking = true;
|
||||
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 (s === 'signing') {
|
||||
if (!closed) step = { kind: 'signing' };
|
||||
} else onTheWay(b);
|
||||
});
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
onsent();
|
||||
wait(b);
|
||||
}
|
||||
onTheWay(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);
|
||||
}
|
||||
// Signed, then the node or the wallet went quiet: it may still land. Keep waiting.
|
||||
if (!mayLetGo(f, left)) return;
|
||||
if (f === 'account') remember(null);
|
||||
await letGo(b);
|
||||
stop();
|
||||
step = { kind: 'failed', text: t(`sign.error.${f}`) };
|
||||
if (!closed) step = { kind: 'failed', text: t(`sign.error.${f}`) };
|
||||
} finally {
|
||||
asking = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +202,9 @@
|
||||
const b = await seal(null);
|
||||
if (!b) return;
|
||||
ballot = b;
|
||||
left = false;
|
||||
dropped = false;
|
||||
if (closed) return letGo(b);
|
||||
const link = geckoLink(b.account, b.amount, b.comment);
|
||||
step = { kind: 'gecko', link, qr: await qrPath(link) };
|
||||
wait(b);
|
||||
@@ -271,6 +302,13 @@
|
||||
{:else if step.kind === 'failed'}
|
||||
<p class="warn" role="alert">{step.text}</p>
|
||||
{/if}
|
||||
{#if receiptShown && ballot}
|
||||
<p class="receipt small">
|
||||
<a href="/api/ballots/{ballot.id}/receipt" download>{t('sign.receipt')}</a>
|
||||
· <a href="/bulletins">{t('sign.history')}</a>
|
||||
</p>
|
||||
<p class="faint small">{t('sign.receipt.note')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -393,6 +431,10 @@
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.receipt a {
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
{#snippet marks(p: Proposal)}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import type { DocState, PanelState } from '$lib/doc.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import type { BasketState } from '$lib/picks.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Lang, Proposal } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import BallotPanel from './BallotPanel.svelte';
|
||||
@@ -84,7 +86,7 @@
|
||||
<div class="bar">
|
||||
<div class="tabs" role="tablist" bind:this={tablist}>
|
||||
{#each tabs as [key, label] (key)}
|
||||
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
|
||||
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
|
||||
@@ -98,8 +100,13 @@
|
||||
{doc}
|
||||
proposalId={voteId}
|
||||
{lang}
|
||||
edit={!!panel.edit}
|
||||
onvote={(id) => onvote(id, true)}
|
||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
|
||||
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null, edit: false })}
|
||||
onanswer={(o) => {
|
||||
const next = answerPanel(o, session.me?.id ?? null);
|
||||
if (next) onchange(next);
|
||||
}}
|
||||
/>
|
||||
{:else if panel.tab === 'proposals' && unit}
|
||||
<ul class="versions">
|
||||
@@ -110,7 +117,7 @@
|
||||
<span class="text">
|
||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<span class="title">{nameOfVersion(v)}</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 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}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -127,6 +134,8 @@
|
||||
unitId={panel.unitId}
|
||||
draftId={panel.draftId ?? null}
|
||||
prefill={panel.prefill ?? null}
|
||||
from={panel.objectionId ? (panel.proposalId ?? null) : null}
|
||||
objectionId={panel.objectionId ?? null}
|
||||
ondone={(r) =>
|
||||
r.proposalId && r.submitted
|
||||
? onvote(r.proposalId, true)
|
||||
@@ -234,6 +243,9 @@
|
||||
.small {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.held {
|
||||
color: var(--ochre);
|
||||
}
|
||||
@keyframes enter {
|
||||
from {
|
||||
transform: translateX(16px);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
// A few line icons, drawn here rather than pulled from a library.
|
||||
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus'; size?: number } = $props();
|
||||
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus' | 'inbox'; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
@@ -22,5 +22,7 @@
|
||||
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
|
||||
{:else if name === 'plus'}
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
{:else if name === 'inbox'}
|
||||
<path d="M4 13l2.2-7.3A1 1 0 0 1 7.2 5h9.6a1 1 0 0 1 1 .7L20 13v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" /><path d="M4 13h4.5l1 2h5l1-2H20" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
@@ -17,12 +17,18 @@
|
||||
unitId,
|
||||
draftId = null,
|
||||
prefill = null,
|
||||
from = null,
|
||||
objectionId = null,
|
||||
ondone,
|
||||
}: {
|
||||
doc: DocState;
|
||||
unitId: string | null;
|
||||
draftId?: string | null;
|
||||
prefill?: { title: string; body: string } | null;
|
||||
/** The version whose text the new one starts from (an answer to an objection). */
|
||||
from?: string | null;
|
||||
/** The objection this proposal answers (ADR 0010): linked to it when created. */
|
||||
objectionId?: string | null;
|
||||
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -44,17 +50,19 @@
|
||||
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
||||
// which would wipe what the person is typing.
|
||||
$effect(() => {
|
||||
const [u0, d0, pf] = [unitId, draftId, prefill];
|
||||
const [u0, d0, pf, f0] = [unitId, draftId, prefill, from];
|
||||
void objectionId;
|
||||
untrack(() => {
|
||||
const u = doc.unit(u0);
|
||||
const d = doc.proposal(d0);
|
||||
const f = doc.proposal(f0);
|
||||
target = u0;
|
||||
savedId = d0;
|
||||
mode = u0 ? 'replace' : 'new';
|
||||
// The section of the article one comes from, else the last one.
|
||||
section = u?.section ?? doc.view?.units.at(-1)?.section ?? '';
|
||||
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
||||
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
|
||||
title = pf?.title ?? d?.title ?? f?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
||||
body = pf?.body ?? d?.body ?? f?.body ?? u?.inForce?.body ?? '';
|
||||
error = '';
|
||||
});
|
||||
});
|
||||
@@ -96,6 +104,7 @@
|
||||
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
||||
body: mode === 'remove' ? '' : body,
|
||||
submit: false,
|
||||
...(objectionId && mode !== 'new' ? { objectionId } : {}),
|
||||
});
|
||||
savedId = r.id;
|
||||
return r.id;
|
||||
@@ -155,6 +164,7 @@
|
||||
</script>
|
||||
|
||||
<div class="propose">
|
||||
{#if objectionId && mode !== 'new' && !draft}<p class="linked">{t('objection.linked')}</p>{/if}
|
||||
{#if !draft}
|
||||
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
|
||||
{#if unit}
|
||||
@@ -252,6 +262,13 @@
|
||||
.assist .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.linked {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--ochre-wash);
|
||||
font-weight: 650;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@
|
||||
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
|
||||
<span class="eyebrow">{t('nav.groups')}</span>
|
||||
</a>
|
||||
<a
|
||||
class="messages"
|
||||
href="/messages"
|
||||
class:current={path === '/messages'}
|
||||
aria-current={path === '/messages' ? 'page' : undefined}
|
||||
aria-label={session.unread ? i18n.plural('nav.messages.count', session.unread) : undefined}
|
||||
onclick={() => onnavigate?.()}
|
||||
>
|
||||
<Icon name="inbox" size={17} /><span>{t('nav.messages')}</span>
|
||||
{#if session.unread}<span class="unread" aria-hidden="true">{session.unread}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ul class="tools">
|
||||
@@ -146,6 +157,37 @@
|
||||
.groups.current .eyebrow {
|
||||
color: var(--ink);
|
||||
}
|
||||
.messages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 2.5rem;
|
||||
margin-top: -0.9rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--r-input);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.messages:hover,
|
||||
.messages.current {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.messages.current {
|
||||
color: var(--water);
|
||||
}
|
||||
.unread {
|
||||
margin-left: auto;
|
||||
min-width: 1.6rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
.tools {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<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).
|
||||
// what is not the vote — typos (and the fixes waiting for an answer),
|
||||
// translations, objections, history. The vote itself unfolds under the
|
||||
// article (VoteFold). Objections and validated typo fixes: ADR 0010.
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { condense } from '$lib/diff';
|
||||
import { shown, type DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import { announce } from '$lib/vote';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Objection, ObjectionKind, Proposal, TypoFix } from '$lib/types';
|
||||
import DiffText from './DiffText.svelte';
|
||||
import Prose from './Prose.svelte';
|
||||
|
||||
@@ -16,27 +19,42 @@
|
||||
doc,
|
||||
proposalId,
|
||||
lang,
|
||||
edit = false,
|
||||
onvote,
|
||||
onpropose,
|
||||
onanswer,
|
||||
}: {
|
||||
doc: DocState;
|
||||
proposalId: string;
|
||||
lang: Lang;
|
||||
/** Opened to reword one's own version (an answer to an objection). */
|
||||
edit?: boolean;
|
||||
/** Unfolds the vote of this version under its article. */
|
||||
onvote: (proposalId: string) => void;
|
||||
onpropose: (prefill: { title: string; body: string }) => void;
|
||||
/** Answers an objection holding a version back: modify the article repeated, or reword. */
|
||||
onanswer: (objection: Objection) => void;
|
||||
} = $props();
|
||||
|
||||
interface HistoryItem {
|
||||
revision: number;
|
||||
kind: 'initial' | 'typo' | 'substantive' | 'amendment' | 'revert';
|
||||
author: string | null;
|
||||
authorInvited: boolean;
|
||||
at: string;
|
||||
diff: DiffPart[] | null;
|
||||
/** A fix someone else wrote: who let it in, or that it passed at its deadline. */
|
||||
typo: { id: string; answeredBy: string | null; deadline: boolean } | null;
|
||||
}
|
||||
|
||||
interface Detail {
|
||||
proposal: Proposal & { author: string | null; authorInvited: boolean };
|
||||
unit: { id: string; code: string; inertia: Inertia };
|
||||
inForce: { id: string; title: string; body: string } | null;
|
||||
diff: DiffPart[] | null;
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
signals: {
|
||||
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;
|
||||
}[];
|
||||
history: HistoryItem[];
|
||||
objections: Objection[];
|
||||
typos: TypoFix[];
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
@@ -49,20 +67,23 @@
|
||||
let typoSubstantive = $state(false);
|
||||
let trTitle = $state('');
|
||||
let trBody = $state('');
|
||||
let signalTarget = $state('');
|
||||
let signalReason = $state('');
|
||||
let objectionKind = $state<ObjectionKind>('contredit');
|
||||
let objectionTarget = $state('');
|
||||
let objectionReason = $state('');
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
||||
const p = $derived(detail?.proposal);
|
||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||
const text = $derived(p ? shown(p, lang, source) : null);
|
||||
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
|
||||
const signalTargets = $derived(
|
||||
/** Open or in force: fixed, translated, objected to — whether held back or not. */
|
||||
const live = $derived(p?.status === 'open' || p?.status === 'in_force');
|
||||
/** The versions an objection may name: another article's, open or in force — never two texts in force. */
|
||||
const objectionTargets = $derived(
|
||||
(view?.units ?? []).flatMap((u) =>
|
||||
[u.inForce, ...u.proposals]
|
||||
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
|
||||
.filter((x) => !(u.id === unit?.id))
|
||||
.filter((x): x is Proposal => !!x && x.id !== p?.id && (x.status === 'open' || x.status === 'in_force') && x.kind === 'text')
|
||||
.filter((x) => u.id !== unit?.id && !(x.status === 'in_force' && p?.status === 'in_force'))
|
||||
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
||||
),
|
||||
);
|
||||
@@ -75,6 +96,7 @@
|
||||
typoTitle = d.proposal.title;
|
||||
typoBody = d.proposal.body;
|
||||
typoSubstantive = false;
|
||||
if (edit && d.proposal.mine && d.proposal.status === 'open') typoOpen = true;
|
||||
const tr = d.proposal.translations[lang];
|
||||
trTitle = tr?.title ?? d.proposal.title;
|
||||
trBody = tr?.body ?? d.proposal.body;
|
||||
@@ -86,6 +108,7 @@
|
||||
$effect(() => {
|
||||
void proposalId;
|
||||
void lang;
|
||||
void edit;
|
||||
void load();
|
||||
});
|
||||
|
||||
@@ -96,15 +119,36 @@
|
||||
async function refresh() {
|
||||
await doc.reload();
|
||||
await load();
|
||||
void session.refreshUnread();
|
||||
}
|
||||
|
||||
async function run(fn: () => Promise<void>) {
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTypo(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
error = '';
|
||||
typoSubstantive = false;
|
||||
try {
|
||||
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
|
||||
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
||||
const r = await api.patch<{ edit: string; typo?: { id: string; dueAt: string; answerer: 'author' | 'admin' } }>(
|
||||
`/proposals/${proposalId}`,
|
||||
{ title: typoTitle, body: typoBody },
|
||||
);
|
||||
if (r.edit === 'typo_pending' && r.typo) {
|
||||
toasts.show(t(r.typo.answerer === 'admin' ? 'typo.pending.admin' : 'typo.pending.author', { date: i18n.date(r.typo.dueAt) }), 'info', 7000);
|
||||
} else {
|
||||
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
||||
}
|
||||
typoOpen = false;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
@@ -118,60 +162,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function answerTypo(fix: TypoFix, gesture: 'validate' | 'refuse') {
|
||||
await run(async () => {
|
||||
await api.post(`/typos/${fix.id}/${gesture}`);
|
||||
toasts.show(t(gesture === 'validate' ? 'typo.validated' : 'typo.refused'));
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTranslation(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await run(async () => {
|
||||
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
||||
toasts.show(t('translate.saved'));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSignal(e: SubmitEvent) {
|
||||
async function raise(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
try {
|
||||
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
|
||||
signalTarget = '';
|
||||
signalReason = '';
|
||||
toasts.show(t('signal.sent'));
|
||||
await run(async () => {
|
||||
await api.post(`/proposals/${proposalId}/objections`, { otherId: objectionTarget, kind: objectionKind, reason: objectionReason });
|
||||
objectionTarget = '';
|
||||
objectionReason = '';
|
||||
toasts.show(t('objection.sent'), 'info', 6000);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function lift(id: string) {
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
|
||||
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
|
||||
await run(async () => {
|
||||
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
|
||||
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||
await refresh();
|
||||
announce(doc, r.entered);
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
announce(doc, r.entered ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
async function withdraw() {
|
||||
busy = true;
|
||||
try {
|
||||
await run(async () => {
|
||||
await api.post(`/proposals/${proposalId}/withdraw`);
|
||||
toasts.show(t('propose.withdrawn'));
|
||||
await doc.reload();
|
||||
} catch (err) {
|
||||
error = message(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an objection stands, in a few words. */
|
||||
function stateOf(o: Objection): string {
|
||||
if (o.phase === 'lifted') return t(`objection.lifted.${o.lift ?? 'objector'}` as Key);
|
||||
if (o.phase === 'objector') return t('objection.phase.objector', { date: i18n.date(o.dueAt) });
|
||||
return t('objection.phase.author');
|
||||
}
|
||||
|
||||
/** What became of a fix, or when it applies by itself. */
|
||||
function typoState(fix: TypoFix): string {
|
||||
if (fix.status === 'pending') return t('typo.status.pending', { date: i18n.date(fix.dueAt) });
|
||||
if (fix.status === 'stale') return t('typo.status.stale');
|
||||
const what = t(`typo.status.${fix.status}`);
|
||||
if (fix.settledBy === 'deadline') return `${what} · ${t('typo.settled.deadline')}`;
|
||||
if (fix.settledBy && fix.settledBy !== 'changed') return `${what} · ${t('typo.settled.by', { name: fix.settledBy })}`;
|
||||
return what;
|
||||
}
|
||||
|
||||
function typoGesture(fix: TypoFix, g: 'validate' | 'refuse'): string {
|
||||
if (fix.status === 'applied') return t('typo.undo');
|
||||
if (fix.status === 'refused') return t('typo.redo');
|
||||
return t(g === 'validate' ? 'typo.validate' : 'typo.refuse');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -184,6 +240,7 @@
|
||||
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
||||
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
||||
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
||||
</p>
|
||||
</header>
|
||||
@@ -211,14 +268,14 @@
|
||||
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
||||
{/if}
|
||||
|
||||
{#if votable}
|
||||
{#if live}
|
||||
<p class="tally faint">
|
||||
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
||||
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if (p.kind === 'text' || p.kind === 'title') && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||
{#if (p.kind === 'text' || p.kind === 'title') && live}
|
||||
<section class="sub">
|
||||
<details bind:open={typoOpen}>
|
||||
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
||||
@@ -236,6 +293,30 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.typos.length}
|
||||
<section class="sub" aria-labelledby="typos-{p.id}">
|
||||
<h4 id="typos-{p.id}">{t('typo.list')}</h4>
|
||||
<ul class="fixes">
|
||||
{#each detail.typos as fix (fix.id)}
|
||||
<li class="fix {fix.status}">
|
||||
<DiffText parts={condense(fix.diff)} />
|
||||
<p class="faint small">
|
||||
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)}
|
||||
</p>
|
||||
<p class="small state">{typoState(fix)}</p>
|
||||
{#if fix.gestures.length}
|
||||
<div class="gestures">
|
||||
{#each fix.gestures as g (g)}
|
||||
<button class="btn small" class:primary={g === 'validate' && fix.status === 'pending'} type="button" disabled={busy} onclick={() => answerTypo(fix, g)}>{typoGesture(fix, g)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if lang !== source && (p.kind === 'text' || p.kind === 'title')}
|
||||
<section class="sub">
|
||||
<details>
|
||||
@@ -256,7 +337,10 @@
|
||||
<ol class="history">
|
||||
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
||||
<li>
|
||||
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
|
||||
<p class="small">
|
||||
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
|
||||
{#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}
|
||||
</p>
|
||||
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -264,33 +348,69 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if p.kind === 'text' && votable}
|
||||
<section class="sub">
|
||||
<h4>{t('signal.title')}</h4>
|
||||
{#each detail.signals as s (s.id)}
|
||||
<div class="signal" class:lifted={s.liftedAt}>
|
||||
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
|
||||
<p class="faint small">
|
||||
{t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
|
||||
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
|
||||
{#if p.kind === 'text' && (live || detail.objections.length)}
|
||||
<section class="sub" aria-labelledby="objections-{p.id}">
|
||||
<h4 id="objections-{p.id}">{t('objection.title')}</h4>
|
||||
{#each detail.objections as o (o.id)}
|
||||
{@const standing = o.phase !== 'lifted'}
|
||||
<div class="objection" class:lifted={!standing}>
|
||||
<p class="small">
|
||||
<span class="tag" class:ochre={standing}>{t(`objection.kind.${o.kind}`)}</span>
|
||||
<b>{t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}</b>
|
||||
</p>
|
||||
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
|
||||
<p class="reason">{o.reason}</p>
|
||||
<p class="faint small">
|
||||
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)}
|
||||
· {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if}
|
||||
</p>
|
||||
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
|
||||
{#if standing}
|
||||
<div class="gestures">
|
||||
{#if o.iAnswer && !o.mine && o.phase === 'author'}
|
||||
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
|
||||
{/if}
|
||||
{#if o.canLift}
|
||||
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
|
||||
{/if}
|
||||
{#if o.canReiterate}
|
||||
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="faint small">{t('signal.none')}</p>
|
||||
<p class="faint small">{t('objection.none')}</p>
|
||||
{/each}
|
||||
<details>
|
||||
<summary>{t('signal.add')}</summary>
|
||||
<form class="form" onsubmit={sendSignal}>
|
||||
<p class="faint small">{t('signal.note')}</p>
|
||||
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
|
||||
<option value="" disabled>{t('signal.target')}</option>
|
||||
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
||||
</select>
|
||||
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
|
||||
</form>
|
||||
</details>
|
||||
{#if live && objectionTargets.length}
|
||||
<details>
|
||||
<summary>{t('objection.add')}</summary>
|
||||
<form class="form" onsubmit={raise}>
|
||||
<p class="faint small">{t('objection.note')}</p>
|
||||
<div class="kinds" role="radiogroup" aria-label={t('objection.kind')}>
|
||||
{#each ['redit', 'contredit'] as const as k (k)}
|
||||
<label class:on={objectionKind === k}>
|
||||
<input type="radio" bind:group={objectionKind} value={k} />
|
||||
<b>{t(`objection.kind.${k}`)}</b><span class="hint">{t(`objection.kind.${k}.hint`)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<select class="select" bind:value={objectionTarget} required aria-label={t('objection.target')}>
|
||||
<option value="" disabled>{t('objection.target')}</option>
|
||||
{#each objectionTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
||||
</select>
|
||||
<textarea
|
||||
class="textarea short"
|
||||
bind:value={objectionReason}
|
||||
rows="3"
|
||||
minlength="10"
|
||||
maxlength="600"
|
||||
placeholder={t(`objection.reason.${objectionKind}`)}
|
||||
aria-label={t(`objection.reason.${objectionKind}`)}
|
||||
></textarea>
|
||||
<button class="btn small" type="submit" disabled={busy || !objectionTarget || objectionReason.trim().length < 10}>{t('objection.send')}</button>
|
||||
</form>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -391,18 +511,88 @@
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.signal {
|
||||
.fixes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.fix,
|
||||
.objection {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--ochre-wash);
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.signal.lifted {
|
||||
.fix {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.fix.pending .state {
|
||||
font-weight: 750;
|
||||
color: var(--water);
|
||||
}
|
||||
.fix.refused,
|
||||
.fix.stale {
|
||||
background: var(--surface-2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.objection {
|
||||
background: var(--ochre-wash);
|
||||
}
|
||||
.objection.lifted {
|
||||
background: var(--surface-2);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.signal .btn {
|
||||
justify-self: start;
|
||||
.reason {
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.held {
|
||||
font-weight: 750;
|
||||
color: var(--ochre);
|
||||
}
|
||||
.gestures {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.kinds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.kinds label {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.kinds label.on {
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.kinds label.on .hint {
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.kinds input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kinds label:has(input:focus-visible) {
|
||||
outline: 2px solid var(--water);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
// 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 ».
|
||||
// stands, then the inertia and the formula. A version held back by an
|
||||
// objection says so, and tells each one what is theirs to do (ADR 0010). The
|
||||
// rest of a version (typos, translations, objections, 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, orderNote, titleOf } from '$lib/labels';
|
||||
import { holding } from '$lib/objections';
|
||||
import { moved } from '$lib/order';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { LevelInfo, Proposal, Unit } from '$lib/types';
|
||||
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
|
||||
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
import Formula from './Formula.svelte';
|
||||
import InertiaPicker from './InertiaPicker.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
@@ -25,6 +29,7 @@
|
||||
onopen,
|
||||
onsettings,
|
||||
ondetails,
|
||||
onanswer,
|
||||
onsign,
|
||||
}: {
|
||||
doc: DocState;
|
||||
@@ -34,6 +39,8 @@
|
||||
onopen: (proposalId: string) => void;
|
||||
onsettings: (proposalId: string) => void;
|
||||
ondetails: (proposalId: string) => void;
|
||||
/** The author of a version held back answers its objection: modify the article repeated, or reword. */
|
||||
onanswer: (objection: Objection) => void;
|
||||
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
|
||||
onsign: (lines: Line[]) => void;
|
||||
} = $props();
|
||||
@@ -42,6 +49,7 @@
|
||||
electorate: number;
|
||||
voteFloor: number;
|
||||
levels: LevelInfo[];
|
||||
objections: Objection[];
|
||||
}
|
||||
|
||||
let detail = $state<Detail | null>(null);
|
||||
@@ -52,7 +60,9 @@
|
||||
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 live = $derived(p.status === 'open' || p.status === 'in_force');
|
||||
/** The objections holding this version back, and what each one asks of whom. */
|
||||
const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []);
|
||||
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
|
||||
const voted = $derived(p.myVote !== null && !p.myVoteStale);
|
||||
const ballot = $derived(p.myBallot);
|
||||
@@ -66,6 +76,9 @@
|
||||
const id = p.id;
|
||||
void p.tally.for;
|
||||
void p.tally.against;
|
||||
void p.blocked;
|
||||
void p.objections;
|
||||
void p.revision;
|
||||
api
|
||||
.get<Detail>(`/proposals/${id}`)
|
||||
.then((d) => {
|
||||
@@ -80,6 +93,22 @@
|
||||
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
|
||||
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
|
||||
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||
await doc.reload();
|
||||
announce(doc, r.entered ?? []);
|
||||
void session.refreshUnread();
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cast(choice: 'for' | 'against' | null) {
|
||||
error = '';
|
||||
const lines: Line[] = [{ proposalId: p.id, choice }];
|
||||
@@ -111,7 +140,38 @@
|
||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||
{#if p.blocked}
|
||||
<div class="held" role="status">
|
||||
<p class="lead">{t('vote.held')}</p>
|
||||
{#each held as o (o.id)}
|
||||
<div class="objection">
|
||||
<p>
|
||||
<b>{t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })}</b>
|
||||
<span class="faint">· {t('objection.by', { name: o.objector ?? '' })}</span>
|
||||
</p>
|
||||
<p class="reason">{o.reason}</p>
|
||||
{#if o.mine}
|
||||
<p>{o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}</p>
|
||||
{:else if o.phase === 'objector'}
|
||||
<p>{t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}</p>
|
||||
{:else if o.iAnswer}
|
||||
<p>{t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}</p>
|
||||
{/if}
|
||||
<div class="gestures">
|
||||
{#if o.iAnswer && !o.mine && o.phase === 'author'}
|
||||
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
|
||||
{/if}
|
||||
{#if o.canLift}
|
||||
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
|
||||
{/if}
|
||||
{#if o.canReiterate}
|
||||
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if order}
|
||||
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
||||
{#each order.ids as id (id)}<li class:shifted={shifted.has(id)}>{nameOf(id)}</li>{/each}
|
||||
@@ -135,10 +195,13 @@
|
||||
{:else if ballot?.status === 'expired'}
|
||||
<p class="warn">{t('vote.mine.expired')}</p>
|
||||
{/if}
|
||||
{#if p.myVoteChain || ballot}<a class="faint small" href="/bulletins">{t('vote.mine.receipts')}</a>{/if}
|
||||
</div>
|
||||
|
||||
{#if !votable}
|
||||
{#if !live}
|
||||
<p class="muted">{t('vote.closed')}</p>
|
||||
{:else if p.blocked}
|
||||
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
|
||||
{:else if access && !access.open}
|
||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||
{:else if access}
|
||||
@@ -271,6 +334,34 @@
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.held {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--ochre-wash);
|
||||
}
|
||||
.lead {
|
||||
font-weight: 750;
|
||||
}
|
||||
.objection {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.reason {
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.gestures {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.gestures:empty {
|
||||
display: none;
|
||||
}
|
||||
.more {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// A typo fix is a sign or two in a long text: shown around its changes only,
|
||||
// a few words on each side, the rest cut between words.
|
||||
|
||||
import type { DiffPart } from './types';
|
||||
|
||||
function head(text: string, n: number): string {
|
||||
if (text.length <= n) return text;
|
||||
let s = text.slice(0, n);
|
||||
if (!/\s/.test(text[n] ?? ' ') && /\s/.test(s)) s = s.slice(0, s.search(/\s\S*$/));
|
||||
return s.trimEnd();
|
||||
}
|
||||
|
||||
function tail(text: string, n: number): string {
|
||||
if (text.length <= n) return text;
|
||||
let s = text.slice(-n);
|
||||
if (!/\s/.test(text[text.length - n - 1] ?? ' ') && /\s/.test(s)) s = s.slice(s.search(/\s/) + 1);
|
||||
return s.trimStart();
|
||||
}
|
||||
|
||||
/** The parts of a diff, the unchanged text kept to `context` signs around each change. */
|
||||
export function condense(parts: DiffPart[], context = 40): DiffPart[] {
|
||||
if (!parts.some((p) => p.op !== 'same')) return parts;
|
||||
const last = parts.length - 1;
|
||||
return parts.map((p, i) => {
|
||||
if (p.op !== 'same') return p;
|
||||
if (i === 0) return p.text.length <= context ? p : { op: 'same', text: `… ${tail(p.text, context)}` };
|
||||
if (i === last) return p.text.length <= context ? p : { op: 'same', text: `${head(p.text, context)} …` };
|
||||
if (p.text.length <= 2 * context + 5) return p;
|
||||
return { op: 'same', text: `${head(p.text, context)} … ${tail(p.text, context)}` };
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,30 @@ export interface PanelState {
|
||||
/** The basket, opened on one section. */
|
||||
section?: string | null;
|
||||
prefill?: { title: string; body: string } | null;
|
||||
/** A new proposal that answers this objection (ADR 0010). */
|
||||
objectionId?: string | null;
|
||||
/** The details of one's own version, its edit form open: to reword it. */
|
||||
edit?: boolean;
|
||||
}
|
||||
|
||||
/** The tabs as the URL names them. */
|
||||
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
|
||||
/** The query of a reader's URL: the unfolded vote and the panel. */
|
||||
export function panelSearch(panel: PanelState | null, vote: string | null): string {
|
||||
const q = new URLSearchParams();
|
||||
if (vote) q.set('vote', vote);
|
||||
if (panel) {
|
||||
q.set('onglet', TAB_NAMES[panel.tab]);
|
||||
if (panel.unitId) q.set('article', panel.unitId);
|
||||
if (panel.proposalId) q.set('version', panel.proposalId);
|
||||
if (panel.draftId) q.set('brouillon', panel.draftId);
|
||||
if (panel.section) q.set('section', panel.section);
|
||||
if (panel.objectionId) q.set('objection', panel.objectionId);
|
||||
if (panel.edit) q.set('modifier', '1');
|
||||
}
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
export interface Shown {
|
||||
|
||||
+143
-19
@@ -11,6 +11,9 @@ export const en: Messages = {
|
||||
'nav.mandates': 'Mandates',
|
||||
'nav.protocols': 'Protocols',
|
||||
'nav.groups': 'My groups',
|
||||
'nav.messages': 'Messages',
|
||||
'nav.messages.count.one': 'Messages, {n} unread',
|
||||
'nav.messages.count.other': 'Messages, {n} unread',
|
||||
'nav.docs.count.one': '{label}, {n} document in progress',
|
||||
'nav.docs.count.other': '{label}, {n} documents in progress',
|
||||
'nav.access': 'Access',
|
||||
@@ -139,7 +142,7 @@ export const en: Messages = {
|
||||
'deep.addition': 'new article',
|
||||
'deep.removal': 'removal of “{title}”',
|
||||
'deep.inertia': 'inertia to {level}',
|
||||
'deep.blocked': 'blocked',
|
||||
'deep.held': 'not open to vote: objection',
|
||||
'deep.mine': 'your proposal',
|
||||
'deep.stale': 'needs reconfirming',
|
||||
'deep.wellrated': 'well-rated',
|
||||
@@ -228,7 +231,14 @@ export const en: Messages = {
|
||||
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
|
||||
'vote.reconfirm': 'Reconfirm: {choice}',
|
||||
'vote.private': 'Nobody sees your vote: only the totals are visible.',
|
||||
'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.",
|
||||
'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.',
|
||||
'vote.held.redit': 'It repeats “{other}”.',
|
||||
'vote.held.contredit': 'It contradicts “{other}”.',
|
||||
'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
|
||||
'vote.held.author.contredit': 'For it to be voted on again, reword it. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
|
||||
'vote.held.waiting': 'An answer came: {name} lifts or reiterates the objection by {date}; with no answer, it is lifted.',
|
||||
'vote.held.mine': 'You raised this objection: lift it when the answer suits you.',
|
||||
'vote.held.mine.asked': 'An answer came: lift your objection if it suits you, or reiterate it, by {date}. With no answer from you, it is lifted.',
|
||||
'vote.entered': '“{title}” has come into force.',
|
||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||
@@ -244,14 +254,15 @@ export const en: Messages = {
|
||||
'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.mine.receipts': 'Your ballots and receipts',
|
||||
'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.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 raise an objection.',
|
||||
'vote.members': 'Only members of this group vote here.',
|
||||
'vote.details': 'Typos, translations, flags, history',
|
||||
'vote.details': 'Typos, translations, objections, 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.',
|
||||
@@ -278,6 +289,7 @@ export const en: Messages = {
|
||||
'basket.sent.other': '{n} votes saved.',
|
||||
'basket.section': 'Vote on this section',
|
||||
'basket.mine': 'your vote: {choice}',
|
||||
'basket.held': 'not open to vote: an objection holds it back',
|
||||
'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.',
|
||||
@@ -305,6 +317,38 @@ export const en: Messages = {
|
||||
'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',
|
||||
'sign.error.chain': 'The Ğ1 chain refused the transaction: your vote did not leave. Try again whenever you like.',
|
||||
'sign.receipt': 'Download your receipt',
|
||||
'sign.history': 'Your ballots',
|
||||
'sign.receipt.note': 'Your receipt keeps your ballot and its salt: with it, you check for yourself that the chain carries your vote. Keep it to yourself.',
|
||||
|
||||
'ballots.title': 'Your ballots',
|
||||
'ballots.intro': 'Each vote on the Ğ1 chain leaves in a sealed ballot: the chain shows that you voted, never what you voted. Your receipt keeps the ballot and its salt; with it, you check your vote for yourself. Nobody else receives it.',
|
||||
'ballots.mine': 'Your ballots',
|
||||
'ballots.signin': 'Sign in to find your ballots.',
|
||||
'ballots.none': 'You have not sent any ballot on the Ğ1 chain yet.',
|
||||
'ballots.lines.one': '{n} vote',
|
||||
'ballots.lines.other': '{n} votes',
|
||||
'ballots.status.pending': 'Waiting for the Ğ1 chain',
|
||||
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
|
||||
'ballots.status.refused': 'Not counted: {reason}',
|
||||
'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count',
|
||||
'ballots.block': 'block {n}',
|
||||
'ballots.receipt': 'Download the receipt',
|
||||
'ballots.verify.title': 'Check a receipt',
|
||||
'ballots.verify.intro': 'Choose the file of a receipt, or paste its content: the page recomputes the seal of the ballot right here, without asking the server anything.',
|
||||
'ballots.verify.file': 'Receipt file',
|
||||
'ballots.verify.paste': 'Or paste the receipt',
|
||||
'ballots.verify.go': 'Check',
|
||||
'ballots.verify.ok': 'The receipt holds: its votes and its salt give this very comment.',
|
||||
'ballots.verify.line': 'Version {version}, revision {revision}: {choice}',
|
||||
'ballots.verify.line.label': '{label} — written in the receipt, outside the seal',
|
||||
'ballots.verify.where': 'Look for this comment in the history of your wallet: it is your vote, written on the Ğ1 chain.',
|
||||
'ballots.verify.where.block': 'Look for this comment in the history of your wallet, or on the Ğ1 chain (the receipt says block {block}, outside the seal): if it is there, signed by your account, it is your vote.',
|
||||
'ballots.verify.bad': 'The receipt does not hold: its votes and its salt do not give its comment.',
|
||||
'ballots.verify.computed': 'Recomputed:',
|
||||
'ballots.verify.expected': 'In the receipt:',
|
||||
'ballots.verify.unreadable': 'This is not a readable vote receipt.',
|
||||
|
||||
'inertia.title': 'Inertia',
|
||||
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
|
||||
@@ -359,27 +403,98 @@ export const en: Messages = {
|
||||
'history.typo': 'typo fixed',
|
||||
'history.substantive': 'substantive change (votes to reconfirm)',
|
||||
'history.amendment': 'starting text replaced',
|
||||
'history.revert': 'fix taken back by the author',
|
||||
'history.by': 'by {name}',
|
||||
'history.typo.validated': 'validated by {name}',
|
||||
'history.typo.deadline': 'applied with no answer within 3 days',
|
||||
|
||||
'typo.title': 'Fix a typo',
|
||||
'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.',
|
||||
'typo.note': "Accents, capitals, typos, words run together or split: the fix is not voted on. On your own text, it applies at once. On someone else's, its author validates it (an administrator for a seed text); with no answer within 3 days, it applies. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.",
|
||||
'typo.save': 'Save the fix',
|
||||
'typo.done': 'Typo fixed, votes untouched.',
|
||||
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
|
||||
'typo.asProposal': 'Make it a proposal',
|
||||
'typo.pending.author': 'Your fix waits for the author of the text: it applies when they validate it, or by itself on {date}.',
|
||||
'typo.pending.admin': 'Your fix waits for an administrator, as this text comes from the seed: it applies when they validate it, or by itself on {date}.',
|
||||
'typo.list': 'Suggested typo fixes',
|
||||
'typo.by': 'suggested by {name}',
|
||||
'typo.status.pending': 'waiting: applies by itself on {date}',
|
||||
'typo.status.applied': 'applied',
|
||||
'typo.status.refused': 'refused',
|
||||
'typo.status.stale': 'out of date: the text changed meanwhile',
|
||||
'typo.settled.by': 'by {name}',
|
||||
'typo.settled.deadline': 'with no answer within 3 days',
|
||||
'typo.validate': 'Validate',
|
||||
'typo.refuse': 'Refuse',
|
||||
'typo.undo': 'Go back to the text before',
|
||||
'typo.redo': 'Apply after all',
|
||||
'typo.validated': 'Fix applied.',
|
||||
'typo.refused': 'Fix refused.',
|
||||
|
||||
'signal.title': 'Contradictions',
|
||||
'signal.none': 'No flags.',
|
||||
'signal.add': 'Flag a contradiction',
|
||||
'signal.target': 'Contradicts',
|
||||
'signal.reason': 'How these two texts contradict each other',
|
||||
'signal.send': 'Flag',
|
||||
'signal.sent': 'Flag saved.',
|
||||
'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.",
|
||||
'signal.lift': 'Lift',
|
||||
'signal.lifted': 'lifted',
|
||||
'signal.lifted.revision': 'lapsed: the text changed',
|
||||
'signal.by': 'by {name}',
|
||||
'objection.title': 'Objections',
|
||||
'objection.none': 'No objection.',
|
||||
'objection.add': 'Raise an objection',
|
||||
'objection.kind': 'Your objection',
|
||||
'objection.kind.redit': 'Repeats',
|
||||
'objection.kind.contredit': 'Contradicts',
|
||||
'objection.kind.redit.hint': 'this text repeats another article',
|
||||
'objection.kind.contredit.hint': 'this text says the opposite of another article',
|
||||
'objection.target': 'Which article?',
|
||||
'objection.reason.redit': 'How these two texts say the same thing',
|
||||
'objection.reason.contredit': 'How these two texts contradict each other',
|
||||
'objection.send': 'Send the objection',
|
||||
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.',
|
||||
'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.',
|
||||
'objection.says.redit': '“{held}” repeats “{other}”',
|
||||
'objection.says.contredit': '“{held}” contradicts “{other}”',
|
||||
'objection.by': 'objection by {name}',
|
||||
'objection.held.here': 'this version cannot be voted on',
|
||||
'objection.phase.author': "waiting for the author's answer",
|
||||
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
|
||||
'objection.answer.revision': 'answer: the held text was reworded',
|
||||
'objection.answer.other': 'answer: the other text changed',
|
||||
'objection.answer.proposal': 'answer: a linked proposal',
|
||||
'objection.lifted.objector': 'lifted by the person who raised it',
|
||||
'objection.lifted.admin': 'lifted by an administrator',
|
||||
'objection.lifted.deadline': 'lifted: 3 days without follow-up',
|
||||
'objection.lifted.withdrawn': 'lifted: the proposal was withdrawn',
|
||||
'objection.lifted.revision': 'lapsed: the text changed',
|
||||
'objection.lift': 'Lift the objection',
|
||||
'objection.lift.admin': 'Lift the objection (administrator)',
|
||||
'objection.reiterate': 'Reiterate the objection',
|
||||
'objection.done.lifted': 'Objection lifted.',
|
||||
'objection.done.reiterated': 'Objection reiterated: a new answer is awaited.',
|
||||
'objection.respond.redit': 'Change the article it repeats',
|
||||
'objection.respond.contredit': 'Reword',
|
||||
'objection.linked': 'This proposal answers an objection: once submitted, the person who raised it lifts it or reiterates it.',
|
||||
'messages.title': 'Messages',
|
||||
'messages.intro': 'What waits for a gesture from you: an objection to answer, a typo fix to validate. Each message carries its deadline; a message dealt with asks nothing more.',
|
||||
'messages.none': 'No messages for now.',
|
||||
'messages.readAll': 'Mark all as read',
|
||||
'messages.read': 'Mark as read',
|
||||
'messages.unread': 'unread',
|
||||
'messages.due': 'deadline: {date}',
|
||||
'messages.open': 'See it in the text',
|
||||
'messages.topic.objection': 'Objection',
|
||||
'messages.topic.typo': 'Typo',
|
||||
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.',
|
||||
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.',
|
||||
'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.',
|
||||
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
|
||||
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
|
||||
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
|
||||
'messages.respond': 'Answer',
|
||||
'messages.outcome.answered': 'answered',
|
||||
'messages.outcome.reiterated': 'objection reiterated',
|
||||
'messages.outcome.lifted': 'objection lifted',
|
||||
'messages.outcome.admin': 'objection lifted by an administrator',
|
||||
'messages.outcome.deadline.objection': 'objection lifted: 3 days without follow-up',
|
||||
'messages.outcome.deadline.typo': 'fix applied: 3 days without an answer',
|
||||
'messages.outcome.withdrawn': 'proposal withdrawn',
|
||||
'messages.outcome.applied': 'fix applied',
|
||||
'messages.outcome.refused': 'fix refused',
|
||||
'messages.outcome.stale': 'fix out of date: the text changed',
|
||||
'messages.outcome.done': 'dealt with',
|
||||
|
||||
'propose.mode.replace': 'Change this text',
|
||||
'propose.mode.remove': 'Remove this article',
|
||||
@@ -490,7 +605,7 @@ export const en: Messages = {
|
||||
'error.internal': 'Something failed on our side. Try again in a moment.',
|
||||
'error.title': 'The title is empty or too long (120 characters max).',
|
||||
'error.body': 'The text is empty or too long (4,000 characters max).',
|
||||
'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).',
|
||||
'error.reason': 'Say in a few words what your objection rests on (10 to 600 characters).',
|
||||
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
|
||||
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
|
||||
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
|
||||
@@ -509,7 +624,7 @@ export const en: Messages = {
|
||||
'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.too_many_ballots': 'You already have many ballots waiting, or left on the way within the hour: wait for them to be confirmed, or try again a little later.',
|
||||
'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.',
|
||||
@@ -523,6 +638,15 @@ export const en: Messages = {
|
||||
'error.not_a_title': 'This module is not the title of the document.',
|
||||
'error.same_title': 'This is already the title in force.',
|
||||
'error.section': 'This section does not exist in this document.',
|
||||
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.',
|
||||
'error.kind': 'Say whether this text repeats the other or contradicts it.',
|
||||
'error.other': 'Choose another article, in force or proposed.',
|
||||
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
|
||||
'error.objection_exists': 'You already have an objection standing on these two texts.',
|
||||
'error.nothing_to_reiterate': 'Nothing to reiterate: no answer has come yet.',
|
||||
'error.lifted': 'This objection is already lifted.',
|
||||
'error.settled': 'This fix is already decided.',
|
||||
'error.link': 'This proposal cannot answer this objection: it targets another article, or the objection is lifted.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
+143
-19
@@ -11,6 +11,9 @@ export const es: Messages = {
|
||||
'nav.mandates': 'Mandatos',
|
||||
'nav.protocols': 'Protocolos',
|
||||
'nav.groups': 'Mis grupos',
|
||||
'nav.messages': 'Mensajes',
|
||||
'nav.messages.count.one': 'Mensajes, {n} sin leer',
|
||||
'nav.messages.count.other': 'Mensajes, {n} sin leer',
|
||||
'nav.docs.count.one': '{label}, {n} documento en curso',
|
||||
'nav.docs.count.other': '{label}, {n} documentos en curso',
|
||||
'nav.access': 'Accesos',
|
||||
@@ -139,7 +142,7 @@ export const es: Messages = {
|
||||
'deep.addition': 'artículo nuevo',
|
||||
'deep.removal': 'retirada de «{title}»',
|
||||
'deep.inertia': 'inercia a {level}',
|
||||
'deep.blocked': 'bloqueada',
|
||||
'deep.held': 'no votable: objeción',
|
||||
'deep.mine': 'tu propuesta',
|
||||
'deep.stale': 'por reconfirmar',
|
||||
'deep.wellrated': 'bien valorada',
|
||||
@@ -228,7 +231,14 @@ export const es: Messages = {
|
||||
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
|
||||
'vote.reconfirm': 'Reconfirmar: {choice}',
|
||||
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
|
||||
'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.',
|
||||
'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.',
|
||||
'vote.held.redit': 'Repite «{other}».',
|
||||
'vote.held.contredit': 'Contradice «{other}».',
|
||||
'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
|
||||
'vote.held.author.contredit': 'Para que vuelva a votarse, reformúlala. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
|
||||
'vote.held.waiting': 'Llegó una respuesta: {name} levanta o reitera la objeción antes del {date}; sin respuesta, se levanta.',
|
||||
'vote.held.mine': 'Hiciste esta objeción: levántala cuando la respuesta te convenga.',
|
||||
'vote.held.mine.asked': 'Llegó una respuesta: levanta tu objeción si te convence, o reitérala, antes del {date}. Sin respuesta tuya, se levanta.',
|
||||
'vote.entered': '«{title}» ha entrado en vigor.',
|
||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||
@@ -244,14 +254,15 @@ export const es: Messages = {
|
||||
'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.mine.receipts': 'Tus papeletas y recibos',
|
||||
'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.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 hacer una objeción.',
|
||||
'vote.members': 'Aquí solo votan los miembros de este grupo.',
|
||||
'vote.details': 'Erratas, traducciones, señalamientos, historial',
|
||||
'vote.details': 'Erratas, traducciones, objeciones, 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.',
|
||||
@@ -278,6 +289,7 @@ export const es: Messages = {
|
||||
'basket.sent.other': '{n} votos registrados.',
|
||||
'basket.section': 'Votar esta sección',
|
||||
'basket.mine': 'tu voto: {choice}',
|
||||
'basket.held': 'no votable: una objeción la retiene',
|
||||
'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.',
|
||||
@@ -305,6 +317,38 @@ export const es: Messages = {
|
||||
'sign.close': 'Cerrar',
|
||||
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
|
||||
'sign.retry': 'Reintentar',
|
||||
'sign.error.chain': 'La cadena Ğ1 rechazó la transacción: tu voto no salió. Vuelve a intentarlo cuando quieras.',
|
||||
'sign.receipt': 'Descargar tu recibo',
|
||||
'sign.history': 'Tus papeletas',
|
||||
'sign.receipt.note': 'Tu recibo guarda tu papeleta y su sal: con él, compruebas por tu cuenta que la cadena lleva tu voto. Guárdalo para ti.',
|
||||
|
||||
'ballots.title': 'Tus papeletas de voto',
|
||||
'ballots.intro': 'Cada voto por la cadena Ğ1 sale en una papeleta sellada: la cadena muestra que votaste, nunca lo que votaste. Tu recibo guarda la papeleta y su sal; con él, compruebas tu voto por tu cuenta. Nadie más lo recibe.',
|
||||
'ballots.mine': 'Tus papeletas',
|
||||
'ballots.signin': 'Conéctate para encontrar tus papeletas.',
|
||||
'ballots.none': 'Aún no has enviado ninguna papeleta por la cadena Ğ1.',
|
||||
'ballots.lines.one': '{n} voto',
|
||||
'ballots.lines.other': '{n} votos',
|
||||
'ballots.status.pending': 'Esperando a la cadena Ğ1',
|
||||
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
|
||||
'ballots.status.refused': 'No contado: {reason}',
|
||||
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
|
||||
'ballots.block': 'bloque {n}',
|
||||
'ballots.receipt': 'Descargar el recibo',
|
||||
'ballots.verify.title': 'Comprobar un recibo',
|
||||
'ballots.verify.intro': 'Elige el archivo de un recibo, o pega su contenido: la página recalcula el sello de la papeleta aquí mismo, sin pedir nada al servidor.',
|
||||
'ballots.verify.file': 'Archivo del recibo',
|
||||
'ballots.verify.paste': 'O pega el recibo',
|
||||
'ballots.verify.go': 'Comprobar',
|
||||
'ballots.verify.ok': 'El recibo es válido: sus votos y su sal dan exactamente este comentario.',
|
||||
'ballots.verify.line': 'Versión {version}, revisión {revision}: {choice}',
|
||||
'ballots.verify.line.label': '{label} — anotado en el recibo, fuera del sello',
|
||||
'ballots.verify.where': 'Busca este comentario en el historial de tu monedero: es tu voto, inscrito en la cadena Ğ1.',
|
||||
'ballots.verify.where.block': 'Busca este comentario en el historial de tu monedero, o en la cadena Ğ1 (el recibo indica el bloque {block}, fuera del sello): si figura allí, firmado por tu cuenta, es tu voto.',
|
||||
'ballots.verify.bad': 'El recibo no es válido: sus votos y su sal no dan su comentario.',
|
||||
'ballots.verify.computed': 'Recalculado:',
|
||||
'ballots.verify.expected': 'En el recibo:',
|
||||
'ballots.verify.unreadable': 'No es un recibo de voto legible.',
|
||||
|
||||
'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.',
|
||||
@@ -359,27 +403,98 @@ export const es: Messages = {
|
||||
'history.typo': 'errata corregida',
|
||||
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
||||
'history.amendment': 'texto de partida sustituido',
|
||||
'history.revert': 'corrección retirada por el autor',
|
||||
'history.by': 'por {name}',
|
||||
'history.typo.validated': 'validada por {name}',
|
||||
'history.typo.deadline': 'aplicada sin respuesta en 3 días',
|
||||
|
||||
'typo.title': 'Corregir una errata',
|
||||
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
|
||||
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección no se vota. En tu texto, se aplica enseguida. En el de otra persona, su autor la valida (un administrador para un texto del seed); sin respuesta en 3 días, se aplica. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
|
||||
'typo.save': 'Guardar la corrección',
|
||||
'typo.done': 'Errata corregida, sin tocar los votos.',
|
||||
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||
'typo.asProposal': 'Convertirlo en propuesta',
|
||||
'typo.pending.author': 'Tu corrección espera al autor del texto: se aplica cuando la valida, o sola el {date}.',
|
||||
'typo.pending.admin': 'Tu corrección espera a un administrador, porque este texto viene del seed: se aplica cuando la valida, o sola el {date}.',
|
||||
'typo.list': 'Erratas propuestas',
|
||||
'typo.by': 'propuesta por {name}',
|
||||
'typo.status.pending': 'en espera: se aplica sola el {date}',
|
||||
'typo.status.applied': 'aplicada',
|
||||
'typo.status.refused': 'rechazada',
|
||||
'typo.status.stale': 'caducada: el texto cambió entretanto',
|
||||
'typo.settled.by': 'por {name}',
|
||||
'typo.settled.deadline': 'sin respuesta en 3 días',
|
||||
'typo.validate': 'Validar',
|
||||
'typo.refuse': 'Rechazar',
|
||||
'typo.undo': 'Volver al texto anterior',
|
||||
'typo.redo': 'Aplicar al final',
|
||||
'typo.validated': 'Corrección aplicada.',
|
||||
'typo.refused': 'Corrección rechazada.',
|
||||
|
||||
'signal.title': 'Contradicciones',
|
||||
'signal.none': 'Ningún aviso.',
|
||||
'signal.add': 'Señalar una contradicción',
|
||||
'signal.target': 'Contradice',
|
||||
'signal.reason': 'En qué se contradicen estos dos textos',
|
||||
'signal.send': 'Señalar',
|
||||
'signal.sent': 'Aviso registrado.',
|
||||
'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.',
|
||||
'signal.lift': 'Levantar',
|
||||
'signal.lifted': 'levantado',
|
||||
'signal.lifted.revision': 'sin efecto: el texto ha cambiado',
|
||||
'signal.by': 'por {name}',
|
||||
'objection.title': 'Objeciones',
|
||||
'objection.none': 'Ninguna objeción.',
|
||||
'objection.add': 'Hacer una objeción',
|
||||
'objection.kind': 'Tu objeción',
|
||||
'objection.kind.redit': 'Repite',
|
||||
'objection.kind.contredit': 'Contradice',
|
||||
'objection.kind.redit.hint': 'este texto repite otro artículo',
|
||||
'objection.kind.contredit.hint': 'este texto dice lo contrario de otro artículo',
|
||||
'objection.target': '¿Qué artículo?',
|
||||
'objection.reason.redit': 'En qué dicen lo mismo estos dos textos',
|
||||
'objection.reason.contredit': 'En qué se contradicen estos dos textos',
|
||||
'objection.send': 'Enviar la objeción',
|
||||
'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.',
|
||||
'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.',
|
||||
'objection.says.redit': '«{held}» repite «{other}»',
|
||||
'objection.says.contredit': '«{held}» contradice «{other}»',
|
||||
'objection.by': 'objeción de {name}',
|
||||
'objection.held.here': 'esta versión no se vota',
|
||||
'objection.phase.author': 'en espera de la respuesta del autor',
|
||||
'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
|
||||
'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
|
||||
'objection.answer.other': 'respuesta: el otro texto cambió',
|
||||
'objection.answer.proposal': 'respuesta: una propuesta vinculada',
|
||||
'objection.lifted.objector': 'levantada por la persona que la hizo',
|
||||
'objection.lifted.admin': 'levantada por un administrador',
|
||||
'objection.lifted.deadline': 'levantada: 3 días sin continuación',
|
||||
'objection.lifted.withdrawn': 'levantada: la propuesta se retiró',
|
||||
'objection.lifted.revision': 'caída: el texto cambió',
|
||||
'objection.lift': 'Levantar la objeción',
|
||||
'objection.lift.admin': 'Levantar la objeción (administrador)',
|
||||
'objection.reiterate': 'Reiterar la objeción',
|
||||
'objection.done.lifted': 'Objeción levantada.',
|
||||
'objection.done.reiterated': 'Objeción reiterada: se espera una nueva respuesta.',
|
||||
'objection.respond.redit': 'Modificar el artículo que repite',
|
||||
'objection.respond.contredit': 'Reformular',
|
||||
'objection.linked': 'Esta propuesta responde a una objeción: una vez enviada, la persona que la hizo la levanta o la reitera.',
|
||||
'messages.title': 'Mensajes',
|
||||
'messages.intro': 'Lo que espera un gesto tuyo: una objeción que responder, una corrección de errata que validar. Cada mensaje lleva su plazo; un mensaje tratado ya no pide nada.',
|
||||
'messages.none': 'Ningún mensaje por ahora.',
|
||||
'messages.readAll': 'Marcar todo como leído',
|
||||
'messages.read': 'Marcar como leído',
|
||||
'messages.unread': 'sin leer',
|
||||
'messages.due': 'plazo: {date}',
|
||||
'messages.open': 'Verlo en el texto',
|
||||
'messages.topic.objection': 'Objeción',
|
||||
'messages.topic.typo': 'Errata',
|
||||
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.',
|
||||
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.',
|
||||
'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.',
|
||||
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
|
||||
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
|
||||
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
|
||||
'messages.respond': 'Responder',
|
||||
'messages.outcome.answered': 'respuesta dada',
|
||||
'messages.outcome.reiterated': 'objeción reiterada',
|
||||
'messages.outcome.lifted': 'objeción levantada',
|
||||
'messages.outcome.admin': 'objeción levantada por un administrador',
|
||||
'messages.outcome.deadline.objection': 'objeción levantada: 3 días sin continuación',
|
||||
'messages.outcome.deadline.typo': 'corrección aplicada: 3 días sin respuesta',
|
||||
'messages.outcome.withdrawn': 'propuesta retirada',
|
||||
'messages.outcome.applied': 'corrección aplicada',
|
||||
'messages.outcome.refused': 'corrección rechazada',
|
||||
'messages.outcome.stale': 'corrección caducada: el texto cambió',
|
||||
'messages.outcome.done': 'tratado',
|
||||
|
||||
'propose.mode.replace': 'Modificar este texto',
|
||||
'propose.mode.remove': 'Retirar este artículo',
|
||||
@@ -490,7 +605,7 @@ export const es: Messages = {
|
||||
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
|
||||
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
|
||||
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
|
||||
'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).',
|
||||
'error.reason': 'Di en pocas palabras en qué se basa tu objeción (de 10 a 600 caracteres).',
|
||||
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
|
||||
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
|
||||
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
|
||||
@@ -509,7 +624,7 @@ export const es: Messages = {
|
||||
'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.too_many_ballots': 'Ya tienes muchas papeletas esperando, o abandonadas en la última hora: espera a que se confirmen, o vuelve a intentarlo un poco más tarde.',
|
||||
'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.',
|
||||
@@ -523,6 +638,15 @@ export const es: Messages = {
|
||||
'error.not_a_title': 'Este módulo no es el título del documento.',
|
||||
'error.same_title': 'Ya es el título en vigor.',
|
||||
'error.section': 'Esta sección no existe en este documento.',
|
||||
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.',
|
||||
'error.kind': 'Di si este texto repite el otro o lo contradice.',
|
||||
'error.other': 'Elige otro artículo, en vigor o propuesto.',
|
||||
'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.',
|
||||
'error.objection_exists': 'Ya tienes una objeción en curso sobre estos dos textos.',
|
||||
'error.nothing_to_reiterate': 'Nada que reiterar: aún no ha llegado ninguna respuesta.',
|
||||
'error.lifted': 'Esta objeción ya está levantada.',
|
||||
'error.settled': 'Esta corrección ya está decidida.',
|
||||
'error.link': 'Esta propuesta no puede responder a esta objeción: apunta a otro artículo, o la objeción está levantada.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
|
||||
+143
-19
@@ -14,6 +14,9 @@ export const fr = {
|
||||
'nav.mandates': 'Mandats',
|
||||
'nav.protocols': 'Protocoles',
|
||||
'nav.groups': 'Mes groupes',
|
||||
'nav.messages': 'Messages',
|
||||
'nav.messages.count.one': 'Messages, {n} non lu',
|
||||
'nav.messages.count.other': 'Messages, {n} non lus',
|
||||
'nav.docs.count.one': '{label}, {n} doc en cours',
|
||||
'nav.docs.count.other': '{label}, {n} docs en cours',
|
||||
'nav.access': 'Accès',
|
||||
@@ -142,7 +145,7 @@ export const fr = {
|
||||
'deep.addition': 'nouvel article',
|
||||
'deep.removal': 'retrait de « {title} »',
|
||||
'deep.inertia': 'inertie vers {level}',
|
||||
'deep.blocked': 'bloquée',
|
||||
'deep.held': 'non votable : objection',
|
||||
'deep.mine': 'ta proposition',
|
||||
'deep.stale': 'à reconfirmer',
|
||||
'deep.wellrated': 'bien cotée',
|
||||
@@ -231,7 +234,14 @@ export const fr = {
|
||||
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
|
||||
'vote.reconfirm': 'Reconfirmer : {choice}',
|
||||
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
|
||||
'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.",
|
||||
'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.",
|
||||
'vote.held.redit': 'Elle redit « {other} ».',
|
||||
'vote.held.contredit': 'Elle contredit « {other} ».',
|
||||
'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
|
||||
'vote.held.author.contredit': "Pour qu'elle se vote à nouveau, reformule-la. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
|
||||
'vote.held.waiting': "Une réponse est arrivée : {name} lève ou réitère l'objection d'ici le {date} ; sans réponse, elle est levée.",
|
||||
'vote.held.mine': 'Tu as fait cette objection : lève-la quand la réponse te convient.',
|
||||
'vote.held.mine.asked': "Une réponse est arrivée : lève ton objection si elle te convient, ou réitère-la, d'ici le {date}. Sans réponse de ta part, elle est levée.",
|
||||
'vote.entered': '« {title} » est entré en vigueur.',
|
||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||
@@ -247,14 +257,15 @@ export const fr = {
|
||||
'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.mine.receipts': 'Tes bulletins et reçus',
|
||||
'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.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 faire une objection.",
|
||||
'vote.members': 'Seuls les membres de ce groupe votent ici.',
|
||||
'vote.details': 'Coquilles, traductions, signalements, historique',
|
||||
'vote.details': 'Coquilles, traductions, objections, 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.',
|
||||
@@ -281,6 +292,7 @@ export const fr = {
|
||||
'basket.sent.other': '{n} votes enregistrés.',
|
||||
'basket.section': 'Voter cette section',
|
||||
'basket.mine': 'ton vote : {choice}',
|
||||
'basket.held': 'non votable : une objection la retient',
|
||||
'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é.',
|
||||
@@ -308,6 +320,38 @@ export const fr = {
|
||||
'sign.close': 'Fermer',
|
||||
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
|
||||
'sign.retry': 'Réessayer',
|
||||
'sign.error.chain': "La chaîne Ğ1 a refusé la transaction : ton vote n'est pas parti. Réessaie quand tu veux.",
|
||||
'sign.receipt': 'Télécharger ton reçu',
|
||||
'sign.history': 'Tes bulletins',
|
||||
'sign.receipt.note': 'Ton reçu garde ton bulletin et son sel : avec lui, tu vérifies toi-même que la chaîne porte ton vote. Garde-le pour toi.',
|
||||
|
||||
'ballots.title': 'Tes bulletins de vote',
|
||||
'ballots.intro': "Chaque vote par la chaîne Ğ1 part dans un bulletin scellé : la chaîne montre que tu as voté, jamais ce que tu as voté. Ton reçu garde le bulletin et son sel ; avec lui, tu vérifies toi-même ton vote. Personne d'autre ne le reçoit.",
|
||||
'ballots.mine': 'Tes bulletins',
|
||||
'ballots.signin': 'Connecte-toi pour retrouver tes bulletins.',
|
||||
'ballots.none': "Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.",
|
||||
'ballots.lines.one': '{n} vote',
|
||||
'ballots.lines.other': '{n} votes',
|
||||
'ballots.status.pending': 'En attente de la chaîne Ğ1',
|
||||
'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »",
|
||||
'ballots.status.refused': 'Pas compté : {reason}',
|
||||
'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas',
|
||||
'ballots.block': 'bloc {n}',
|
||||
'ballots.receipt': 'Télécharger le reçu',
|
||||
'ballots.verify.title': 'Vérifier un reçu',
|
||||
'ballots.verify.intro': "Choisis le fichier d'un reçu, ou colle son contenu : la page recalcule l'empreinte du bulletin ici même, sans rien demander au serveur.",
|
||||
'ballots.verify.file': 'Fichier du reçu',
|
||||
'ballots.verify.paste': 'Ou colle le reçu',
|
||||
'ballots.verify.go': 'Vérifier',
|
||||
'ballots.verify.ok': 'Le reçu tient : ses votes et son sel donnent bien ce commentaire.',
|
||||
'ballots.verify.line': 'Version {version}, révision {revision} : {choice}',
|
||||
'ballots.verify.line.label': '{label} — noté dans le reçu, hors du sceau',
|
||||
'ballots.verify.where': "Cherche ce commentaire dans l'historique de ton portefeuille : c'est ton vote, inscrit sur la chaîne Ğ1.",
|
||||
'ballots.verify.where.block': "Cherche ce commentaire dans l'historique de ton portefeuille, ou sur la chaîne Ğ1 (le reçu indique le bloc {block}, hors du sceau) : s'il y figure, signé de ton compte, c'est ton vote.",
|
||||
'ballots.verify.bad': 'Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.',
|
||||
'ballots.verify.computed': 'Recalculé :',
|
||||
'ballots.verify.expected': 'Dans le reçu :',
|
||||
'ballots.verify.unreadable': "Ce n'est pas un reçu de vote lisible.",
|
||||
|
||||
'inertia.title': 'Inertie',
|
||||
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
|
||||
@@ -362,27 +406,98 @@ export const fr = {
|
||||
'history.typo': 'coquille corrigée',
|
||||
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
||||
'history.amendment': 'texte de départ remplacé',
|
||||
'history.revert': "correction retirée par l'auteur",
|
||||
'history.by': 'par {name}',
|
||||
'history.typo.validated': 'validée par {name}',
|
||||
'history.typo.deadline': 'appliquée sans réponse sous 3 jours',
|
||||
|
||||
'typo.title': 'Corriger une coquille',
|
||||
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
|
||||
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction ne se vote pas. Sur ton texte, elle s'applique tout de suite. Sur celui d'une autre personne, son auteur la valide (un administrateur pour un texte du seed) ; sans réponse sous 3 jours, elle s'applique. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
|
||||
'typo.save': 'Enregistrer la correction',
|
||||
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
|
||||
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
|
||||
'typo.asProposal': 'En faire une proposition',
|
||||
'typo.pending.author': "Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le {date}.",
|
||||
'typo.pending.admin': "Ta correction attend un administrateur, ce texte venant du seed : elle s'applique quand il la valide, ou d'elle-même le {date}.",
|
||||
'typo.list': 'Coquilles proposées',
|
||||
'typo.by': 'proposée par {name}',
|
||||
'typo.status.pending': "en attente : s'applique d'elle-même le {date}",
|
||||
'typo.status.applied': 'appliquée',
|
||||
'typo.status.refused': 'refusée',
|
||||
'typo.status.stale': 'périmée : le texte a changé entre-temps',
|
||||
'typo.settled.by': 'par {name}',
|
||||
'typo.settled.deadline': 'sans réponse sous 3 jours',
|
||||
'typo.validate': 'Valider',
|
||||
'typo.refuse': 'Refuser',
|
||||
'typo.undo': "Revenir au texte d'avant",
|
||||
'typo.redo': 'Appliquer finalement',
|
||||
'typo.validated': 'Correction appliquée.',
|
||||
'typo.refused': 'Correction refusée.',
|
||||
|
||||
'signal.title': 'Contradictions',
|
||||
'signal.none': 'Aucun signalement.',
|
||||
'signal.add': 'Signaler une contradiction',
|
||||
'signal.target': 'Contredit',
|
||||
'signal.reason': 'En quoi ces deux textes se contredisent',
|
||||
'signal.send': 'Signaler',
|
||||
'signal.sent': 'Signalement enregistré.',
|
||||
'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.",
|
||||
'signal.lift': 'Lever',
|
||||
'signal.lifted': 'levé',
|
||||
'signal.lifted.revision': 'tombé : le texte a changé',
|
||||
'signal.by': 'par {name}',
|
||||
'objection.title': 'Objections',
|
||||
'objection.none': 'Aucune objection.',
|
||||
'objection.add': 'Faire une objection',
|
||||
'objection.kind': 'Ton objection',
|
||||
'objection.kind.redit': 'Redit',
|
||||
'objection.kind.contredit': 'Contredit',
|
||||
'objection.kind.redit.hint': 'ce texte répète un autre article',
|
||||
'objection.kind.contredit.hint': "ce texte dit le contraire d'un autre article",
|
||||
'objection.target': 'Quel article ?',
|
||||
'objection.reason.redit': 'En quoi ces deux textes disent la même chose',
|
||||
'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
|
||||
'objection.send': "Envoyer l'objection",
|
||||
'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.",
|
||||
'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.",
|
||||
'objection.says.redit': '« {held} » redit « {other} »',
|
||||
'objection.says.contredit': '« {held} » contredit « {other} »',
|
||||
'objection.by': 'objection de {name}',
|
||||
'objection.held.here': 'cette version ne se vote plus',
|
||||
'objection.phase.author': "en attente d'une réponse de l'auteur",
|
||||
'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même",
|
||||
'objection.answer.revision': 'réponse : le texte retenu a été reformulé',
|
||||
'objection.answer.other': "réponse : l'autre texte a changé",
|
||||
'objection.answer.proposal': 'réponse : une proposition liée',
|
||||
'objection.lifted.objector': "levée par la personne qui l'a faite",
|
||||
'objection.lifted.admin': 'levée par un administrateur',
|
||||
'objection.lifted.deadline': 'levée : 3 jours sans suite',
|
||||
'objection.lifted.withdrawn': 'levée : la proposition a été retirée',
|
||||
'objection.lifted.revision': 'tombée : le texte a changé',
|
||||
'objection.lift': "Lever l'objection",
|
||||
'objection.lift.admin': "Lever l'objection (administrateur)",
|
||||
'objection.reiterate': "Réitérer l'objection",
|
||||
'objection.done.lifted': 'Objection levée.',
|
||||
'objection.done.reiterated': 'Objection réitérée : une nouvelle réponse est attendue.',
|
||||
'objection.respond.redit': "Modifier l'article visé",
|
||||
'objection.respond.contredit': 'Reformuler',
|
||||
'objection.linked': "Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.",
|
||||
'messages.title': 'Messages',
|
||||
'messages.intro': 'Ce qui attend un geste de toi : une objection à laquelle répondre, une correction de coquille à valider. Chaque message porte son échéance ; un message traité ne demande plus rien.',
|
||||
'messages.none': "Aucun message pour l'instant.",
|
||||
'messages.readAll': 'Tout marquer comme lu',
|
||||
'messages.read': 'Marquer comme lu',
|
||||
'messages.unread': 'non lu',
|
||||
'messages.due': 'échéance : {date}',
|
||||
'messages.open': 'Voir dans le texte',
|
||||
'messages.topic.objection': 'Objection',
|
||||
'messages.topic.typo': 'Coquille',
|
||||
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.",
|
||||
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.",
|
||||
'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.',
|
||||
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
|
||||
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
|
||||
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
|
||||
'messages.respond': 'Répondre',
|
||||
'messages.outcome.answered': 'réponse donnée',
|
||||
'messages.outcome.reiterated': 'objection réitérée',
|
||||
'messages.outcome.lifted': 'objection levée',
|
||||
'messages.outcome.admin': 'objection levée par un administrateur',
|
||||
'messages.outcome.deadline.objection': 'objection levée : 3 jours sans suite',
|
||||
'messages.outcome.deadline.typo': 'correction appliquée : 3 jours sans réponse',
|
||||
'messages.outcome.withdrawn': 'proposition retirée',
|
||||
'messages.outcome.applied': 'correction appliquée',
|
||||
'messages.outcome.refused': 'correction refusée',
|
||||
'messages.outcome.stale': 'correction périmée : le texte a changé',
|
||||
'messages.outcome.done': 'traité',
|
||||
|
||||
'propose.mode.replace': 'Modifier ce texte',
|
||||
'propose.mode.remove': 'Retirer cet article',
|
||||
@@ -493,7 +608,7 @@ export const fr = {
|
||||
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
|
||||
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
|
||||
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
|
||||
'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).',
|
||||
'error.reason': 'Dis en quelques mots ce qui fonde ton objection (10 à 600 signes).',
|
||||
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
|
||||
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
|
||||
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
|
||||
@@ -512,7 +627,7 @@ export const fr = {
|
||||
'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.too_many_ballots': 'Tu as déjà beaucoup de bulletins en attente, ou laissés en route depuis une heure : attends leur confirmation, ou réessaie un peu plus tard.',
|
||||
'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.",
|
||||
@@ -526,6 +641,15 @@ export const fr = {
|
||||
'error.not_a_title': "Ce module n'est pas le titre du document.",
|
||||
'error.same_title': "C'est déjà le titre en vigueur.",
|
||||
'error.section': "Cette section n'existe pas dans ce document.",
|
||||
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.",
|
||||
'error.kind': "Dis si ce texte redit l'autre ou le contredit.",
|
||||
'error.other': 'Choisis un autre article, en vigueur ou proposé.',
|
||||
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
|
||||
'error.objection_exists': 'Tu as déjà une objection en cours sur ces deux textes.',
|
||||
'error.nothing_to_reiterate': "Rien à réitérer : aucune réponse n'est encore arrivée.",
|
||||
'error.lifted': 'Cette objection est déjà levée.',
|
||||
'error.settled': 'Cette correction est déjà tranchée.',
|
||||
'error.link': 'Cette proposition ne peut pas répondre à cette objection : elle vise un autre article, ou l’objection est levée.',
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Answering an objection (Yvv, 2026-10-30: « Pour ce qui constitue une
|
||||
// répétition → modifier l'article cible ; pour une contradiction →
|
||||
// reformuler »): where the author of the version held back is taken.
|
||||
|
||||
import type { PanelState } from './doc.svelte';
|
||||
import type { Objection } from './types';
|
||||
|
||||
/**
|
||||
* A repetition: a proposal that changes the article repeated, linked to the
|
||||
* objection, starting from its text. A contradiction: one's own open version is
|
||||
* reworded in place; a seeded one, by a linked proposal on its article.
|
||||
*/
|
||||
export function answerPanel(o: Objection, me: string | null): PanelState | null {
|
||||
const held = o.heldVersion;
|
||||
if (!held) return null;
|
||||
if (o.kind === 'redit') {
|
||||
return { tab: 'propose', unitId: o.other.unitId, proposalId: o.other.id, objectionId: o.id };
|
||||
}
|
||||
if (me && held.authorId === me && held.status === 'open') {
|
||||
return { tab: 'details', unitId: held.unitId, proposalId: held.id, edit: true };
|
||||
}
|
||||
return { tab: 'propose', unitId: held.unitId, proposalId: held.id, objectionId: o.id };
|
||||
}
|
||||
|
||||
/** The objections standing against a version, held back by them. */
|
||||
export function holding(objections: Objection[]): Objection[] {
|
||||
return objections.filter((o) => o.held && o.phase !== 'lifted');
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its
|
||||
// lines and its salt — as the person who asked for it downloads it
|
||||
// (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking
|
||||
// the server anything: the canonical text, its blake2b-256 seal, and the comment
|
||||
// `ld1:` + seal that the chain carries. The same rules as the engine
|
||||
// (crates/ld-engine/src/ballot.rs, `canonical` and `seal`).
|
||||
|
||||
import { blake2b } from '@noble/hashes/blake2.js';
|
||||
|
||||
export type ReceiptChoice = 'for' | 'against' | 'none';
|
||||
|
||||
export interface ReceiptLine {
|
||||
proposalId: string;
|
||||
revision: number;
|
||||
choice: ReceiptChoice;
|
||||
/** For reading only: the article and the title of the version voted. */
|
||||
article?: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface Sealed {
|
||||
canonical: string;
|
||||
digest: string;
|
||||
comment: string;
|
||||
/** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */
|
||||
remarkHash: string;
|
||||
}
|
||||
|
||||
export type ReceiptCheck =
|
||||
| { kind: 'unreadable' }
|
||||
| { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] }
|
||||
| { kind: 'mismatch'; comment: string; computed: string };
|
||||
|
||||
const CHOICES: readonly string[] = ['for', 'against', 'none'];
|
||||
/** Printable ASCII without spaces: what reads back, one field per line. */
|
||||
const PLAIN = /^[\x21-\x7e]+$/;
|
||||
const SALT = /^[0-9a-f]{32}$/;
|
||||
|
||||
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
export function blake2_256(text: string): string {
|
||||
return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 }));
|
||||
}
|
||||
|
||||
/** The ballot as it is hashed; `null` if it cannot be one. */
|
||||
export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null {
|
||||
if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null;
|
||||
const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0));
|
||||
if (!sorted.length) return null;
|
||||
for (const [i, l] of sorted.entries()) {
|
||||
if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null;
|
||||
if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null;
|
||||
}
|
||||
let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`;
|
||||
for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`;
|
||||
return `${out}salt ${salt}\n`;
|
||||
}
|
||||
|
||||
export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null {
|
||||
const text = canonical(doc, author, lines, salt);
|
||||
if (text === null) return null;
|
||||
const digest = blake2_256(text);
|
||||
const comment = `ld1:${digest}`;
|
||||
return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` };
|
||||
}
|
||||
|
||||
const isLine = (l: unknown): l is ReceiptLine => {
|
||||
const v = l as Partial<ReceiptLine> | null;
|
||||
return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string';
|
||||
};
|
||||
|
||||
/** Reads a receipt (its text, as downloaded) and recomputes its seal. */
|
||||
export function checkReceipt(text: string): ReceiptCheck {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return { kind: 'unreadable' };
|
||||
}
|
||||
const r = raw as Record<string, unknown> | null;
|
||||
if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' };
|
||||
const { doc, author, lines, salt, comment, block } = r;
|
||||
if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' };
|
||||
if (author !== null && typeof author !== 'string') return { kind: 'unreadable' };
|
||||
if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' };
|
||||
const s = seal(doc, author, lines, salt);
|
||||
if (!s) return { kind: 'unreadable' };
|
||||
if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment };
|
||||
return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines };
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { api } from './api';
|
||||
import type { Me } from './types';
|
||||
|
||||
/** How often the number of unread messages is looked at again, while signed in. */
|
||||
const UNREAD_EVERY_MS = 60_000;
|
||||
|
||||
class Session {
|
||||
me = $state<Me | null>(null);
|
||||
ready = $state(false);
|
||||
g1 = $state(false);
|
||||
dev = $state(false);
|
||||
/** Unread messages of one's box, next to « Messages » in the menu (ADR 0010). */
|
||||
unread = $state(0);
|
||||
#timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async load() {
|
||||
const [me, cfg] = await Promise.all([
|
||||
@@ -16,11 +22,33 @@ class Session {
|
||||
this.g1 = cfg.g1;
|
||||
this.dev = cfg.dev;
|
||||
this.ready = true;
|
||||
this.watch();
|
||||
}
|
||||
|
||||
/** Looks at the box again: on a navigation, after a gesture, every minute. */
|
||||
async refreshUnread() {
|
||||
if (!this.me) {
|
||||
this.unread = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.unread = (await api.get<{ unread: number }>('/messages/unread')).unread;
|
||||
} catch {
|
||||
/* the count waits for the next look */
|
||||
}
|
||||
}
|
||||
|
||||
/** A sign-in on another page sets `me` directly: the count follows on the next look. */
|
||||
private watch() {
|
||||
clearInterval(this.#timer);
|
||||
void this.refreshUnread();
|
||||
this.#timer = setInterval(() => void this.refreshUnread(), UNREAD_EVERY_MS);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await api.post('/auth/logout');
|
||||
this.me = null;
|
||||
this.unread = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+99
-1
@@ -110,8 +110,12 @@ export interface Proposal {
|
||||
tally: Tally;
|
||||
evaluation: Evaluation | null;
|
||||
votedAtInertia: Inertia;
|
||||
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
|
||||
blocked: boolean;
|
||||
signals: number;
|
||||
/** May be voted now: open or in force, and not held back (ADR 0010). */
|
||||
votable: boolean;
|
||||
/** Standing objections around this version. */
|
||||
objections: number;
|
||||
myVote: 'for' | 'against' | null;
|
||||
myVoteStale: boolean;
|
||||
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
|
||||
@@ -169,6 +173,20 @@ export interface Ballot {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
|
||||
export interface BallotEntry {
|
||||
id: string;
|
||||
doc: { id: string; group: string; slug: string; title: LangMap | string };
|
||||
comment: string;
|
||||
status: Ballot['status'];
|
||||
reason: Ballot['reason'];
|
||||
block: number | null;
|
||||
voter: string | null;
|
||||
lines: number;
|
||||
createdAt: string;
|
||||
settledAt: string | null;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -244,3 +262,83 @@ export interface Entered {
|
||||
code: string;
|
||||
kind: 'text' | 'remove' | 'inertia' | 'title' | 'order';
|
||||
}
|
||||
|
||||
export type ObjectionKind = 'redit' | 'contredit';
|
||||
|
||||
/** A version as an objection names it. */
|
||||
export interface VersionRef {
|
||||
id: string;
|
||||
title: string | null;
|
||||
status: Proposal['status'] | null;
|
||||
unitId: string | null;
|
||||
code: string | null;
|
||||
author: string | null;
|
||||
authorId: string | null;
|
||||
}
|
||||
|
||||
/** An objection, as the session sees it (ADR 0010). */
|
||||
export interface Objection {
|
||||
id: string;
|
||||
kind: ObjectionKind;
|
||||
reason: string;
|
||||
objector: string | null;
|
||||
objectorInvited: boolean;
|
||||
/** This session raised it. */
|
||||
mine: boolean;
|
||||
at: string;
|
||||
/** The author answers, then the objector lifts or reiterates; or it is lifted. */
|
||||
phase: 'author' | 'objector' | 'lifted';
|
||||
cycle: number;
|
||||
/** While the objector is asked: past this, it is lifted. */
|
||||
dueAt: string | null;
|
||||
answer: 'revision' | 'other' | 'proposal' | null;
|
||||
answerId: string | null;
|
||||
answeredAt: string | null;
|
||||
lift: 'objector' | 'admin' | 'deadline' | 'withdrawn' | 'revision' | null;
|
||||
liftedAt: string | null;
|
||||
/** The latest arrival of the two, not votable while it stands. */
|
||||
heldVersion: VersionRef | null;
|
||||
other: VersionRef;
|
||||
/** This session may answer by a proposal linked to it (the author of the held version, or anyone for a seeded one). */
|
||||
iAnswer: boolean;
|
||||
canLift: boolean;
|
||||
canReiterate: boolean;
|
||||
/** Around one version: whether that version is the one held back. */
|
||||
held?: boolean;
|
||||
}
|
||||
|
||||
/** A typo fix written by someone else than the author (ADR 0010). */
|
||||
export interface TypoFix {
|
||||
id: string;
|
||||
status: 'pending' | 'applied' | 'refused' | 'stale';
|
||||
writer: string | null;
|
||||
writerInvited: boolean;
|
||||
at: string;
|
||||
dueAt: string;
|
||||
settledAt: string | null;
|
||||
/** A name, or `deadline` (passed without an answer), or `changed` (the text moved on). */
|
||||
settledBy: string | null;
|
||||
/** Who answers: the author of the version, or an administrator for a seeded text. */
|
||||
answerer: 'author' | 'admin';
|
||||
diff: DiffPart[];
|
||||
gestures: ('validate' | 'refuse')[];
|
||||
version?: { id: string; title: string; status: Proposal['status']; unitId: string; code: string | null };
|
||||
}
|
||||
|
||||
/** A message of one's box: about an objection or a typo fix, with its gestures now. */
|
||||
export interface Message {
|
||||
id: string;
|
||||
topic: 'objection' | 'typo';
|
||||
kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending';
|
||||
cycle: number;
|
||||
createdAt: string;
|
||||
dueAt: string | null;
|
||||
read: boolean;
|
||||
/** Dealt with: no gesture any more. */
|
||||
done: boolean;
|
||||
outcome: string | null;
|
||||
gestures: ('respond' | 'lift' | 'reiterate' | 'validate' | 'refuse')[];
|
||||
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
|
||||
objection?: Objection;
|
||||
typo?: TypoFix;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ export function level(p: Proposal): number {
|
||||
|
||||
/**
|
||||
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
|
||||
* against. A blocked version comes after the others: it cannot enter into force.
|
||||
* against. A version held back by an objection comes after the others: it is not
|
||||
* votable, and cannot enter into force, until the objection is lifted.
|
||||
*/
|
||||
export function compare(a: Proposal, b: Proposal): number {
|
||||
return (
|
||||
|
||||
@@ -25,9 +25,13 @@ export interface VoteTransfer {
|
||||
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';
|
||||
/**
|
||||
* signing: the wallet asks the person; signed: signed and handed to the node —
|
||||
* from now on the transaction may reach the chain; sent: on its way; included: in a block.
|
||||
*/
|
||||
export type SendStatus = 'signing' | 'signed' | 'sent' | 'included';
|
||||
/** chain: included in a block, but the chain undid it (the transfer failed): no vote will come. */
|
||||
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet' | 'chain';
|
||||
|
||||
export class WalletError extends Error {
|
||||
constructor(public code: Failure) {
|
||||
@@ -69,6 +73,12 @@ export function injectedNames(page: Page): string[] {
|
||||
/** 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;
|
||||
// polkadot-api's refusal by the node's pool (stale, bad proof, too late…): never sent.
|
||||
// Its message is indented JSON, so the reason is read from the error itself.
|
||||
if (e instanceof Error && e.name === 'InvalidTxError') {
|
||||
const reason = JSON.stringify((e as Error & { error?: unknown }).error ?? e.message).toLowerCase();
|
||||
return /payment|funds|balance|existential/.test(reason) ? 'funds' : 'chain';
|
||||
}
|
||||
let text: string;
|
||||
if (e instanceof Error) text = e.message;
|
||||
else {
|
||||
@@ -81,14 +91,29 @@ export function failure(e: unknown): Failure {
|
||||
text = text.toLowerCase();
|
||||
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
|
||||
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
|
||||
// Refused by the node's pool (stale, bad proof…): it will never be included.
|
||||
if (/"type":\s*"invalid"|invalid transaction/.test(text)) return 'chain';
|
||||
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';
|
||||
return f === 'cancelled' || f === 'funds' || f === 'account' || f === 'chain';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page may let a ballot go after a failure: nothing was signed yet
|
||||
* (`left` false — a node down before sending, a refusal, the window closed), or
|
||||
* the chain said no. Otherwise the transaction may still land: the ballot is
|
||||
* kept, and the server reads the indexer for it until its end.
|
||||
*/
|
||||
export function mayLetGo(f: Failure, left: boolean): boolean {
|
||||
return !left || nothingSent(f);
|
||||
}
|
||||
|
||||
/** How long a node may take to answer before the wallet is asked to sign. */
|
||||
const REACH_MS = 20_000;
|
||||
|
||||
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. */
|
||||
@@ -106,10 +131,23 @@ async function enable(name: string): Promise<Enabled> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests: one call to the fake node; `null` if it does not answer. */
|
||||
async function rpc(node: string, method: string, params: unknown[] = []): Promise<{ error?: unknown } | null> {
|
||||
const body = { jsonrpc: '2.0', id: 1, method, params };
|
||||
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!res?.ok) return null;
|
||||
return (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||
}
|
||||
|
||||
/** 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');
|
||||
// As polkadot-api does, the node is reached before the wallet is asked to sign.
|
||||
const health = await rpc(node, 'system_health');
|
||||
if (!health || health.error) throw new WalletError('node');
|
||||
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
|
||||
on('signing');
|
||||
let signature: string;
|
||||
@@ -118,11 +156,10 @@ async function sendTest(name: string, node: string, account: WalletAccount, tx:
|
||||
} 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('signed');
|
||||
const submitted = await rpc(node, 'author_submitExtrinsic', [testExtrinsic({ ...fields, signature })]);
|
||||
if (!submitted) throw new WalletError('node');
|
||||
if (submitted.error) throw new WalletError(failure(submitted.error));
|
||||
on('sent');
|
||||
}
|
||||
|
||||
@@ -140,6 +177,16 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
||||
try {
|
||||
const signer = ext.getAccounts().find((a) => a.address === account.address);
|
||||
if (!signer) throw new WalletError('account');
|
||||
// A node must answer before the wallet is asked to sign: otherwise nothing leaves, and the page says so.
|
||||
let wait: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
client.getFinalizedBlock(),
|
||||
new Promise((_, reject) => (wait = setTimeout(() => reject(new WalletError('node')), REACH_MS))),
|
||||
])
|
||||
.catch(() => {
|
||||
throw new WalletError('node');
|
||||
})
|
||||
.finally(() => clearTimeout(wait));
|
||||
// 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!({
|
||||
@@ -153,6 +200,8 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
|
||||
next: (ev) => {
|
||||
// Signed: from now on, the transaction may reach the chain.
|
||||
if (ev.type === 'created') on('signed');
|
||||
if (ev.type === 'broadcasted') {
|
||||
on('sent');
|
||||
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
|
||||
@@ -167,7 +216,7 @@ async function sendChain(name: string, nodes: string[], account: WalletAccount,
|
||||
if (ev.ok) {
|
||||
on('included');
|
||||
resolve();
|
||||
} else reject(new WalletError(failure(ev.dispatchError)));
|
||||
} else reject(new WalletError(failure(ev.dispatchError) === 'funds' ? 'funds' : 'chain'));
|
||||
}
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
afterNavigate(() => {
|
||||
drawer = false;
|
||||
if (session.ready) void session.refreshUnread();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -43,7 +44,8 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><Logo size={1.2} /></a>
|
||||
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
|
||||
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -157,6 +159,7 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.menu {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
@@ -167,5 +170,21 @@
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
}
|
||||
/* Unread messages, seen without opening the menu. */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -0.3rem;
|
||||
right: -0.3rem;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--ochre);
|
||||
color: var(--on-water);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
||||
// note promises everyone can check their own vote with its salt: the receipt is
|
||||
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
||||
// it is checked here, in the browser, without asking the server anything.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { BallotEntry, LangMap } from '$lib/types';
|
||||
|
||||
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
||||
const MAX_FILE = 1_000_000;
|
||||
|
||||
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
||||
onMount(() => {
|
||||
if (session.me) void data.load();
|
||||
});
|
||||
|
||||
let pasted = $state('');
|
||||
let check = $state<ReceiptCheck | null>(null);
|
||||
|
||||
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
||||
|
||||
function standing(b: BallotEntry): string {
|
||||
if (b.status === 'pending') return t('ballots.status.pending');
|
||||
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||
return t('ballots.status.expired');
|
||||
}
|
||||
|
||||
async function fromFile(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
pasted = '';
|
||||
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
||||
}
|
||||
|
||||
function fromText(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
check = checkReceipt(pasted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('ballots.title')}</h1>
|
||||
<p class="lead">{t('ballots.intro')}</p>
|
||||
|
||||
<section aria-labelledby="ballots-mine">
|
||||
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
||||
{#if !session.me}
|
||||
<p class="muted">
|
||||
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
<ul class="list">
|
||||
{#each data.value.ballots as b (b.id)}
|
||||
<li class="card block ballot">
|
||||
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||
<p class="state {b.status}">
|
||||
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||
</p>
|
||||
<code class="comment">{b.comment}</code>
|
||||
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="faint">{t('ballots.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ballots-verify">
|
||||
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
||||
<div class="card block">
|
||||
<p class="muted">{t('ballots.verify.intro')}</p>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.file')}</span>
|
||||
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
||||
</label>
|
||||
<form class="paste" onsubmit={fromText}>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.paste')}</span>
|
||||
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
||||
</label>
|
||||
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
||||
</form>
|
||||
<div class="result" aria-live="polite">
|
||||
{#if check?.kind === 'ok'}
|
||||
<p class="ok">{t('ballots.verify.ok')}</p>
|
||||
<code class="comment">{check.comment}</code>
|
||||
<ul class="lines">
|
||||
{#each check.lines as l (l.proposalId)}
|
||||
<li>
|
||||
{t('ballots.verify.line', { choice: t(`vote.choice.${l.choice}`), version: l.proposalId, revision: i18n.number(l.revision) })}
|
||||
{#if l.article || l.title}<span class="faint small unsealed">{t('ballots.verify.line.label', { label: [l.article, l.title].filter(Boolean).join(' · ') })}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="faint small">
|
||||
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
||||
</p>
|
||||
{:else if check?.kind === 'mismatch'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
||||
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
||||
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
||||
{:else if check?.kind === 'unreadable'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.list,
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.ballot {
|
||||
justify-items: start;
|
||||
}
|
||||
.state {
|
||||
font-weight: 750;
|
||||
}
|
||||
.state.confirmed {
|
||||
color: var(--moss);
|
||||
}
|
||||
.state.pending {
|
||||
color: var(--water);
|
||||
}
|
||||
.state.refused,
|
||||
.state.expired {
|
||||
color: var(--ochre);
|
||||
}
|
||||
.comment {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
textarea {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.paste {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.paste .field,
|
||||
.paste textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lines {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.unsealed {
|
||||
display: block;
|
||||
}
|
||||
.ok {
|
||||
font-weight: 800;
|
||||
color: var(--moss);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,10 +11,12 @@
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
import { sections } from '$lib/basket';
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { sections, votableIds } from '$lib/basket';
|
||||
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import { BasketState } from '$lib/picks.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
@@ -26,23 +28,30 @@
|
||||
$effect(() => {
|
||||
void doc.load(group, slug);
|
||||
});
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
|
||||
// The basket of this document, kept for the tab; versions no longer open to vote
|
||||
// (or held back by an objection) leave it.
|
||||
$effect(() => {
|
||||
const v = doc.view;
|
||||
if (!v) return;
|
||||
const known = new Set(sections(v.units, v.modules).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
basket.load(v.doc.id, known);
|
||||
basket.load(v.doc.id, votableIds(sections(v.units, v.modules)));
|
||||
});
|
||||
|
||||
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
|
||||
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
let prefill = $state<PanelState['prefill']>(null);
|
||||
const panel = $derived.by((): PanelState | null => {
|
||||
const q = page.url.searchParams;
|
||||
const tab = TABS[q.get('onglet') ?? ''];
|
||||
if (!tab) return null;
|
||||
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
|
||||
return {
|
||||
unitId: q.get('article'),
|
||||
proposalId: q.get('version'),
|
||||
tab,
|
||||
draftId: q.get('brouillon'),
|
||||
section: q.get('section'),
|
||||
prefill,
|
||||
objectionId: q.get('objection'),
|
||||
edit: q.get('modifier') === '1',
|
||||
};
|
||||
});
|
||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||
const vote = $derived.by(() => {
|
||||
@@ -52,16 +61,7 @@
|
||||
|
||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||
prefill = nextPanel?.prefill ?? null;
|
||||
const q = new URLSearchParams();
|
||||
if (nextVote) q.set('vote', nextVote);
|
||||
if (nextPanel) {
|
||||
q.set('onglet', TAB_NAMES[nextPanel.tab]);
|
||||
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
|
||||
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
|
||||
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
|
||||
if (nextPanel.section) q.set('section', nextPanel.section);
|
||||
}
|
||||
const search = q.toString();
|
||||
const search = panelSearch(nextPanel, nextVote);
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
|
||||
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
|
||||
noScroll: true,
|
||||
@@ -130,6 +130,7 @@
|
||||
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' })}
|
||||
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
|
||||
onsign={sign}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
// The box « Messages » of trustinWallet, in the application (2026-10-31, lot F,
|
||||
// ADR 0010): what waits for a gesture of the person — an objection to answer,
|
||||
// to lift or to reiterate, a typo fix to validate or refuse —, each with its
|
||||
// deadline. A message dealt with offers no gesture any more. No mail.
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import DiffText from '$lib/components/DiffText.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { condense } from '$lib/diff';
|
||||
import { panelSearch } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Message } from '$lib/types';
|
||||
import { errorText } from '$lib/vote';
|
||||
|
||||
const box = new Remote(() => api.get<{ unread: number; messages: Message[] }>('/messages'));
|
||||
onMount(() => {
|
||||
if (session.me) void box.load();
|
||||
});
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
const unread = $derived(box.value?.unread ?? 0);
|
||||
|
||||
async function reload() {
|
||||
await box.load();
|
||||
session.unread = box.value?.unread ?? session.unread;
|
||||
}
|
||||
|
||||
async function read(m: Message) {
|
||||
if (m.read) return;
|
||||
const r = await api.post<{ unread: number }>(`/messages/${m.id}/read`).catch(() => null);
|
||||
if (r) session.unread = r.unread;
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
await api.post('/messages/read').catch(() => null);
|
||||
await reload();
|
||||
}
|
||||
|
||||
/** The text of a message, in the language of the reader, from its subject. */
|
||||
function textOf(m: Message): string {
|
||||
const o = m.objection;
|
||||
const fix = m.typo;
|
||||
if (o) {
|
||||
const held = o.heldVersion?.title ?? '';
|
||||
if (m.kind === 'objection_raised') return t(`messages.objection_raised.${o.kind}`, { name: o.objector ?? '', held, other: o.other.title ?? '' });
|
||||
if (m.kind === 'objection_reiterated') return t('messages.objection_reiterated', { name: o.objector ?? '', held });
|
||||
return t('messages.objection_answered', { held });
|
||||
}
|
||||
if (fix) return t(fix.answerer === 'admin' ? 'messages.typo_pending.admin' : 'messages.typo_pending.author', { name: fix.writer ?? '', title: fix.version?.title ?? '' });
|
||||
return '';
|
||||
}
|
||||
|
||||
function outcomeOf(m: Message): string {
|
||||
const o = m.outcome;
|
||||
if (!o) return t('messages.outcome.done');
|
||||
if (o === 'deadline') return t(m.topic === 'typo' ? 'messages.outcome.deadline.typo' : 'messages.outcome.deadline.objection');
|
||||
const key = `messages.outcome.${o}`;
|
||||
return key in OUTCOMES ? t(key as Key) : t('messages.outcome.done');
|
||||
}
|
||||
const OUTCOMES: Record<string, true> = Object.fromEntries(
|
||||
['answered', 'reiterated', 'lifted', 'admin', 'withdrawn', 'applied', 'refused', 'stale'].map((k) => [`messages.outcome.${k}`, true]),
|
||||
);
|
||||
|
||||
/** The version a message is about, in the reader: its details. */
|
||||
function hrefOf(m: Message): string {
|
||||
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
|
||||
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
|
||||
if (!v) return base;
|
||||
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
|
||||
}
|
||||
|
||||
async function gesture(m: Message, g: Message['gestures'][number]) {
|
||||
error = '';
|
||||
if (g === 'respond' && m.objection) {
|
||||
const panel = answerPanel(m.objection, session.me?.id ?? null);
|
||||
void read(m);
|
||||
await goto(`/g/${m.doc.groupSlug}/docs/${m.doc.slug}?${panelSearch(panel, null)}`);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if ((g === 'lift' || g === 'reiterate') && m.objection) {
|
||||
await api.post(`/objections/${m.objection.id}/${g}`);
|
||||
toasts.show(t(g === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
||||
} else if ((g === 'validate' || g === 'refuse') && m.typo) {
|
||||
await api.post(`/typos/${m.typo.id}/${g}`);
|
||||
toasts.show(t(g === 'validate' ? 'typo.validated' : 'typo.refused'));
|
||||
}
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
await reload();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function label(m: Message, g: Message['gestures'][number]): string {
|
||||
switch (g) {
|
||||
case 'respond':
|
||||
return m.objection ? t(m.objection.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit') : t('messages.respond');
|
||||
case 'lift':
|
||||
return t('objection.lift');
|
||||
case 'reiterate':
|
||||
return t('objection.reiterate');
|
||||
case 'validate':
|
||||
return t('typo.validate');
|
||||
case 'refuse':
|
||||
return t('typo.refuse');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('messages.title')} · libreDecision</title></svelte:head>
|
||||
|
||||
{#if !session.me}
|
||||
<Guard code="unauthenticated" />
|
||||
{:else if box.error}
|
||||
<Guard code={box.error} onretry={() => box.load()} />
|
||||
{:else}
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<h1>{t('messages.title')}</h1>
|
||||
<p class="muted">{t('messages.intro')}</p>
|
||||
{#if unread}
|
||||
<button class="btn small" type="button" onclick={readAll}>{t('messages.readAll')}</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
|
||||
{#if box.value}
|
||||
<ul class="box">
|
||||
{#each box.value.messages as m (m.id)}
|
||||
<li class="message" class:unread={!m.read} class:done={m.done}>
|
||||
<p class="top">
|
||||
<span class="tag" class:ochre={m.topic === 'objection'} class:water={m.topic === 'typo'}>{t(`messages.topic.${m.topic}`)}</span>
|
||||
<span class="doc">{m.doc.title[i18n.lang] ?? m.doc.title.fr ?? ''}</span>
|
||||
<span class="faint">{i18n.date(m.createdAt)}</span>
|
||||
{#if !m.read}<span class="new">{t('messages.unread')}</span>{/if}
|
||||
</p>
|
||||
<p class="text">{textOf(m)}</p>
|
||||
{#if m.objection?.reason && m.kind !== 'objection_answered'}<p class="reason">{m.objection.reason}</p>{/if}
|
||||
{#if m.typo}<DiffText parts={condense(m.typo.diff)} />{/if}
|
||||
{#if m.done}
|
||||
<p class="outcome">{outcomeOf(m)}</p>
|
||||
{:else if m.dueAt}
|
||||
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if !m.done}
|
||||
{#each m.gestures as g (g)}
|
||||
<button class="btn small" class:primary={g === 'respond' || g === 'lift' || g === 'validate'} type="button" disabled={busy} onclick={() => gesture(m, g)}>{label(m, g)}</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<a class="linkish" href={hrefOf(m)} onclick={() => void read(m)}>{t('messages.open')}</a>
|
||||
{#if !m.read}<button class="linkish" type="button" onclick={() => read(m)}>{t('messages.read')}</button>{/if}
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty muted">{t('messages.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="faint">{t('common.loading')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 5rem;
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.head .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.5rem);
|
||||
}
|
||||
.box {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.message.unread {
|
||||
background: color-mix(in srgb, var(--surface) 88%, var(--water));
|
||||
}
|
||||
.message.done {
|
||||
opacity: 0.78;
|
||||
box-shadow: none;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.6rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.doc {
|
||||
font-weight: 750;
|
||||
}
|
||||
.new {
|
||||
margin-left: auto;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reason {
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.due {
|
||||
font-weight: 750;
|
||||
color: var(--ochre);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.outcome {
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.8rem;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.2rem 0;
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
|
||||
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket';
|
||||
import type { Proposal, Unit } from '../../src/lib/types';
|
||||
|
||||
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||
@@ -7,7 +7,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
||||
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: {},
|
||||
blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -92,4 +92,26 @@ describe('the basket of a vote', () => {
|
||||
]);
|
||||
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
|
||||
});
|
||||
|
||||
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => {
|
||||
// E5 has the text in force and a rival held back; E6 is a new article, held back.
|
||||
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
|
||||
prop('e5r', 'E5', { blocked: true, votable: false }),
|
||||
]);
|
||||
const e6 = unit('E6', 'technique', null, [prop('e6', 'E6', { blocked: true, votable: false })]);
|
||||
const s = sections([e5, e6]);
|
||||
const [slotE5, slotE6] = s[0]?.slots ?? [];
|
||||
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
|
||||
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
|
||||
// A held article stays in sight, with nothing to choose.
|
||||
expect(slotE6?.versions).toEqual([]);
|
||||
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
|
||||
const all = s.flatMap((x) => x.slots);
|
||||
// The held rival does not compete now: the text in force is the only choice.
|
||||
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
|
||||
expect(waiting(all, {})).toEqual([]);
|
||||
// A choice kept from before the objection is not sent.
|
||||
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]);
|
||||
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// What the end-to-end tests import is declared: a package found only as the
|
||||
// dependency of another (as @noble/hashes under polkadot-api) may vanish with it.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { builtinModules } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = join(__dirname, '../..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
|
||||
|
||||
/** `@scope/name/sub.js` → `@scope/name`; `name/sub` → `name`. */
|
||||
const packageOf = (spec: string) => spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/');
|
||||
|
||||
describe('the end-to-end tests', () => {
|
||||
it('import only declared packages', () => {
|
||||
const dir = join(root, 'e2e');
|
||||
for (const file of readdirSync(dir).filter((f) => /\.(mjs|ts)$/.test(f))) {
|
||||
const text = readFileSync(join(dir, file), 'utf8');
|
||||
for (const [, spec] of text.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
if (!spec || spec.startsWith('.') || spec.startsWith('node:') || builtinModules.includes(spec)) continue;
|
||||
expect(declared.has(packageOf(spec)), `${file}: ${spec}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { condense } from '../../src/lib/diff';
|
||||
import type { DiffPart } from '../../src/lib/types';
|
||||
|
||||
const text = (parts: DiffPart[]) => parts.map((p) => (p.op === 'same' ? p.text : `[${p.op === 'added' ? '+' : '-'}${p.text}]`)).join('');
|
||||
|
||||
describe('a typo fix, told around its change', () => {
|
||||
it('keeps a few words on each side of a change, cut between words', () => {
|
||||
const parts: DiffPart[] = [
|
||||
{ op: 'same', text: "Raison d'être\nDerrière une clé il n'y a qu'un seul être humain vivant. C" },
|
||||
{ op: 'removed', text: "'" },
|
||||
{ op: 'added', text: '’' },
|
||||
{ op: 'same', text: "est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur." },
|
||||
];
|
||||
expect(text(condense(parts, 24))).toBe("… être humain vivant. C[-'][+’]est le seul et unique …");
|
||||
});
|
||||
|
||||
it('two changes far apart: the middle is cut; close ones stay together', () => {
|
||||
const far: DiffPart[] = [
|
||||
{ op: 'same', text: 'Je m' },
|
||||
{ op: 'removed', text: "'" },
|
||||
{ op: 'added', text: '’' },
|
||||
{ op: 'same', text: 'engage à vérifier la clé publique avec la personne, en sa présence, avant toute certification de son compte, et à n' },
|
||||
{ op: 'removed', text: "'" },
|
||||
{ op: 'added', text: '’' },
|
||||
{ op: 'same', text: 'en rien faire sinon.' },
|
||||
];
|
||||
expect(text(condense(far, 20))).toBe("Je m[-'][+’]engage à vérifier la … son compte, et à n[-'][+’]en rien faire sinon.");
|
||||
const near: DiffPart[] = [
|
||||
{ op: 'same', text: 'Je m' },
|
||||
{ op: 'removed', text: "'" },
|
||||
{ op: 'added', text: '’' },
|
||||
{ op: 'same', text: 'engage à n' },
|
||||
{ op: 'removed', text: "'" },
|
||||
{ op: 'added', text: '’' },
|
||||
{ op: 'same', text: 'utiliser qu' },
|
||||
];
|
||||
expect(condense(near, 20)).toEqual(near);
|
||||
});
|
||||
|
||||
it('nothing changed: the text as it is', () => {
|
||||
const same: DiffPart[] = [{ op: 'same', text: 'Un texte.' }];
|
||||
expect(condense(same)).toEqual(same);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { answerPanel, holding } from '../../src/lib/objections';
|
||||
import type { Objection, VersionRef } from '../../src/lib/types';
|
||||
|
||||
const ref = (id: string, over: Partial<VersionRef> = {}): VersionRef => ({
|
||||
id, title: id, status: 'open', unitId: `u-${id}`, code: id.toUpperCase(), author: null, authorId: null, ...over,
|
||||
});
|
||||
|
||||
function objection(over: Partial<Objection> = {}): Objection {
|
||||
return {
|
||||
id: 'o1', kind: 'redit', reason: 'Ces deux textes disent la même chose.', objector: 'Yvv', objectorInvited: false,
|
||||
mine: false, at: '', phase: 'author', cycle: 1, dueAt: null, answer: null, answerId: null, answeredAt: null,
|
||||
lift: null, liftedAt: null, heldVersion: ref('k1', { author: 'Chiara', authorId: 'dev:chiara' }),
|
||||
other: ref('e2', { status: 'in_force' }), iAnswer: true, canLift: false, canReiterate: false, held: true, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('answering an objection', () => {
|
||||
it('a repetition: a proposal linked to it, on the article repeated, from its text', () => {
|
||||
expect(answerPanel(objection(), 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-e2', proposalId: 'e2', objectionId: 'o1' });
|
||||
});
|
||||
|
||||
it('a contradiction: one rewords one’s own version in place', () => {
|
||||
expect(answerPanel(objection({ kind: 'contredit' }), 'dev:chiara')).toEqual({ tab: 'details', unitId: 'u-k1', proposalId: 'k1', edit: true });
|
||||
});
|
||||
|
||||
it('a contradiction on a seeded version: a linked proposal on its article', () => {
|
||||
const o = objection({ kind: 'contredit', heldVersion: ref('k1') });
|
||||
expect(answerPanel(o, 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-k1', proposalId: 'k1', objectionId: 'o1' });
|
||||
});
|
||||
|
||||
it('nothing held back, nothing to answer; a lifted objection holds nothing', () => {
|
||||
expect(answerPanel(objection({ heldVersion: null }), 'dev:chiara')).toBeNull();
|
||||
const list = [objection(), objection({ id: 'o2', phase: 'lifted' }), objection({ id: 'o3', held: false })];
|
||||
expect(holding(list).map((o) => o.id)).toEqual(['o1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// The receipt of a vote on the Ğ1 chain, checked in the browser: the same
|
||||
// canonical text and the same seal as the engine (crates/ld-engine/src/ballot.rs,
|
||||
// whose vectors were computed apart with Python's hashlib.blake2b).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canonical, checkReceipt, seal } from '../../src/lib/receipt';
|
||||
|
||||
const SALT = '00112233445566778899aabbccddeeff';
|
||||
const LINES = [
|
||||
{ proposalId: 'p2', revision: 1, choice: 'against' as const },
|
||||
{ proposalId: 'p1', revision: 3, choice: 'for' as const },
|
||||
];
|
||||
|
||||
describe('the sealed ballot', () => {
|
||||
it('reads as the engine writes it, the lines in the order of their versions', () => {
|
||||
expect(canonical('d0c', null, LINES, SALT)).toBe(
|
||||
'ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('seals to the remark of the engine', () => {
|
||||
const s = seal('d0c', null, LINES, SALT);
|
||||
expect(s?.digest).toBe('20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
|
||||
expect(s?.comment).toBe('ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
|
||||
expect(s?.remarkHash).toBe('0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3');
|
||||
const bound = seal('d0c', 'g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv', [{ proposalId: 'p1', revision: 3, choice: 'none' }], 'ffeeddccbbaa99887766554433221100');
|
||||
expect(bound?.digest).toBe('6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e');
|
||||
expect(bound?.remarkHash).toBe('0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4');
|
||||
});
|
||||
|
||||
it('what cannot be sealed', () => {
|
||||
expect(seal('d0c', null, [], SALT)).toBeNull();
|
||||
expect(seal('d0c', null, [LINES[0]!, { ...LINES[0]!, revision: 2 }], SALT)).toBeNull();
|
||||
expect(seal('d 0c', null, LINES, SALT)).toBeNull();
|
||||
expect(seal('d0c', null, LINES, SALT.toUpperCase())).toBeNull();
|
||||
expect(seal('d0c', null, LINES, '0011')).toBeNull();
|
||||
expect(seal('d0c', null, [{ proposalId: 'p1', revision: 1.5, choice: 'for' }], SALT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a receipt', () => {
|
||||
const receipt = {
|
||||
format: 'ld1',
|
||||
doc: 'd0c',
|
||||
author: null,
|
||||
lines: LINES.map((l) => ({ ...l, article: 'E1', title: 'Unicité du compte' })),
|
||||
salt: SALT,
|
||||
comment: 'ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340',
|
||||
block: 3_400_000,
|
||||
};
|
||||
|
||||
it('holds when its lines and its salt give its comment', () => {
|
||||
expect(checkReceipt(JSON.stringify(receipt))).toEqual({
|
||||
kind: 'ok',
|
||||
comment: receipt.comment,
|
||||
remarkHash: '0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3',
|
||||
block: 3_400_000,
|
||||
lines: receipt.lines,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hold once a choice is changed', () => {
|
||||
const changed = { ...receipt, lines: [{ ...receipt.lines[0]!, choice: 'for' }, receipt.lines[1]!] };
|
||||
const check = checkReceipt(JSON.stringify(changed));
|
||||
expect(check.kind).toBe('mismatch');
|
||||
if (check.kind === 'mismatch') expect(check.computed).not.toBe(receipt.comment);
|
||||
});
|
||||
|
||||
it('what is not a receipt', () => {
|
||||
for (const text of ['', 'nope', '[]', '{}', JSON.stringify({ ...receipt, format: 'ld2' }), JSON.stringify({ ...receipt, lines: 'x' })]) {
|
||||
expect(checkReceipt(text).kind, text).toBe('unreadable');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
|
||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
||||
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InvalidTxError } from 'polkadot-api';
|
||||
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
|
||||
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
|
||||
import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected';
|
||||
|
||||
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
||||
const COMMENT = `ld1:${'ab'.repeat(32)}`;
|
||||
@@ -30,6 +31,28 @@ describe('wallets of the page', () => {
|
||||
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');
|
||||
// Refused by the pool of the node: it will never be included.
|
||||
expect(failure({ type: 'Invalid', value: { type: 'Stale' } })).toBe('chain');
|
||||
expect(failure({ code: 1010, message: 'Invalid Transaction' })).toBe('chain');
|
||||
});
|
||||
|
||||
it('a refusal of the node, as polkadot-api raises it, is the chain saying no (relecture of 7ffa4fd)', () => {
|
||||
// The real class: its message is indented JSON (`"type": "Invalid"`, with a space).
|
||||
for (const reason of ['Stale', 'AncientBirthBlock', 'BadProof', 'ExhaustsResources', 'Future']) {
|
||||
const e = new InvalidTxError({ type: 'Invalid', value: { type: reason } });
|
||||
expect(failure(e), reason).toBe('chain');
|
||||
expect(mayLetGo(failure(e), true), reason).toBe(true);
|
||||
}
|
||||
expect(failure(new InvalidTxError({ type: 'Invalid', value: { type: 'Payment' } }))).toBe('funds');
|
||||
expect(failure(new InvalidTxError({ type: 'Unknown', value: { type: 'CannotLookup' } }))).toBe('chain');
|
||||
});
|
||||
|
||||
it('a ballot is let go when nothing left the page, or when the chain said no — kept when it may still land', () => {
|
||||
// Before the signature (the node down before sending, a refusal, the window closed): nothing left.
|
||||
for (const f of ['node', 'wallet', 'cancelled', 'funds', 'account', 'chain'] as const) expect(mayLetGo(f, false), f).toBe(true);
|
||||
// Signed and handed to the node: it may reach the chain, unless the chain refused it.
|
||||
for (const f of ['funds', 'account', 'chain'] as const) expect(mayLetGo(f, true), f).toBe(true);
|
||||
for (const f of ['node', 'wallet'] as const) expect(mayLetGo(f, true), f).toBe(false);
|
||||
});
|
||||
|
||||
it('in tests, the fake node receives what was signed, readable', () => {
|
||||
|
||||
Reference in New Issue
Block a user