Merge branch 'chantier/acte-certification' into lot/u-lecteur

This commit is contained in:
Yvv
2026-11-03 07:34:49 +01:00
87 changed files with 9932 additions and 895 deletions
+113 -2
View File
@@ -1,4 +1,4 @@
import type { Browser, Page } from '@playwright/test';
import { expect, type Browser, type Page } from '@playwright/test';
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
@@ -75,6 +75,11 @@ export async function signed(page: Page): Promise<{ address: string; dest: strin
);
}
/** The line of an article in « Propositions en cours », by its title alone (an excerpt may name it). */
export function deepRow(page: Page, title: string) {
return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) });
}
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
export async function shot(page: Page, name: string) {
const dir = process.env.LD_SHOTS;
@@ -86,13 +91,48 @@ 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 }[] }[];
units: {
id: string;
code: string;
inForce: { id: string; title: string } | null;
proposals: { id: string; title: string; seedProposal?: 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 { id: d.doc.id, units: d.units };
}
/**
* The three seed proposals (ADR 0011: I1, E1 and E2, not in force at the start) brought into
* force in the test group, as the vote does it: voices for until each enters, then those
* voices withdrawn. A version in force stays so (a permanent vote: only a rival displaces
* it), at zero votes, as the controls written before the seed proposals found it. Never in
* Monnaie libre, whose vote goes through the chain; nothing done twice.
*/
export async function seedsInForce(browser: Browser) {
const voices = [await person(browser, 'Amorce A'), await person(browser, 'Amorce B')];
for (const code of ['I1', 'E1', 'E2']) {
const unit = (await testDoc(voices[0]!)).units.find((u) => u.code === code);
const seed = unit?.proposals.find((p) => p.seedProposal);
if (!unit || unit.inForce || !seed) continue;
const cast: Page[] = [];
for (const page of voices) {
const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: 'for' }, headers: CSRF });
if (!r.ok()) throw new Error(`vote ${r.status()} ${await r.text()}`);
cast.push(page);
if ((await r.json()).entered?.length) break;
}
for (const page of cast) {
const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: null }, headers: CSRF });
if (!r.ok()) throw new Error(`withdraw ${r.status()} ${await r.text()}`);
}
const now = (await testDoc(voices[0]!)).units.find((u) => u.code === code);
if (now?.inForce?.id !== seed.id) throw new Error(`${code} did not enter into force`);
}
for (const page of voices) await page.context().close();
}
/** 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);
@@ -138,3 +178,74 @@ export async function chain(page: Page, docPath = DOC) {
(await page.request.post(`${base}/control/${what}`)).json(),
};
}
/**
* The welcome (Yvv, 2026-11-02), in French: the keystone arch shelters the three points, the
* toolbox first, each centred under the keystone in the opening; then the title, centred, on
* two lines broken after « possible ». Nothing tilted, nothing past the edges of the screen.
* Measured once the arch has settled (its entrance done).
*/
export async function expectWelcome(page: Page) {
await expect(page.locator('.vault .leitmotiv li')).toHaveText(['boîte à outils', 'contexte et cadrage', 'processus et modalités']);
const h1 = page.getByRole('heading', { level: 1 });
await expect(h1).toHaveText('Rendre possible la décision collective');
await expect(h1.locator('.line')).toHaveText(['Rendre possible', 'la décision collective']);
const m = await page.evaluate(async () => {
const intro = document.querySelector('.intro')!;
await Promise.all(intro.getAnimations({ subtree: true }).map((a) => a.finished));
const text = (el: Element) => {
const r = document.createRange();
r.selectNodeContents(el);
return { box: r.getBoundingClientRect(), rects: r.getClientRects().length };
};
const v = document.querySelector('.vault')!.getBoundingClientRect();
// The arch in its own units: 92 wide (x from −46), the top of the keystone at y = −40.
const u = v.width / 92;
const at = (x: number, y: number) => ({ x: v.left + (x + 46) * u, y: v.top + (y + 40) * u });
const points = [...document.querySelectorAll('.leitmotiv li')].map((li) => {
const { box, rects } = text(li);
// Back into the arch's units, from its axis and its springing line (y = 0).
return { rects, left: (box.left - at(0, 0).x) / u, right: (box.right - at(0, 0).x) / u, top: (box.top - at(0, 0).y) / u, bottom: (box.bottom - at(0, 0).y) / u };
});
const h = document.querySelector('h1')!.getBoundingClientRect();
const lines = [...document.querySelectorAll('h1 .line')].map((l) => ({ ...text(l), block: l.getBoundingClientRect() }));
const tilted = [document.querySelector('.vault svg')!, ...document.querySelectorAll('.vault svg path')]
.flatMap((n) => {
const out: string[] = [];
for (let e: Element | null = n; e && e !== document.body; e = e.parentElement) {
const cs = getComputedStyle(e);
const flat = cs.transform === 'none' || /^matrix\(1, 0, 0, 1, /.test(cs.transform);
if (!flat || (cs.rotate && cs.rotate !== 'none')) out.push(`${e.tagName} ${cs.transform} ${cs.rotate}`);
}
return out;
});
return {
overflow: document.documentElement.scrollWidth - innerWidth,
axis: v.left + v.width / 2,
vaultBottom: v.bottom,
points,
title: { top: h.top, axis: h.left + h.width / 2 },
lines: lines.map(({ box, rects, block }) => ({ rects, top: block.top, bottom: block.bottom, axis: box.left + box.width / 2 })),
tilted,
};
});
expect(m.overflow, 'nothing past the edge of the screen').toBeLessThanOrEqual(0);
expect(m.tilted, 'the arch is never tilted').toEqual([]);
// The three points: one line each, top to bottom, centred on the axis, under the keystone (its
// foot at y = −19), between the imposts (x = ±19) and above the slab (y = 15.6); a line that
// rises above the springing line stays clear of the voussoirs, closer in there (x = ±17).
for (const [i, p] of m.points.entries()) {
expect(p.rects, `point ${i}: one line`).toBe(1);
expect(Math.abs((p.left + p.right) / 2), `point ${i}: centred`).toBeLessThan(0.3);
expect(p.top, `point ${i}: under the keystone`).toBeGreaterThan(-19);
expect(p.bottom, `point ${i}: above the slab`).toBeLessThan(15.6);
expect(p.right, `point ${i}: in the opening`).toBeLessThan(p.top < 0 ? 17 : 19);
if (i > 0) expect(p.top, `point ${i}: under the one before`).toBeGreaterThan(m.points[i - 1]!.top);
}
// The title: under the arch, centred on its axis, two lines of one line each.
expect(m.title.top).toBeGreaterThanOrEqual(m.vaultBottom);
expect(Math.abs(m.title.axis - m.axis)).toBeLessThan(1);
expect(m.lines.map((l) => l.rects)).toEqual([1, 1]);
expect(m.lines[1]!.top).toBeGreaterThanOrEqual(m.lines[0]!.bottom - 1);
for (const l of m.lines) expect(Math.abs(l.axis - m.axis)).toBeLessThan(1);
}
+13 -7
View File
@@ -1,5 +1,5 @@
import { expect, test, type Locator } from '@playwright/test';
import { DOC, shot, signIn } from './helpers';
import { DOC, expectWelcome, shot, signIn } from './helpers';
/** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */
function contrast(a: string, b: string): number {
@@ -25,13 +25,19 @@ async function backdrop(el: Locator): Promise<string> {
});
}
test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => {
test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/');
const brand = page.locator('header.topbar a.brand');
await expect(brand).toHaveAccessibleName('libreDecision');
await expect(brand.locator('svg')).toBeVisible();
await expect(page.locator('a.root')).toContainText('Monnaie libre');
// The welcome on this phone, then on a narrow one (360): the arch still shelters the three points.
await expectWelcome(page);
const size = page.viewportSize();
await page.setViewportSize({ width: 360, height: 780 });
await expectWelcome(page);
if (size) await page.setViewportSize(size);
await page.goto(DOC);
await page.getByRole('button', { name: 'Menu' }).click();
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
@@ -73,11 +79,11 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier,
expect(mark.shadow).toContain('0px 0px 0px 2px');
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
// The vote unfolds under the article, on a phone too.
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
// The vote unfolds under the article, on a phone too (a seed proposal, ADR 0011: nothing is
// in force at the start, its vote unfolds in the list of proposals).
await page.locator('ul.deep button.row').first().click();
const fold = page.locator('ul.deep section.fold');
await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
+1 -1
View File
@@ -190,7 +190,7 @@ test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que
await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible();
// Then what the group asks, the group as subject.
await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible();
await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude.")).toBeVisible();
await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude.")).toBeVisible();
await shot(page, 'c27-participation-bornee');
});
+5 -1
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers';
import { advanceClock, person, propose, seedsInForce, 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,
@@ -10,6 +10,10 @@ import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpe
const DAY = 86_400;
// An objection names an article in force, a typo of C31 a text in force: the seed proposals
// I1, E1 and E2 (ADR 0011) are voted into force in the test group first.
test.beforeAll(async ({ browser }) => seedsInForce(browser));
/** 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' });
+85 -60
View File
@@ -1,10 +1,13 @@
import { expect, test } from '@playwright/test';
import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers';
import { expect, test, type Page } from '@playwright/test';
import { BOB, deepRow, DOC, expectWelcome, person, seedsInForce, signIn, TEST_DOC, testDoc, wallet } from './helpers';
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
// The seed proposals (ADR 0011) are not in force at the start: the controls that read a
// text in force find I1, E1 and E2 brought in by the vote in the test group.
test.beforeAll(async ({ browser }) => seedsInForce(browser));
test('C1 — l’accueil sans session : la voûte abrite les trois points, le titre centré en deux lignes, la connexion, le menu complet', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']);
await expectWelcome(page);
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
@@ -34,38 +37,41 @@ test('C2 — l’accueil avec session : le choix du groupe, ceux de la même cl
await expect(page).toHaveURL((u) => u.pathname === '/');
});
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements sont des Propositions seed', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
const units = page.locator('section.text article.unit');
await expect(units).toHaveCount(3);
await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être");
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
// The seed mark, with the real number of votes beside it (none yet on these three).
await expect(units.getByText('seed a minima · 0 vote')).toHaveCount(3);
await expect(units.getByTitle("Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.")).toHaveCount(3);
// Nothing in force (Yvv, 2026-11-02): said once, above the orders of the sections.
await expect(page.locator('section.text article.unit')).toHaveCount(0);
await expect(page.getByText("Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.")).toBeVisible();
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
// One line per article: E3 and E7 hold their two variants inside.
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
// One line per article: the 33, E3 and E7 holding their two variants inside.
await expect(page.locator('ul.deep button.row')).toHaveCount(33);
// The three seed proposals, marked, at the low level they start at.
const seeds = page.locator('ul.deep button.row', { has: page.locator('.tag', { hasText: 'Proposition seed' }) });
await expect(seeds.locator('.title')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']);
await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(3);
for (const row of await seeds.all()) await expect(row).toContainText('0 pour · 0 contre');
for (const row of await seeds.all()) await expect(row).toContainText('inertie 2 / 5 · basse');
});
test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => {
// In the test group, where the Raison d'être was voted into force (beforeAll).
await signIn(page, 'Chiara');
await page.goto(DOC);
await page.goto(TEST_DOC);
const first = page.locator('section.text article.unit').first();
await expect(first.locator('h2')).toHaveText("Raison d'être");
await first.hover();
const toggle = first.getByRole('button', { name: 'Le vote' });
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible();
await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible();
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
await fold.getByText('La formule du seuil').click();
await expect(fold.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
// The level of the article sets M and G: the Raison d'être, a seed proposal, starts at 2 / 5.
await expect(fold.getByText('M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
await expect(page.locator('aside.panel')).toHaveCount(0);
@@ -77,18 +83,28 @@ test('C4 — les actions sont contextuelles, le vote se déplie sous l’article
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
const yvv = await person(browser, 'Yvv');
const chiara = await person(browser, 'Chiara');
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
// Every dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1, one
// voice may suffice (ADR 0007); else the second one brings it in.
const title = 'Les deux garanties réciproques';
const news = `« ${title} » est entré en vigueur.`;
const inForce = async (page: Page) => (await testDoc(page)).units.some((u) => u.inForce?.title === title);
let voices = 0;
let last: Page | undefined;
for (const name of ['Yvv', 'Chiara']) {
last = await person(browser, name);
await last.goto(TEST_DOC);
await last.locator('ul.deep button.row', { hasText: title }).click();
await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(last.getByText('Vote enregistré.')).toBeVisible();
voices += 1;
if (await inForce(last)) break;
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
if (!last) throw new Error('nobody voted');
await expect(last.getByText(news)).toBeVisible();
// With the three seed proposals voted in (beforeAll).
await expect(last.locator('section.text article.unit')).toHaveCount(4);
// In force now: its vote stays unfolded, under the text.
await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre');
await expect(last.locator('section.text section.fold')).toContainText(`${voices} pour · 0 contre`);
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
@@ -148,8 +164,9 @@ test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => {
await page.getByRole('radio', { name: 'Si tout passe' }).click();
await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible();
await expect(page.locator('section.text article.unit')).toHaveCount(33);
// Nothing in force in Monnaie libre, and nothing well rated yet (no « pour » there so far).
await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click();
await expect(page.locator('section.text article.unit')).toHaveCount(3);
await expect(page.locator('section.text article.unit')).toHaveCount(0);
});
test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => {
@@ -229,9 +246,11 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
});
test('C18 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
test('C18 — la Raison d’être ouvre le texte, en trois langues', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
// A seed proposal, not in force: it opens the text « if everything passes ».
await page.getByRole('radio', { name: 'Si tout passe' }).click();
const first = page.locator('section.text article.unit').first();
await expect(first.locator('h2')).toHaveText("Raison d'être");
// The first sentence in italics, then two paragraphs.
@@ -264,7 +283,8 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
// A version unfolds its vote below it, as any proposal.
await versions.nth(1).click();
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
// Level 3, the quorum 50 (ADR 0013): 39 « pour » at no vote, the voters missing counted as « non ».
await expect(page.locator('section.fold').getByText('Seuil : 39 voix pour')).toBeVisible();
await expect(page).toHaveURL(/vote=/);
await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false');
@@ -280,35 +300,40 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const panel = page.locator('aside.panel');
await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible();
await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible();
await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible();
// No vote yet: the reference, twice C.
await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible();
// No vote yet: the reference, twice the reference quorum (ADR 0013).
await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois 50, le quorum de référence : la participation de référence/)).toBeVisible();
const rows = panel.locator('table tbody tr');
await expect(rows).toHaveCount(5);
await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]);
await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']);
await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']);
// At 100 voters: C plus each level's share of the 50 votes beyond C.
// The quorum of each level (Yvv: « 12 · 12 · 50 · 50 · 50 »).
const quorums = rows.locator('td:nth-child(5)');
await expect(quorums).toHaveText(['12', '12', '50', '50', '50']);
// At 100 voters, above every quorum: each level's share of the 100.
const required = panel.locator('table tbody td.required');
await expect(required).toHaveText(['80', '84', '88', '92', '95']);
await expect(required).toHaveText(['60', '67', '75', '83', '90']);
await expect(panel.getByText("Voix pour : à 100 votants, comme d'habitude.")).toBeVisible();
await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).toBeVisible();
await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude.")).toBeVisible();
await expect(panel.getByText(/^Quorum : en dessous, les votants qui manquent comptent comme « non »\./)).toBeVisible();
await page.keyboard.press('Escape');
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
// From the vote of an article: Unicité du compte, a seed proposal, is at 2 / 5, its row is marked.
await deepRow(page, 'Unicité du compte').click();
const e1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('2');
await expect(panel.locator('tr.current th')).toContainText('cet article');
await expect(required).toHaveText(['50', '50', '50', '50', '50']);
// No vote: each level on its quorum, the voters missing counted as « non ».
await expect(quorums).toHaveText(['12', '12', '50', '50', '50']);
await expect(required).toHaveText(['8', '9', '39', '43', '46']);
await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible();
await page.keyboard.press('Escape');
// The five levels to choose from, in the test group; the change is voted at the article's level.
// The five levels to choose from, in the test group (Réciprocité voted into force there,
// beforeAll); the change is voted at the article's level.
await page.goto(TEST_DOC);
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
@@ -317,25 +342,25 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
// The current level is reachable — one stop in the tab order — and never chosen.
await expect(levels.nth(3)).toHaveAttribute('aria-disabled', 'true');
await expect(levels.nth(3)).toContainText('actuelle');
await expect(levels.nth(1)).toHaveAttribute('aria-disabled', 'true');
await expect(levels.nth(1)).toContainText('actuelle');
await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1);
await expect(levels.nth(3)).toHaveAttribute('tabindex', '0');
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await expect(levels.nth(1)).toHaveAttribute('tabindex', '0');
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 2 / 5 · basse.")).toBeVisible();
// A click on it (Playwright refuses an aria-disabled target: the event is sent as is) chooses nothing.
await levels.nth(3).dispatchEvent('click');
await levels.nth(1).dispatchEvent('click');
await expect(e2.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0);
// With the keyboard: the arrows go from level to level and choose it, wrapping round.
await levels.nth(3).focus();
await page.keyboard.press('ArrowRight');
await expect(levels.nth(4)).toBeFocused();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('ArrowRight');
await levels.nth(1).focus();
await page.keyboard.press('ArrowLeft');
await expect(levels.nth(0)).toBeFocused();
await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true');
await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('ArrowLeft');
await expect(levels.nth(4)).toBeFocused();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('ArrowRight');
await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true');
// Or with a click.
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
@@ -343,5 +368,5 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
// Its own vote unfolds, in the list of proposals.
const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' });
await expect(change).toBeVisible();
await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await expect(change.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
});
+73 -22
View File
@@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test';
import { ALICE, chain, CHARLIE, DOC, person, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers';
import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a
// basket, and on the Ğ1 chain where the electorate is the web of trust. The chain
// here is simulated (e2e/chain-mock.mjs): never a real transaction.
// here is simulated (e2e/chain-mock.mjs): never a real transaction. In Monnaie libre
// nothing is in force at the start (the seed proposals, ADR 0011): their vote unfolds in
// the list of proposals; in the test group they were voted into force.
test.beforeAll(async ({ browser }) => seedsInForce(browser));
test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => {
await signIn(page, 'Chiara');
@@ -116,19 +119,17 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// Under the article: her vote, from the chain, and the totals.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
// Under the seed proposal: her vote, from the chain, and the totals.
const e1 = deepRow(page, 'Unicité du compte');
await e1.click();
const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1');
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
// The last one stands, in the order of the chain: a « contre » included first, but seen late
// by the indexer, never overrides the withdrawal included after it.
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
const e2 = deepRow(page, 'Réciprocité');
await e2.click();
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
await control('hide');
await fold2.getByRole('button', { name: 'Contre', exact: true }).click();
@@ -147,18 +148,15 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
// One vote unfolded at a time (an accordion): back to the first, untouched.
await expect(fold1).toHaveCount(0);
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await e1.click();
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold2).toHaveCount(0);
// Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain.
const other = await person(browser, 'Maëlle');
await other.goto(DOC);
const raison = other.locator('section.text article.unit').first();
await raison.hover();
await raison.getByRole('button', { name: 'Le vote' }).click();
await raison.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await deepRow(other, "Raison d'être").click();
await other.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible();
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
@@ -178,8 +176,9 @@ test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
await signIn(page, 'Chiara');
// The e2e server has its vote account: the page is shown what a server without one answers
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
// The e2e server has its vote account: the page is shown a vote closed, as a server without
// one answered before lot V. Since lot V (ADR 0012) a server without one votes in simulation
// instead (the server: `without_a_vote_account_monnaie_libre_votes_in_simulation_…`; here, C34).
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
@@ -192,14 +191,13 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
await page.goto(DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
await page.locator('ul.deep button.row').first().click();
const fold = page.locator('ul.deep section.fold');
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// The totals and the threshold stay in sight.
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
// Raison d'être, level 2, the quorum 12 (ADR 0013).
await expect(fold.getByText('Seuil : 9 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 ».
@@ -216,3 +214,56 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
await unit.getByRole('button', { name: 'Le vote' }).click();
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
});
/** The version of an article of Monnaie libre, by its title, as the API tells it to `page`. */
async function version(page: import('@playwright/test').Page, title: string) {
const g = await (await page.request.get('/api/groups/monnaie-libre')).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
type P = { title: string; tally: { against: number }; tallySimulated: { against: number }; myVoteState: string | null };
const all: P[] = d.units.flatMap((u: { proposals: P[] }) => u.proposals);
const p = all.find((x) => x.title === title);
if (!p) throw new Error(`no version « ${title} »`);
return { vote: d.vote as { way: string; open: boolean; simulated: boolean }, p };
}
test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simulé compte aussitôt, marqué « simulé » dans tes bulletins', async ({ page }) => {
// Lot V (ADR 0012, Yvv 2026-11-02: « Il compte, puis s'efface »). The e2e server has its
// chain: it is closed for this control only, then opened again. The vote goes to a
// proposal no other control reads, and only « contre »: nothing enters into force.
await signIn(page, 'Sacha');
const chainOpen = async (open: boolean) =>
(await (await page.request.post('/api/dev/vote-chain', { data: { open }, headers: { 'x-requested-with': 'libredecision' } })).json()).open;
const title = 'Logiciel auditable';
expect(await chainOpen(false)).toBe(false);
try {
const before = await version(page, title);
expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true });
await page.goto(DOC);
// The basket is there, without a wallet: the vote leaves by the server.
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
await deepRow(page, title).click();
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0);
await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/);
await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible();
// Under the article: marked « simulé », never « enregistré »; the note, once, by the buttons.
await expect(fold.locator('.voted')).toHaveText('Ton vote : contre · simulé');
await expect(fold.getByText('enregistré', { exact: false })).toHaveCount(0);
await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1);
const n = before.p.tallySimulated.against + 1;
await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible();
// Counted at once, marked as simulated.
const after = await version(page, title);
expect(after.p.myVoteState).toBe('simulated');
expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1);
// In her ballots: simulated, counted for her session, never a block of the chain.
await page.goto('/bulletins');
const ballot = page.locator('li.ballot').first();
await expect(ballot.locator('.state')).toHaveText("Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1");
await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0);
} finally {
// The next controls need the chain.
expect(await chainOpen(true)).toBe(true);
}
});
+1 -1
View File
@@ -7,7 +7,7 @@
"": {
"name": "libredecision-web",
"version": "0.3.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "libredecision-web",
"version": "0.3.0",
"private": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort",
+1
View File
@@ -78,6 +78,7 @@
{/snippet}
{#snippet marks(p: Proposal)}
{#if p.seedProposal}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</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}
+1 -1
View File
@@ -117,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}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : v.seedProposal ? t('doc.seedProposal') : 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>
+10 -4
View File
@@ -33,12 +33,18 @@
<details class="formula">
<summary>{t('formula.title')}</summary>
<p class="muted">{t('formula.intro')}</p>
<!-- ADR 0013: judged on T′ = max(T, Q) voters; below the quorum, the voters missing count as against. -->
<math display="block" class="math">
<mrow>
<mi>R</mi><mo>=</mo><mi>C</mi><mo>+</mo><msup><mi>B</mi><mi>W</mi></msup><mo>+</mo>
<msup><mi>T</mi><mo>′</mo></msup><mo>=</mo><mi>max</mi><mo>(</mo><mi>T</mi><mo>,</mo><mi>Q</mi><mo>)</mo>
</mrow>
</math>
<math display="block" class="math">
<mrow>
<mi>R</mi><mo>=</mo><msup><mi>B</mi><mi>W</mi></msup><mo>+</mo>
<mrow><mo>(</mo><mi>M</mi><mo>+</mo><mo>(</mo><mn>1</mn><mo>−</mo><mi>M</mi><mo>)</mo><mo>(</mo><mn>1</mn><mo>−</mo>
<msup><mrow><mo>(</mo><mfrac><mi>T</mi><mi>W</mi></mfrac><mo>)</mo></mrow><mi>G</mi></msup><mo>)</mo><mo>)</mo></mrow>
<mo>·</mo><mi>max</mi><mo>(</mo><mn>0</mn><mo>,</mo><mi>T</mi><mo>−</mo><mi>C</mi><mo>)</mo>
<msup><mrow><mo>(</mo><mfrac><msup><mi>T</mi><mo>′</mo></msup><mi>W</mi></mfrac><mo>)</mo></mrow><mi>G</mi></msup><mo>)</mo><mo>)</mo></mrow>
<mo>·</mo><msup><mi>T</mi><mo>′</mo></msup>
</mrow>
</math>
<ul class="values">
@@ -49,7 +55,7 @@
<li>{t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}</li>
{/if}
<li>{t('formula.B', { n: i18n.number(base) })}</li>
<li>{t('formula.C', { n: i18n.number(floor) })}</li>
<li>{t('formula.Q', { n: i18n.number(evaluation?.quorum ?? p?.quorum ?? floor) })}</li>
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
</ul>
<p class="faint">{t('formula.reading')}</p>
+19 -5
View File
@@ -1,8 +1,9 @@
<script lang="ts">
// The settings of the formula, read only: W and its source, C, B, the usual
// The settings of the formula, read only: W and its source, B, the usual
// turnout of the group (median or reference) and the five levels on its
// scale. From the head of the document, the *for* votes asked are those of
// the usual turnout; from a version, those of its own turnout.
// scale, each with its quorum (ADR 0013). From the head of the document, the
// *for* votes asked are those of the usual turnout; from a version, those of
// its own turnout, on its article's quorum.
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
@@ -50,7 +51,6 @@
<ul class="values">
<li>{t('formula.W', { n: i18n.number(view.doc.electorate), source: t(view.doc.electorateSource === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.C', { n: i18n.number(view.doc.voteFloor) })}</li>
<li>{t('formula.B', { n: i18n.number(view.doc.formula.base) })}</li>
</ul>
@@ -60,7 +60,7 @@
{#if p.source === 'median'}
{i18n.plural('settings.usual.median', shown?.voters ?? p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(shown?.ratio ?? p.ratio), sample: p.sample })}
{:else}
{i18n.plural('settings.usual.reference', shown?.voters ?? p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(shown?.ratio ?? p.ratio), min: p.minSample, sample: p.sample })}
{i18n.plural('settings.usual.reference', shown?.voters ?? p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(shown?.ratio ?? p.ratio), q: i18n.number(view.doc.voteFloor), min: p.minSample, sample: p.sample })}
{/if}
</p>
{#if p.bounded}<p class="faint small">{i18n.plural('settings.usual.bounded', p.voters, { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]), pct: pct(p.ratio) })}</p>{/if}
@@ -76,6 +76,7 @@
<th scope="col">M</th>
<th scope="col">G</th>
<th scope="col">{t('settings.col.share')}</th>
<th scope="col">{t('settings.col.quorum')}</th>
<th scope="col">{t('settings.col.required')}</th>
</tr>
</thead>
@@ -92,6 +93,7 @@
<td>{whole(l.majorityPct)}</td>
<td>{gradient(l.gradient)}</td>
<td>{whole(l.sharePct)}</td>
<td>{l.quorum === undefined ? '—' : i18n.number(l.quorum)}</td>
<td class="required">{l.required === null ? '—' : i18n.number(l.required)}</td>
</tr>
{/each}
@@ -99,6 +101,7 @@
</table>
</div>
<p class="faint small">{i18n.plural('settings.share.note', p.voters)}</p>
<p class="faint small">{t('settings.quorum.note')}</p>
<p class="faint small">{i18n.plural(version ? 'settings.required.version' : 'settings.required.usual', cast)}</p>
<p class="faint small">{t('settings.reading')}</p>
</section>
@@ -194,4 +197,15 @@
.tag {
font-size: 0.68rem;
}
/* On a phone, six columns hold in the sheet: the headers and the level's name wrap. */
@media (max-width: 480px) {
th,
td {
padding: 0.4rem 0.3rem;
}
thead th,
th[scope='row'] {
white-space: normal;
}
}
</style>
@@ -238,6 +238,7 @@
<h3>{p.kind === 'text' || p.kind === 'title' ? text.title : titleOf(p, unit)}</h3>
<p class="meta faint">
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
{:else if p.seedProposal}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</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}
+5 -3
View File
@@ -133,7 +133,7 @@
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
@@ -186,8 +186,8 @@
{/if}
{#if p.myVoteStale}
<p class="warn">{t('vote.stale')}</p>
{:else if p.myVote}
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
{:else if p.myVote && p.myVoteState !== 'pending'}
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : p.myVoteState === 'simulated' ? 'vote.mine.simulated' : 'vote.mine.saved')}</span></p>
{:else if ballot?.status !== 'pending'}
<p class="muted">{t('vote.mine.none')}</p>
{/if}
@@ -215,6 +215,8 @@
{#if chain && access.chain}
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
<p class="faint small">{t('vote.chain.counts')}</p>
{:else if access.simulated}
<p class="faint small">{t('vote.simulated.note')}</p>
{/if}
{/if}
<p class="faint small">{t('vote.private')}</p>
+25 -10
View File
@@ -30,7 +30,8 @@ export const en: Messages = {
'nav.menu': 'Menu',
'nav.close': 'Close',
'home.title': 'Making collective decisions possible',
'home.title.line1': 'Making it possible',
'home.title.line2': 'to decide together',
'home.leitmotiv.context': 'context and framing',
'home.leitmotiv.process': 'processes and methods',
'home.leitmotiv.tools': 'a toolbox',
@@ -122,6 +123,8 @@ export const en: Messages = {
'doc.seed.long': 'Came into force without a vote, so as not to start from a blank page. It remains open for voting, like any text.',
'doc.seed.votes.one': 'seed a minima · {n} vote',
'doc.seed.votes.other': 'seed a minima · {n} votes',
'doc.seedProposal': 'Seed proposal',
'doc.seedProposal.long': 'A starting text put forward to start the vote. It is not in force: it comes into force through the vote, like any proposal.',
'doc.confirmed': 'confirmed by vote',
'doc.adopted': 'adopted on {date}',
'doc.action.vote': 'Voting',
@@ -134,7 +137,7 @@ export const en: Messages = {
'doc.publish.pending': 'Publication pending',
'deep.title': 'Open proposals',
'deep.note': 'Not in force yet. Each one moves up as soon as it reaches its threshold: at least {floor} votes in favor, then the majority its inertia requires.',
'deep.note': 'Not in force yet. Each one moves up as soon as it reaches its threshold: the share of votes in favour its inertia requires, counted on at least its quorum of voters.',
'deep.new': 'Propose a new article',
'deep.drafts': 'Your drafts',
'deep.drafts.note': 'Not submitted to vote. Only you can see them.',
@@ -252,6 +255,10 @@ export const en: Messages = {
'vote.mine.chain': 'recorded on the Ğ1 chain',
'vote.mine.saved': 'saved',
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
'vote.mine.simulated': 'simulated',
'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until it is erased when the chain opens.',
'vote.tally.simulated.one': 'of which {n} simulated',
'vote.tally.simulated.other': 'of which {n} simulated',
'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',
@@ -266,6 +273,7 @@ export const en: Messages = {
'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.',
'ballot.reason.objection': 'an objection was holding this version back when the Ğ1 chain confirmed your vote; your earlier vote stands.',
'basket.title': 'Voting basket',
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
'basket.doc': 'The whole document',
@@ -333,6 +341,7 @@ export const en: Messages = {
'ballots.lines.other': '{n} votes',
'ballots.status.pending': 'Waiting for the Ğ1 chain',
'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”',
'ballots.status.simulated': 'Simulated: counted for “{name}” until the Ğ1 chain opens',
'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}',
@@ -372,17 +381,17 @@ export const en: Messages = {
'formula.M': 'M — majority at full turnout: {n}%',
'formula.G': 'G — gradient: {n}',
'formula.B': 'B — base: {n}',
'formula.C': 'C — minimum votes in favor: {n}',
'formula.Q': 'Q — quorum: {n}',
'formula.R': 'R — current threshold: {n} votes in favor',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Below the quorum Q, the missing voters count as against.',
'common.pct': '{n}%',
'settings.title': 'Formula settings',
'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.',
'settings.usual': 'Usual turnout',
'settings.usual.median.one': '{n} voter out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.',
'settings.usual.median.other': '{n} voters out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.',
'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice {q}, the reference quorum: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice {q}, the reference quorum: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.bounded.one': 'Kept between {lo} and {hi} of the electorate: the group asks for its votes in favour as if {n} person voted ({pct}).',
'settings.usual.bounded.other': 'Kept between {lo} and {hi} of the electorate: the group asks for its votes in favour as if {n} people voted ({pct}).',
'settings.usual.when': 'Measured once a day, last on {date}.',
@@ -390,15 +399,17 @@ export const en: Messages = {
'settings.levels': 'The five levels',
'settings.col.level': 'Level',
'settings.col.share': 'Share asked',
'settings.col.quorum': 'Quorum',
'settings.col.required': 'Votes for',
'settings.current': 'this article',
'settings.share.note.one': 'Share asked: the votes in favour asked beyond C, at {n} voter as usual.',
'settings.share.note.other': 'Share asked: the votes in favour asked beyond C, at {n} voters as usual.',
'settings.share.note.one': 'Share asked: the share of votes in favour asked, at {n} voter as usual.',
'settings.share.note.other': 'Share asked: the share of votes in favour asked, at {n} voters as usual.',
'settings.quorum.note': "Quorum: below it, the missing voters count as against. It is the level's; the author of an article may set another one before the first vote.",
'settings.required.version.one': "Votes for: at {n} voter, this version's turnout.",
'settings.required.version.other': "Votes for: at {n} voters, this version's turnout.",
'settings.required.usual.one': 'Votes for: at {n} voter, as usual.',
'settings.required.usual.other': 'Votes for: at {n} voters, as usual.',
'settings.reading': 'At each level, the group asks for its share of votes in favour beyond C when it votes as usual; the more voters, the closer it comes to M. Nothing passes below C votes in favour.',
'settings.reading': 'At each level, the group asks for its share of votes in favour when it votes as usual; the more voters, the closer it comes to M. Below the quorum of the level, the missing voters count as against.',
'history.title': 'Corrections',
'history.initial': 'initial text',
@@ -470,7 +481,7 @@ export const en: Messages = {
'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.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. You also read here what became of a vote that was not counted.',
'messages.none': 'No messages for now.',
'messages.readAll': 'Mark all as read',
'messages.read': 'Mark as read',
@@ -479,12 +490,16 @@ export const en: Messages = {
'messages.open': 'See it in the text',
'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Typo',
'messages.topic.ballot': 'Vote',
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. 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.ballot_expired': 'Your vote of {date} ({articles}) was not seen on the Ğ1 chain within 24 hours: it is set aside. You can vote again.',
'messages.ballot_refused': 'Your vote of {date} ({articles}) was not counted: {reason} You can vote again.',
'messages.ballot_objection': 'Your “for” on {articles} is set aside: an objection was holding this version back when the Ğ1 chain confirmed your vote. Your earlier vote stands.',
'messages.respond': 'Answer',
'messages.outcome.answered': 'answered',
'messages.outcome.reiterated': 'objection reiterated',
+25 -10
View File
@@ -30,7 +30,8 @@ export const es: Messages = {
'nav.menu': 'Menú',
'nav.close': 'Cerrar',
'home.title': 'Hacer posible la decisión colectiva',
'home.title.line1': 'Hacer posible',
'home.title.line2': 'la decisión colectiva',
'home.leitmotiv.context': 'contexto y encuadre',
'home.leitmotiv.process': 'procesos y modalidades',
'home.leitmotiv.tools': 'caja de herramientas',
@@ -122,6 +123,8 @@ export const es: Messages = {
'doc.seed.long': 'Entró en vigor sin votación, para no partir de una página en blanco. Sigue en votación, como todo texto.',
'doc.seed.votes.one': 'seed a minima · {n} voto',
'doc.seed.votes.other': 'seed a minima · {n} votos',
'doc.seedProposal': 'Propuesta seed',
'doc.seedProposal.long': 'Texto de partida destacado para lanzar la votación. No está en vigor: entra en vigor por la votación, como toda propuesta.',
'doc.confirmed': 'confirmado por la votación',
'doc.adopted': 'adoptado el {date}',
'doc.action.vote': 'Votación',
@@ -134,7 +137,7 @@ export const es: Messages = {
'doc.publish.pending': 'Publicación pendiente',
'deep.title': 'Propuestas en curso',
'deep.note': 'Aún no están en vigor. Cada una sube en cuanto alcanza su umbral: al menos {floor} votos a favor y, después, la mayoría que exige su inercia.',
'deep.note': 'Aún no están en vigor. Cada una sube en cuanto alcanza su umbral: la proporción de votos a favor que exige su inercia, contada sobre al menos su quórum de votantes.',
'deep.new': 'Proponer un artículo nuevo',
'deep.drafts': 'Tus borradores',
'deep.drafts.note': 'No sometidos a votación. Solo tú los ves.',
@@ -252,6 +255,10 @@ export const es: Messages = {
'vote.mine.chain': 'inscrito en la cadena Ğ1',
'vote.mine.saved': 'registrado',
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
'vote.mine.simulated': 'simulado',
'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», hasta que se borre al abrirse la cadena.',
'vote.tally.simulated.one': 'de ellos {n} simulado',
'vote.tally.simulated.other': 'de ellos {n} simulados',
'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',
@@ -266,6 +273,7 @@ export const es: Messages = {
'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.',
'ballot.reason.objection': 'una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto; tu voto anterior se mantiene.',
'basket.title': 'Cesta de votación',
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
'basket.doc': 'Todo el documento',
@@ -333,6 +341,7 @@ export const es: Messages = {
'ballots.lines.other': '{n} votos',
'ballots.status.pending': 'Esperando a la cadena Ğ1',
'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»',
'ballots.status.simulated': 'Simulado: contado para «{name}» hasta que se abra la cadena Ğ1',
'ballots.status.refused': 'No contado: {reason}',
'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta',
'ballots.block': 'bloque {n}',
@@ -372,17 +381,17 @@ export const es: Messages = {
'formula.M': 'M — mayoría con participación plena: {n} %',
'formula.G': 'G — gradiente: {n}',
'formula.B': 'B — base: {n}',
'formula.C': 'C — mínimo de votos a favor: {n}',
'formula.Q': 'Q — quórum: {n}',
'formula.R': 'R — umbral actual: {n} votos a favor',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Por debajo del quórum Q, los votantes que faltan cuentan como «no».',
'common.pct': '{n} %',
'settings.title': 'Ajustes de la fórmula',
'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.',
'settings.usual': 'Participación habitual',
'settings.usual.median.one': '{n} votante de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.',
'settings.usual.median.other': '{n} votantes de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.',
'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces {q}, el quórum de referencia: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces {q}, el quórum de referencia: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.bounded.one': 'Mantenida entre el {lo} y el {hi} del electorado: el grupo pide sus votos a favor como si votara {n} persona ({pct}).',
'settings.usual.bounded.other': 'Mantenida entre el {lo} y el {hi} del electorado: el grupo pide sus votos a favor como si votaran {n} personas ({pct}).',
'settings.usual.when': 'Medida una vez al día, la última el {date}.',
@@ -390,15 +399,17 @@ export const es: Messages = {
'settings.levels': 'Los cinco niveles',
'settings.col.level': 'Nivel',
'settings.col.share': 'Proporción exigida',
'settings.col.quorum': 'Quórum',
'settings.col.required': 'Votos a favor',
'settings.current': 'este artículo',
'settings.share.note.one': 'Proporción exigida: los votos a favor pedidos más allá de C, con {n} votante como de costumbre.',
'settings.share.note.other': 'Proporción exigida: los votos a favor pedidos más allá de C, con {n} votantes como de costumbre.',
'settings.share.note.one': 'Proporción exigida: la parte de votos a favor pedida, con {n} votante como de costumbre.',
'settings.share.note.other': 'Proporción exigida: la parte de votos a favor pedida, con {n} votantes como de costumbre.',
'settings.quorum.note': 'Quórum: por debajo, los votantes que faltan cuentan como «no». Es el del nivel; el autor de un artículo puede fijar otro antes del primer voto.',
'settings.required.version.one': 'Votos a favor: con {n} votante, la participación de esta versión.',
'settings.required.version.other': 'Votos a favor: con {n} votantes, la participación de esta versión.',
'settings.required.usual.one': 'Votos a favor: con {n} votante, como de costumbre.',
'settings.required.usual.other': 'Votos a favor: con {n} votantes, como de costumbre.',
'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor más allá de C cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Nada pasa con menos de C votos a favor.',
'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Por debajo del quórum del nivel, los votantes que faltan cuentan como «no».',
'history.title': 'Correcciones',
'history.initial': 'texto inicial',
@@ -470,7 +481,7 @@ export const es: Messages = {
'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.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. Aquí lees también qué fue de un voto que no se contó.',
'messages.none': 'Ningún mensaje por ahora.',
'messages.readAll': 'Marcar todo como leído',
'messages.read': 'Marcar como leído',
@@ -479,12 +490,16 @@ export const es: Messages = {
'messages.open': 'Verlo en el texto',
'messages.topic.objection': 'Objeción',
'messages.topic.typo': 'Errata',
'messages.topic.ballot': 'Voto',
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». 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.ballot_expired': 'Tu voto del {date} ({articles}) no se vio en la cadena Ğ1 en 24 horas: queda descartado. Puedes votar de nuevo.',
'messages.ballot_refused': 'Tu voto del {date} ({articles}) no se contó: {reason} Puedes votar de nuevo.',
'messages.ballot_objection': 'Tu «a favor» sobre {articles} queda descartado: una objeción retenía esta versión cuando la cadena Ğ1 confirmó tu voto. Tu voto anterior se mantiene.',
'messages.respond': 'Responder',
'messages.outcome.answered': 'respuesta dada',
'messages.outcome.reiterated': 'objeción reiterada',
+26 -10
View File
@@ -33,7 +33,9 @@ export const fr = {
'nav.menu': 'Menu',
'nav.close': 'Fermer',
'home.title': 'Rendre possible la décision collective',
// The title on two lines, the break after « possible » (Yvv, 2026-11-02): one key per line.
'home.title.line1': 'Rendre possible',
'home.title.line2': 'la décision collective',
'home.leitmotiv.context': 'contexte et cadrage',
'home.leitmotiv.process': 'processus et modalités',
'home.leitmotiv.tools': 'boîte à outils',
@@ -125,6 +127,8 @@ export const fr = {
'doc.seed.long': "Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.",
'doc.seed.votes.one': 'seed a minima · {n} vote',
'doc.seed.votes.other': 'seed a minima · {n} votes',
'doc.seedProposal': 'Proposition seed',
'doc.seedProposal.long': "Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.",
'doc.confirmed': 'confirmé par le vote',
'doc.adopted': 'adopté le {date}',
'doc.action.vote': 'Le vote',
@@ -137,7 +141,7 @@ export const fr = {
'doc.publish.pending': 'Publication en attente',
'deep.title': 'Propositions en cours',
'deep.note': "Pas encore en vigueur. Chacune remonte dès qu'elle atteint son seuil : au moins {floor} voix pour, puis la majorité que demande son inertie.",
'deep.note': "Pas encore en vigueur. Chacune remonte dès qu'elle atteint son seuil : la part de voix pour que demande son inertie, comptée sur au moins son quorum de votants.",
'deep.new': 'Proposer un nouvel article',
'deep.drafts': 'Tes brouillons',
'deep.drafts.note': 'Non soumis au vote. Toi seul les vois.',
@@ -255,6 +259,10 @@ export const fr = {
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
'vote.mine.saved': 'enregistré',
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
'vote.mine.simulated': 'simulé',
'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », jusqu'à son effacement à l'ouverture de la chaîne.",
'vote.tally.simulated.one': 'dont {n} simulé',
'vote.tally.simulated.other': 'dont {n} simulés',
'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',
@@ -269,6 +277,7 @@ export const fr = {
'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.',
'ballot.reason.objection': "une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote ; ton vote d'avant reste.",
'basket.title': 'Panier de vote',
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
'basket.doc': 'Tout le document',
@@ -336,6 +345,7 @@ export const fr = {
'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.simulated': "Simulé : compté pour « {name} » jusqu'à l'ouverture de la chaîne Ğ1",
'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}',
@@ -375,17 +385,17 @@ export const fr = {
'formula.M': 'M — majorité à pleine participation : {n} %',
'formula.G': 'G — gradient : {n}',
'formula.B': 'B — base : {n}',
'formula.C': 'C — minimum de voix pour : {n}',
'formula.Q': 'Q — quorum : {n}',
'formula.R': 'R — seuil actuel : {n} voix pour',
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.",
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Sous le quorum Q, les votants qui manquent comptent comme « non ».",
'common.pct': '{n} %',
'settings.title': 'Réglages de la formule',
'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.",
'settings.usual': 'Participation habituelle',
'settings.usual.median.one': '{n} votant sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.',
'settings.usual.median.other': '{n} votants sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.',
'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois {q}, le quorum de référence : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois {q}, le quorum de référence : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.bounded.one': "Ramenée entre {lo} et {hi} de l'électorat : le groupe demande ses voix pour comme si {n} personne votait ({pct}).",
'settings.usual.bounded.other': "Ramenée entre {lo} et {hi} de l'électorat : le groupe demande ses voix pour comme si {n} personnes votaient ({pct}).",
'settings.usual.when': 'Mesurée une fois par jour, la dernière fois le {date}.',
@@ -393,15 +403,17 @@ export const fr = {
'settings.levels': 'Les cinq crans',
'settings.col.level': 'Cran',
'settings.col.share': 'Part exigée',
'settings.col.quorum': 'Quorum',
'settings.col.required': 'Voix pour',
'settings.current': 'cet article',
'settings.share.note.one': "Part exigée : les « pour » demandés au-delà de C, à {n} votant comme d'habitude.",
'settings.share.note.other': "Part exigée : les « pour » demandés au-delà de C, à {n} votants comme d'habitude.",
'settings.share.note.one': "Part exigée : la part de « pour » demandée, à {n} votant comme d'habitude.",
'settings.share.note.other': "Part exigée : la part de « pour » demandée, à {n} votants comme d'habitude.",
'settings.quorum.note': "Quorum : en dessous, les votants qui manquent comptent comme « non ». C'est celui du cran ; l'auteur d'un article peut en fixer un autre avant le premier vote.",
'settings.required.version.one': 'Voix pour : à {n} votant, la participation de cette version.',
'settings.required.version.other': 'Voix pour : à {n} votants, la participation de cette version.',
'settings.required.usual.one': "Voix pour : à {n} votant, comme d'habitude.",
'settings.required.usual.other': "Voix pour : à {n} votants, comme d'habitude.",
'settings.reading': "À chaque cran, le groupe demande sa part de « pour » au-delà de C quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Rien ne passe sous C voix pour.",
'settings.reading': "À chaque cran, le groupe demande sa part de « pour » quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Sous le quorum du cran, les votants qui manquent comptent comme « non ».",
'history.title': 'Corrections',
'history.initial': 'texte initial',
@@ -473,7 +485,7 @@ export const fr = {
'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.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. Tu y lis aussi ce qu'il advient d'un vote qui n'a pas été compté.",
'messages.none': "Aucun message pour l'instant.",
'messages.readAll': 'Tout marquer comme lu',
'messages.read': 'Marquer comme lu',
@@ -482,12 +494,16 @@ export const fr = {
'messages.open': 'Voir dans le texte',
'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Coquille',
'messages.topic.ballot': 'Vote',
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». 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.ballot_expired': "Ton vote du {date} ({articles}) n'a pas été vu sur la chaîne Ğ1 en 24 heures : il est écarté. Tu peux voter à nouveau.",
'messages.ballot_refused': "Ton vote du {date} ({articles}) n'a pas été compté : {reason} Tu peux voter à nouveau.",
'messages.ballot_objection': "Ton « pour » sur {articles} est écarté : une objection retenait cette version quand la chaîne Ğ1 a confirmé ton vote. Ton vote d'avant reste.",
'messages.respond': 'Répondre',
'messages.outcome.answered': 'réponse donnée',
'messages.outcome.reiterated': 'objection réitérée',
+70 -8
View File
@@ -4,17 +4,19 @@ export type LangMap = Partial<Record<Lang, string>>;
export type Inertia = 1 | 2 | 3 | 4 | 5;
export const INERTIA_LEVELS: Inertia[] = [1, 2, 3, 4, 5];
/** One level on the group's scale: M and G of the formula, s_k, and the *for* votes asked at `cast` votes. */
/** One level on the group's scale: M and G of the formula, s_k, its quorum, and the *for* votes asked at `cast` votes. */
export interface LevelInfo {
level: Inertia;
majorityPct: number;
gradient: number;
sharePct: number;
/** Q — the quorum of the level (ADR 0013); from a version, its article's own if it has one. */
quorum?: number;
cast: number;
required: number | null;
}
/** p̃ — the usual turnout of the group, measured once a day (or the reference 2C/W). */
/** p̃ — the usual turnout of the group, measured once a day (or the reference 2Q₀/W, Q₀ = `voteFloor`). */
export interface ParticipationInfo {
ratio: number;
source: 'median' | 'reference';
@@ -60,11 +62,17 @@ export interface Tally {
export interface Evaluation {
cast: number;
/** Q — the quorum it is judged on (ADR 0013): below it, the voters missing count as against. */
quorum?: number;
required: number;
reached: boolean;
missing: number;
requiredRatio: number;
wellRated: boolean;
/** The total of *for* that passes with the *against* cast now. */
yesNeeded?: number;
/** The total of *against* that holds the *for* cast now in check (0: the quorum holds them). */
noNeeded?: number;
}
export interface Translation {
@@ -107,7 +115,16 @@ export interface Proposal {
enteredAt: string | null;
confirmedAt: string | null;
seedMinimal: boolean;
/** A seed proposal (ADR 0011): open, put forward by the seed to start the vote, not in force. */
seedProposal?: boolean;
/** The totals shown: recorded, pending and simulated votes, each intention in place of
* the vote its session sees as its own (ADR 0012). */
tally: Tally;
/** The part of `tally` still without proof (an intention pending on the chain). */
tallyPending: Tally;
/** The part of `tally` that comes from the simulation (ADR 0012). */
tallySimulated: Tally;
/** On the totals shown (`tally`), the quorum included (ADR 0013). */
evaluation: Evaluation | null;
votedAtInertia: Inertia;
/** Held back by a standing objection: the latest arrival of the two (never a text in force). */
@@ -116,11 +133,17 @@ export interface Proposal {
votable: boolean;
/** Standing objections around this version. */
objections: number;
/** What the person sees as her vote now: her latest gesture, an intention pending included. */
myVote: 'for' | 'against' | null;
/** Where « ton vote » stands (ADR 0012): `recorded` — proven by the chain, or a vote of
* the server in a managed group; `pending` — an intention waiting for the chain;
* `simulated` — a vote of the simulation, while the chain is closed. */
myVoteState: 'recorded' | 'pending' | 'simulated' | null;
myVoteStale: boolean;
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
myVoteChain: boolean;
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go —
* or its *for* set aside by an objection (`refused`, reason `objection`, ADR 0012). */
myBallot: MyBallot | null;
translations: Partial<Record<Lang, Translation>>;
/** An order version: the order it gives its section now. */
@@ -144,7 +167,7 @@ export type Choice = 'for' | 'against' | 'none';
export interface MyBallot {
id: string;
status: 'pending' | 'refused' | 'expired';
reason: 'not_member' | 'no_identity' | 'transfer' | null;
reason: 'not_member' | 'no_identity' | 'transfer' | 'objection' | null;
choice: Choice;
}
@@ -164,8 +187,12 @@ export interface ChainSettings {
/** How this session votes in the group of the document, and whether it may now. */
export interface VoteAccess {
/** `server` too while the chain is closed: the vote is then simulated (`simulated`). */
way: 'server' | 'chain';
open: boolean;
/** The chain is closed: the vote leaves by a write of the server, marked as simulated,
* and counts until an act « supprime » sets it aside (ADR 0012). */
simulated: boolean;
reason: 'members_only' | 'chain_off' | null;
chain: ChainSettings | null;
/**
@@ -188,6 +215,8 @@ export interface Ballot {
voter: string | null;
entered: Entered[];
expiresAt: string;
/** `simulated`: a ballot of the simulation, proven by the server, never on the chain. */
mode: 'chain' | 'simulated';
}
/** A ballot of the person's history (`GET /api/ballots`): never anyone else's. */
@@ -202,6 +231,7 @@ export interface BallotEntry {
lines: number;
createdAt: string;
settledAt: string | null;
mode: Ballot['mode'];
}
export interface Unit {
@@ -219,6 +249,12 @@ export interface Unit {
members?: string[];
/** An order of fewer than two articles: nothing to decide, not shown. */
moot?: boolean;
/** Its own quorum, set by the author of its first version before the first vote; `null`: its level's (ADR 0013). */
quorum?: number | null;
/** The quorum of its level. */
quorumDefault?: number;
/** This session may set its quorum now (`PUT /units/{id}/quorum`). */
canSetQuorum?: boolean;
}
/** What a document decides besides its articles: its title, the order of each section. */
@@ -228,12 +264,16 @@ export interface Modules {
}
export interface Publication {
state: 'none' | 'pending' | 'done';
/** `suspended`: no article in force yet, the queue waits for the first text (ADR 0011). */
state: 'none' | 'pending' | 'suspended' | 'done';
configured: boolean;
since?: string;
doneAt?: string | null;
commit?: string | null;
attempts?: number;
/** Why it is suspended: no article in force yet (`text`, ADR 0011), or something in force
* entered by simulated votes that the proven votes do not carry yet (`proven`, ADR 0012). */
waitsFor?: 'text' | 'proven' | null;
}
export interface DocView {
@@ -248,7 +288,10 @@ export interface DocView {
title: LangMap;
summary: LangMap;
sourceLang: Lang;
/** Q₀ — the reference quorum (the C of before): the reference turnout is 2Q₀/W. */
voteFloor: number;
/** The quorum of each level, 1 to 5 (ADR 0013). */
quorums?: number[];
electorate: number;
electorateSource: 'wot' | 'group';
formula: { base: number };
@@ -342,11 +385,29 @@ export interface TypoFix {
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. */
/** A ballot set aside, as a message tells it (ADR 0012): line by line, what became of it. */
export interface BallotNotice {
id: string;
status: Ballot['status'];
reason: MyBallot['reason'];
mode: Ballot['mode'];
createdAt: string;
settledAt: string | null;
lines: { proposalId: string; unitId: string; code: string; title: string; choice: Choice; outcome: 'counted' | 'superseded' | 'objection' | null }[];
}
/** A message of one's box: about an objection, a typo fix or a ballot set aside, with its gestures now. */
export interface Message {
id: string;
topic: 'objection' | 'typo';
kind: 'objection_raised' | 'objection_answered' | 'objection_reiterated' | 'typo_pending';
topic: 'objection' | 'typo' | 'ballot';
kind:
| 'objection_raised'
| 'objection_answered'
| 'objection_reiterated'
| 'typo_pending'
| 'ballot_expired'
| 'ballot_refused'
| 'ballot_objection';
cycle: number;
createdAt: string;
dueAt: string | null;
@@ -358,4 +419,5 @@ export interface Message {
doc: { id: string; groupSlug: string; slug: string; title: LangMap };
objection?: Objection;
typo?: TypoFix;
ballot?: BallotNotice;
}
+121 -39
View File
@@ -1,17 +1,28 @@
<script lang="ts">
// The welcome (Yvv, 2026-11-02): the keystone arch, large enough to shelter the three points of
// the leitmotiv, the toolbox first; then the title, centred, broken after « possible ».
import sign from '$lib/assets/cle-de-voute.svg?raw';
import GroupChoice from '$lib/components/GroupChoice.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
const LEITMOTIV = ['home.leitmotiv.tools', 'home.leitmotiv.context', 'home.leitmotiv.process'] as const;
</script>
<div class="home">
<header class="intro">
<h1>{t('home.title')}</h1>
<ul class="leitmotiv">
<li>{t('home.leitmotiv.context')}</li>
<li>{t('home.leitmotiv.process')}</li>
<li>{t('home.leitmotiv.tools')}</li>
</ul>
<div class="vault">
<span class="sign" aria-hidden="true">{@html sign}</span>
<ul class="leitmotiv">
{#each LEITMOTIV as key, i (key)}
<li style:--i={i}>{t(key)}</li>
{/each}
</ul>
</div>
<h1>
<span class="line">{t('home.title.line1')}</span>
<span class="line">{t('home.title.line2')}</span>
</h1>
</header>
{#if session.me}
@@ -28,56 +39,127 @@
.home {
max-width: 44rem;
margin: 0 auto;
padding: clamp(3rem, 10vh, 6.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
padding: clamp(2rem, 7vh, 4.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
display: grid;
gap: clamp(2.4rem, 6vh, 3.6rem);
}
.intro h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2.1rem, 1.4rem + 3vw, 3.4rem);
letter-spacing: -0.015em;
line-height: 1.08;
font-variation-settings: 'opsz' 72;
max-width: 15ch;
text-wrap: balance;
/* The arch, drawn by the sign of the logo (stones in the ink of the mood, the keystone in
`--cle`), never tilted. Its box is the stone itself: 92 units wide (the base slab, x from
−46 to 46) and 59 high (y from −40, the top of the keystone, to 19, under the slab); the SVG's
own 3-unit margin spills out above and below (clipped on the sides, where it is empty). On a
phone the arch reaches into the side padding, 0.75rem short of the screen's edges, so that the
opening keeps as much room as it can. */
.vault {
--w: min(34rem, 100vw - 1.5rem);
container-type: inline-size;
position: relative;
width: var(--w);
margin-inline: calc(50% - var(--w) / 2);
aspect-ratio: 92 / 59;
overflow-x: clip;
color: var(--ink);
}
/* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */
.sign {
position: absolute;
inset: calc(-300% / 59) calc(-300% / 92);
}
.sign :global(svg) {
display: block;
width: 100%;
height: 100%;
}
/* The light under the vault: the opening (x from −18.5 to 18.5, from the keystone down to the
slab), its top a half circle, washed with the keystone's colour. */
.vault::before {
content: '';
position: absolute;
inset: calc(2150% / 59) calc(2750% / 92) calc(340% / 59);
border-radius: 50% 50% 0 0 / 54.25% 54.25% 0 0;
background: radial-gradient(
ellipse 75% 62% at 50% 60%,
color-mix(in srgb, var(--cle) 12%, transparent),
transparent
);
}
/* The three points, sheltered: centred in the opening (x from −19 to 19; a band from y = −19,
under the keystone, to 19, centred on the springing line y = 0), one line each. The type
follows the arch (cqi): the longest line of the three languages, 10.5 em (es), holds in the
38 units of the opening with room to spare. On a phone the italic is small (12px at 360):
its advances are kept exact, not rounded to the pixel, or the letters drift apart
(« pr ocessus ») and the word spaces close up. */
.leitmotiv {
list-style: none;
margin: 1.4rem 0 0;
position: absolute;
inset: calc(2100% / 59) calc(2700% / 92) 0;
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.2rem;
align-content: center;
justify-items: center;
gap: 0.2em;
font-family: var(--font-text);
font-style: italic;
font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem);
line-height: 1.45;
font-size: 3.55cqi;
line-height: 1.3;
color: var(--ink-soft);
white-space: nowrap;
text-align: center;
text-rendering: geometricPrecision;
}
.leitmotiv li {
display: flex;
align-items: baseline;
gap: 0.7rem;
.intro h1 {
margin-top: clamp(1.4rem, 4vh, 2.4rem);
font-family: var(--font-text);
font-weight: 500;
font-size: min(3.3rem, 8.6vw);
letter-spacing: -0.015em;
line-height: 1.1;
font-variation-settings: 'opsz' 72;
text-align: center;
}
.leitmotiv li::before {
content: '';
flex: none;
width: 0.62em;
height: 0.34em;
border-radius: 0.1em;
background: var(--cle);
opacity: 0.75;
translate: 0 -0.12em;
.line {
display: block;
text-wrap: balance;
}
.enter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.2rem;
display: grid;
justify-items: center;
gap: 0.8rem;
text-align: center;
}
.enter .btn {
min-height: 2.75rem;
padding-inline: 1.4rem;
}
.enter p {
font-size: 0.9rem;
max-width: 34ch;
}
/* The keystone sets the arch, then the three points come under it. */
@media (prefers-reduced-motion: no-preference) {
.sign :global(path[fill^='var(--cle']) {
transform-box: fill-box;
animation: settle 0.7s var(--ease) 0.1s backwards;
}
.leitmotiv li {
animation: appear 0.6s var(--ease) backwards;
animation-delay: calc(0.55s + var(--i) * 0.12s);
}
}
@keyframes settle {
from {
transform: translateY(-40%);
opacity: 0;
}
}
@keyframes appear {
from {
transform: translateY(0.4em);
opacity: 0;
}
}
</style>
+5 -2
View File
@@ -27,6 +27,8 @@
function standing(b: BallotEntry): string {
if (b.status === 'pending') return t('ballots.status.pending');
// A vote of the simulation (ADR 0012): counted for the session, never on the chain.
if (b.status === 'confirmed' && b.mode === 'simulated') return t('ballots.status.simulated', { name: b.voter ?? '' });
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');
@@ -62,7 +64,7 @@
{#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}">
<p class="state {b.status}" class:simulated={b.mode === 'simulated'}>
{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>
@@ -159,7 +161,8 @@
.state.confirmed {
color: var(--moss);
}
.state.pending {
.state.pending,
.state.confirmed.simulated {
color: var(--water);
}
.state.refused,
@@ -192,6 +192,8 @@
<section class="text" aria-label={t('doc.view.inforce')}>
{#if doc.mode === 'inforce' || !doc.preview}
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
{#each s.units as u (u.id)}
{#if u.inForce}
@@ -224,8 +226,6 @@
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
</OrderModule>
{/if}
{:else}
<p class="muted">{t('doc.empty')}</p>
{/each}
{:else}
{#each doc.preview as pu (pu.unitId)}
+22 -2
View File
@@ -2,7 +2,8 @@
// 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.
// deadline. A message dealt with offers no gesture any more. No mail. A ballot set
// aside (ADR 0012) is told here too, without gesture: one votes again in the text.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
@@ -44,10 +45,27 @@
await reload();
}
/** The lines of a ballot a message is about: those set aside by an objection, or all. */
function linesOf(m: Message) {
const lines = m.ballot?.lines ?? [];
const held = lines.filter((l) => l.outcome === 'objection');
return m.kind === 'ballot_objection' && held.length ? held : lines;
}
/** 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;
const b = m.ballot;
if (b) {
const articles = linesOf(m)
.map((l) => l.code)
.join(', ');
const date = i18n.date(b.createdAt);
if (m.kind === 'ballot_expired') return t('messages.ballot_expired', { date, articles });
if (m.kind === 'ballot_refused') return t('messages.ballot_refused', { date, articles, reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
return t('messages.ballot_objection', { articles });
}
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 ?? '' });
@@ -73,6 +91,8 @@
function hrefOf(m: Message): string {
const v = m.objection?.heldVersion ?? m.typo?.version ?? null;
const base = `/g/${m.doc.groupSlug}/docs/${m.doc.slug}`;
const line = linesOf(m)[0];
if (line) return `${base}?${panelSearch({ tab: 'details', unitId: line.unitId, proposalId: line.proposalId }, null)}`;
if (!v) return base;
return `${base}?${panelSearch({ tab: 'details', unitId: v.unitId ?? null, proposalId: v.id }, null)}`;
}
@@ -149,7 +169,7 @@
<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}
{#if m.done && m.topic !== 'ballot'}
<p class="outcome">{outcomeOf(m)}</p>
{:else if m.dueAt}
<p class="due">{t('messages.due', { date: i18n.date(m.dueAt) })}</p>
+2 -2
View File
@@ -6,8 +6,8 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
return {
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
+14
View File
@@ -14,6 +14,20 @@ describe('five levels of inertia', () => {
expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']);
expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']);
});
it('the threshold is told with the quorum, no longer with C (ADR 0013)', () => {
const told = [
'deep.note', 'formula.reading', 'settings.reading', 'settings.share.note.one', 'settings.share.note.other',
'settings.usual.reference.one', 'settings.usual.reference.other',
] as const;
for (const cat of [fr, en, es]) {
for (const k of told) expect(cat[k], k).not.toMatch(/\bC\b/);
expect(cat['deep.note']).not.toContain('{floor}');
expect(cat['formula.Q']).toContain('{n}');
expect(cat['settings.usual.reference.other']).toContain('{q}');
}
expect(fr['formula.reading']).toContain('les votants qui manquent comptent comme « non »');
expect(fr['settings.quorum.note']).toContain('avant le premier vote');
});
it('a change of inertia is said to be voted at the level of the article, no longer one above', () => {
for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}');
expect(fr['inertia.change.note']).toContain("à l'inertie de l'article");
+2 -2
View File
@@ -6,9 +6,9 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
return {
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, tallyPending: { for: 0, against: 0 }, tallySimulated: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteState: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}