diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index 3d881a4..d793b5c 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -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 { const doc = await testDoc(page); diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index 6357a22..5470d5d 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -79,10 +79,10 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti 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'); + // 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 : 50 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(); diff --git a/web/e2e/objections.spec.ts b/web/e2e/objections.spec.ts index 1b7f31c..45dd711 100644 --- a/web/e2e/objections.spec.ts +++ b/web/e2e/objections.spec.ts @@ -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' }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index e4766c3..55ec01a 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -1,5 +1,9 @@ -import { expect, test } from '@playwright/test'; -import { BOB, DOC, expectWelcome, 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'; + +// 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('/'); @@ -33,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); @@ -76,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 }) => { @@ -147,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 }) => { @@ -228,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. @@ -294,20 +314,20 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).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']); 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(); @@ -316,25 +336,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'); @@ -342,5 +362,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(); }); diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index fd1354e..db7c70b 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -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(); @@ -192,10 +190,8 @@ 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. diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index 1e71a2e..107c39b 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -78,6 +78,7 @@ {/snippet} {#snippet marks(p: Proposal)} + {#if p.seedProposal}{t('doc.seedProposal')}{/if} {#if p.evaluation?.wellRated}{t('deep.wellrated')}{/if} {#if p.blocked}{t('deep.held')}{/if} {#if p.myVoteStale}{t('deep.stale')}{/if} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index c9e412f..ab013f1 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -117,7 +117,7 @@ {v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} {nameOfVersion(v)} - {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} · {t('deep.held')}{/if} + {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} · {t('deep.held')}{/if} diff --git a/web/src/lib/components/VersionDetails.svelte b/web/src/lib/components/VersionDetails.svelte index 9da7aed..3457cd6 100644 --- a/web/src/lib/components/VersionDetails.svelte +++ b/web/src/lib/components/VersionDetails.svelte @@ -238,6 +238,7 @@

{p.kind === 'text' || p.kind === 'title' ? text.title : titleOf(p, unit)}

{#if p.seedMinimal}{t('doc.seed')} + {:else if p.seedProposal}{t('doc.seedProposal')} {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} {#if p.blocked}{t('deep.held')}{/if} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 50e861e..00b8bc7 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -123,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', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 144df45..bb79f9d 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -123,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', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 2a922d3..46ff1e2 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -127,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', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index f700121..0e5701f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -107,6 +107,8 @@ 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; tally: Tally; evaluation: Evaluation | null; votedAtInertia: Inertia; diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 9481bc9..8600d1e 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -192,6 +192,8 @@

{#if doc.mode === 'inforce' || !doc.preview} + + {#if !doc.inForce.length}

{t('doc.empty')}

{/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} {/if} - {:else} -

{t('doc.empty')}

{/each} {:else} {#each doc.preview as pu (pu.unitId)}