forked from yvv/decision
Merge branch 'chantier/acte-certification' into lot/u-lecteur
This commit is contained in:
+113
-2
@@ -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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user