forked from yvv/decision
v3 : modules d'ordre et de titre — l'interface, restes mineurs (L11, C25–C27)
Chaque section du lecteur finit par l'ordre de ses articles (inertie 2) : ses articles numérotés, son vote déplié, « Proposer un autre ordre » — une liste où l'on clique un article puis sa place, ses flèches, ou le clavier. Le titre du document (inertie 4) se vote et se propose sous le titre, « traduction à revoir » quand il a changé. Les deux sont dans la liste des propositions, le panier et la chaîne Ğ1 ; un nouvel article se propose dans une section, à sa fin. La liste du groupe compte les propositions ouvertes des modules. Restes mineurs : la participation ramenée se dit en deux temps (la mesure, puis ce que le groupe demande), les lignes « habituelle » des réglages resserrées, le sélecteur d'inertie se parcourt aux flèches (le cran actuel atteignable, jamais choisi), la marque « seed a minima » porte le vrai nombre de votes. Contrôles : C25 (ordre, dont la chaîne et le téléphone), C26 (titre), C27 (participation ramenée) ; C3, C17 (41 modules), C20 mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -142,8 +142,10 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
|
||||
const rows = page.locator('ul.lines > li');
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toContainText("Acte d'engagement Certification");
|
||||
await expect(rows.first()).toContainText('33 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 33 sans ton vote');
|
||||
// 33 articles, the title of the document and the order of 7 sections (ADR 0009; the
|
||||
// section of a single article has no order to decide).
|
||||
await expect(rows.first()).toContainText('41 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 41 sans ton vote');
|
||||
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
|
||||
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
|
||||
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
|
||||
@@ -163,8 +165,8 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
|
||||
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
|
||||
.toBe('confirmed');
|
||||
await page.reload();
|
||||
await expect(rows.first()).toContainText('33 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 32 sans ton vote');
|
||||
await expect(rows.first()).toContainText('41 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 40 sans ton vote');
|
||||
await rows.first().getByRole('link').click();
|
||||
await expect(page).toHaveURL(path(DOC));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { devices, expect, test, type Page } from '@playwright/test';
|
||||
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
|
||||
|
||||
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
||||
// order of its articles (inertia 2), proposed with a list one clicks; at the head of
|
||||
// the document, its title (inertia 4). Both voted like an article: unfolded, in the
|
||||
// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs).
|
||||
// The server is shared with the specs that follow: the test group keeps its articles
|
||||
// and their votes; its conclusion is reordered and its title changes.
|
||||
|
||||
const K1 = 'Règles abrégées de la toile de confiance';
|
||||
const K2 = 'Monnaie G1';
|
||||
|
||||
/** A vote unfolded in the list of proposals: « Pour », then the news. */
|
||||
async function voteFor(page: Page, row: string) {
|
||||
await page.locator('ul.deep button.row', { hasText: row }).click();
|
||||
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
}
|
||||
|
||||
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
// Each section ends with its order; a section of one article has nothing to decide.
|
||||
const modules = page.locator('section.text [role="group"]');
|
||||
await expect(modules).toHaveCount(7);
|
||||
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
|
||||
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
|
||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K1}`, `2 ${K2}`]);
|
||||
await expect(order).toContainText('2 articles · inertie 2 / 5 · basse');
|
||||
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-1-ordre-lecture');
|
||||
|
||||
// Its vote unfolds under it, like an article's; the mark tells the real number of votes.
|
||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||
await fond.hover();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
||||
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
|
||||
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
||||
await fondVote.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||||
await fondVote.getByRole('button', { name: 'Retirer mon vote' }).click();
|
||||
await expect(fond.getByText('seed a minima · 0 vote')).toBeVisible();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
|
||||
// Another order: the article, then its new place.
|
||||
await order.hover();
|
||||
await order.getByRole('button', { name: 'Proposer un autre ordre' }).click();
|
||||
await expect(page).toHaveURL(/onglet=proposer/);
|
||||
const panel = page.locator('aside.panel');
|
||||
const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' });
|
||||
const items = list.locator('button.item');
|
||||
const names = list.locator('button.item .name');
|
||||
await expect(names).toHaveText([K1, K2]);
|
||||
const submit = panel.getByRole('button', { name: 'Proposer cet ordre' });
|
||||
await expect(submit).toBeDisabled();
|
||||
await items.nth(1).click();
|
||||
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||
await items.nth(0).click();
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
await expect(panel.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached();
|
||||
await expect(submit).toBeEnabled();
|
||||
// With the keyboard: the moved article keeps the focus, the arrows move it.
|
||||
await expect(items.nth(0)).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(names).toHaveText([K1, K2]);
|
||||
await expect(submit).toBeDisabled();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
// Or its arrows.
|
||||
await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click();
|
||||
await panel.getByRole('button', { name: `Monter « ${K2} »` }).click();
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
await shot(page, 'c25-2-ordre-propose');
|
||||
await submit.click();
|
||||
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
|
||||
|
||||
// Its vote unfolds in the list of proposals, the list it proposes in sight.
|
||||
const fold = page.locator('ul.deep section.fold');
|
||||
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
||||
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
|
||||
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
await shot(page, 'c25-3-ordre-vote');
|
||||
|
||||
// In the basket too: at the end of its section.
|
||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||
|
||||
// A second voice brings it into force: the section reads in its new order.
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, 'Ordre des articles · Conclusion');
|
||||
await expect(yvv.getByText('Le nouvel ordre de « Conclusion » est entré en vigueur.')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
||||
// In force now: its vote stays unfolded, under the module, the list whole.
|
||||
await expect(order.locator('section.fold')).toContainText('2 pour · 0 contre');
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||
await expect(order).toContainText(/adopté le/);
|
||||
const rows = await page.locator('ul.deep button.row .title').allTextContents();
|
||||
expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1));
|
||||
});
|
||||
|
||||
test('C26 — le titre du document : proposé et voté en tête, ses traductions à revoir', async ({ page, browser }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
const head = page.locator('header.head');
|
||||
const bar = head.locator('.title-unit');
|
||||
await expect(bar).toContainText('Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote');
|
||||
await head.hover();
|
||||
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
|
||||
await expect(page).toHaveURL(/onglet=proposer/);
|
||||
const panel = page.locator('aside.panel');
|
||||
const input = panel.getByRole('textbox', { name: 'Nouveau titre' });
|
||||
await expect(input).toHaveValue("Acte d'engagement Certification");
|
||||
const submit = panel.getByRole('button', { name: 'Proposer un autre titre' });
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(panel.getByText("Le titre du document se vote à l'inertie 4 / 5 · haute.", { exact: false })).toBeVisible();
|
||||
const next = "Acte d'engagement de la certification";
|
||||
await input.fill(next);
|
||||
await shot(page, 'c26-1-titre-propose');
|
||||
await submit.click();
|
||||
await expect(page.getByText('Titre proposé au vote.')).toBeVisible();
|
||||
const fold = page.locator('ul.deep section.fold');
|
||||
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
||||
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, next);
|
||||
await expect(yvv.getByText(`Le titre « ${next} » est entré en vigueur.`)).toBeVisible();
|
||||
|
||||
// The document changes title; its translations stay editorial, to review.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(next);
|
||||
await expect(bar).toContainText(/adopté le/);
|
||||
await expect(bar.locator('.tag')).toHaveCount(0);
|
||||
// In force now: its vote stays unfolded, under the title.
|
||||
const vote = bar.locator('section.fold');
|
||||
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
||||
await expect(vote.getByText('2 pour · 0 contre')).toBeVisible();
|
||||
await shot(page, 'c26-2-titre-vote');
|
||||
await bar.getByRole('button', { name: 'Le vote' }).click();
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
|
||||
await expect(bar.locator('.tag')).toHaveText('translation needs review');
|
||||
await shot(page, 'c26-3-titre-traduction-a-revoir');
|
||||
});
|
||||
|
||||
test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que le groupe demande', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
// A group of four whose median is 2,5 voters, brought back to half of it by the scale.
|
||||
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = await res.json();
|
||||
body.doc.electorate = 4;
|
||||
body.doc.participation = { ...body.doc.participation, source: 'median', median: 2.5, sample: 12, bounded: true, voters: 2, ratio: 0.5, bounds: [0.05, 0.5] };
|
||||
await route.fulfill({ response: res, json: body });
|
||||
});
|
||||
await page.goto(TEST_DOC);
|
||||
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
// The measure as it is: 2,5 of 4 is 62,5 %, never 3 of 4 rounded first.
|
||||
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 shot(page, 'c27-participation-bornee');
|
||||
});
|
||||
|
||||
test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, la liste tient', async ({ browser }) => {
|
||||
// The chain is waited for: the server reads the indexer every 10 seconds.
|
||||
test.setTimeout(90_000);
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
|
||||
const page = await ctx.newPage();
|
||||
await signIn(page, 'Chiara');
|
||||
await wallet(page, [ALICE]);
|
||||
await page.goto(DOC);
|
||||
// The basket of Monnaie libre: the order of the fundamental commitments, alone, in one transaction.
|
||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByRole('group', { name: 'Ton choix pour « Ordre des articles · Engagements fondamentaux »' }).getByRole('button', { name: 'Pour' }).click();
|
||||
await panel.getByRole('button', { name: 'Signer et envoyer (1)' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
await expect(page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }).locator('.voted')).toHaveText(
|
||||
'Ton vote : pour · inscrit sur la chaîne Ğ1',
|
||||
);
|
||||
await fond.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-4-ordre-mobile');
|
||||
// The title on a phone: its gestures in sight, without a pointer to hover with.
|
||||
await page.goto(TEST_DOC);
|
||||
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
|
||||
await shot(page, 'c26-4-titre-mobile');
|
||||
// The list one clicks, on a phone: nothing overflows, the buttons are big enough to touch.
|
||||
const tech = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' });
|
||||
await tech.getByRole('button', { name: 'Proposer un autre ordre' }).click();
|
||||
const items = panel.getByRole('list', { name: 'Nouvel ordre des articles' }).locator('button.item');
|
||||
await expect(items).toHaveCount(11);
|
||||
await items.nth(3).click();
|
||||
await items.nth(0).click();
|
||||
await expect(panel.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled();
|
||||
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
|
||||
expect(holds).toBe(true);
|
||||
const box = await items.nth(0).boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await shot(page, 'c25-5-ordre-propose-mobile');
|
||||
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
|
||||
await ctx.close();
|
||||
});
|
||||
+24
-4
@@ -43,7 +43,9 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
|
||||
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é');
|
||||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
// 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);
|
||||
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);
|
||||
@@ -287,7 +289,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
// At 100 voters: C plus each level's share of the 50 votes beyond C.
|
||||
const required = panel.locator('table tbody td.required');
|
||||
await expect(required).toHaveText(['80', '84', '88', '92', '95']);
|
||||
await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible();
|
||||
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 page.keyboard.press('Escape');
|
||||
|
||||
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
|
||||
@@ -300,7 +303,7 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
await expect(panel.locator('tr.current th')).toContainText('4');
|
||||
await expect(panel.locator('tr.current th')).toContainText('cet article');
|
||||
await expect(required).toHaveText(['50', '50', '50', '50', '50']);
|
||||
await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible();
|
||||
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.
|
||||
@@ -311,9 +314,26 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
await e2.getByText("Proposer un changement d'inertie").click();
|
||||
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
|
||||
await expect(levels).toHaveCount(5);
|
||||
await expect(levels.nth(3)).toBeDisabled();
|
||||
// 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(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 levels.nth(3).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 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)).toHaveAttribute('aria-checked', 'true');
|
||||
// Or with a click.
|
||||
await levels.nth(4).click();
|
||||
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
||||
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||
|
||||
+29
-11
@@ -5,7 +5,7 @@
|
||||
// chooses one's version. Then one gesture sends it all: one write on the
|
||||
// server, or one transaction on the Ğ1 chain.
|
||||
|
||||
import type { Choice, Proposal, Unit } from './types';
|
||||
import type { Choice, Modules, Proposal, Unit } from './types';
|
||||
|
||||
/** A choice in the basket: for, against, or withdrawing one's vote. Absent: nothing. */
|
||||
export type Picks = Record<string, Choice>;
|
||||
@@ -25,22 +25,40 @@ export interface Section {
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
/** The articles of the document with something to vote, by section, in document order. */
|
||||
export function sections(units: Unit[]): Section[] {
|
||||
/** What a unit offers to vote: its version in force and open versions, its open changes of inertia. */
|
||||
function slotOf(unit: Unit): Slot | null {
|
||||
const open = unit.proposals.filter((p) => p.status === 'open');
|
||||
const versions = [
|
||||
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
||||
...open.filter((p) => p.kind !== 'inertia'),
|
||||
];
|
||||
const inertia = open.filter((p) => p.kind === 'inertia');
|
||||
return versions.length || inertia.length ? { unit, versions, inertia } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The articles of the document with something to vote, by section, in document order —
|
||||
* and its modules (ADR 0009): the title of the document first, the order of each section
|
||||
* at the end of it; an order with nothing to decide (one article) is left out.
|
||||
*/
|
||||
export function sections(units: Unit[], modules: Modules = { title: null, orders: [] }): Section[] {
|
||||
const out: Section[] = [];
|
||||
for (const unit of units) {
|
||||
const open = unit.proposals.filter((p) => p.status === 'open');
|
||||
const versions = [
|
||||
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
|
||||
...open.filter((p) => p.kind !== 'inertia'),
|
||||
];
|
||||
const inertia = open.filter((p) => p.kind === 'inertia');
|
||||
if (!versions.length && !inertia.length) continue;
|
||||
const slot = slotOf(unit);
|
||||
if (!slot) continue;
|
||||
const last = out.at(-1);
|
||||
const slot = { unit, versions, inertia };
|
||||
if (last && last.key === unit.section) last.slots.push(slot);
|
||||
else out.push({ key: unit.section, slots: [slot] });
|
||||
}
|
||||
for (const order of modules.orders) {
|
||||
const slot = order.moot ? null : slotOf(order);
|
||||
if (!slot) continue;
|
||||
const home = out.find((s) => s.key === order.section);
|
||||
if (home) home.slots.push(slot);
|
||||
else out.push({ key: order.section, slots: [slot] });
|
||||
}
|
||||
const title = modules.title && slotOf(modules.title);
|
||||
if (title) out.unshift({ key: null, slots: [title] });
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
||||
import type { BasketState } from '$lib/picks.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Proposal } from '$lib/types';
|
||||
@@ -30,12 +30,10 @@
|
||||
onread: (proposalId: string) => void;
|
||||
} = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
|
||||
const view = $derived(doc.view);
|
||||
const access = $derived(view?.vote);
|
||||
const all = $derived(sections(view?.units ?? []));
|
||||
// The modules too (ADR 0009): the title first, each order at the end of its section.
|
||||
const all = $derived(sections(view?.units ?? [], view?.modules));
|
||||
const slots = $derived(all.flatMap((s) => s.slots));
|
||||
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
|
||||
const toSend = $derived(lines(basket.picks, known));
|
||||
@@ -51,6 +49,7 @@
|
||||
});
|
||||
|
||||
function headline(s: Slot): string {
|
||||
if (s.unit.kind) return moduleName(s.unit);
|
||||
const text = s.versions.find((p) => p.kind === 'text');
|
||||
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
|
||||
}
|
||||
@@ -58,6 +57,9 @@
|
||||
/** What tells a version from the others of its article. */
|
||||
function labelOf(p: Proposal, s: Slot): string {
|
||||
if (p.status === 'in_force') return t('basket.inforce');
|
||||
// Another title reads as itself; another order, as a proposal (its list unfolds with its vote).
|
||||
if (p.kind === 'title') return p.title;
|
||||
if (p.kind === 'order') return p.author?.name ? t('panel.by', { name: p.author.name }) : t('module.order.proposal');
|
||||
if (p.kind !== 'text') return titleOf(p, s.unit);
|
||||
if (p.label) return t('deep.variant', { label: p.label });
|
||||
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// marks — and each unfolds its own vote.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { plain } from '$lib/markdown';
|
||||
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, moduleName, orderNote, relationOf, sectionLabel, titleOf } from '$lib/labels';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import { excerpts, type Article } from '$lib/versions';
|
||||
import Icon from './Icon.svelte';
|
||||
@@ -18,7 +18,9 @@
|
||||
ontoggle,
|
||||
fold,
|
||||
onsection,
|
||||
nameOf = (id) => id,
|
||||
}: {
|
||||
/** Articles and modules (ADR 0009) with open proposals, in document order. */
|
||||
articles: Article[];
|
||||
/** The version whose vote is unfolded. */
|
||||
vote: string | null;
|
||||
@@ -26,10 +28,16 @@
|
||||
fold: Snippet<[Unit, Proposal]>;
|
||||
/** Opens the basket on a section; absent when this session does not vote here. */
|
||||
onsection?: (section: string) => void;
|
||||
/** The title of an article, for the orders proposed. */
|
||||
nameOf?: (unitId: string) => string;
|
||||
} = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
/** What a line shows of a version: the start of its text, or the order it proposes. */
|
||||
function excerptOf(p: Proposal): string {
|
||||
if (p.kind === 'text') return plain(p.body);
|
||||
if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · ');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Unfolded by hand, or because the version whose vote is unfolded is inside.
|
||||
let unfolded = $state<Record<string, boolean>>({});
|
||||
@@ -38,6 +46,7 @@
|
||||
|
||||
/** The title of the article: its best text version, or what the best proposal is. */
|
||||
function headline(a: Article): string {
|
||||
if (a.unit.kind) return moduleName(a.unit);
|
||||
const text = a.versions.find((p) => p.kind === 'text');
|
||||
const best = a.versions[0];
|
||||
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
|
||||
@@ -45,7 +54,7 @@
|
||||
|
||||
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
|
||||
function labelOf(p: Proposal, a: Article): string {
|
||||
if (p.kind !== 'text') return titleOf(p, a.unit);
|
||||
if (p.kind === 'inertia' || p.kind === 'remove') return titleOf(p, a.unit);
|
||||
if (p.label) return t('deep.variant', { label: p.label });
|
||||
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
||||
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
|
||||
@@ -56,10 +65,18 @@
|
||||
function toldApart(a: Article): Map<string, string> {
|
||||
const textual = a.versions.filter((p) => p.kind === 'text');
|
||||
const told = excerpts(textual.map((p) => plain(p.body)));
|
||||
return new Map(textual.map((p, k) => [p.id, told[k] ?? plain(p.body)]));
|
||||
return new Map([
|
||||
...a.versions.map((p): [string, string] => [p.id, excerptOf(p)]),
|
||||
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet module(p: Proposal)}
|
||||
{#if p.kind === 'title'}<span class="faint">{t('module.title.name')}</span>{/if}
|
||||
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet marks(p: Proposal)}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
@@ -86,11 +103,12 @@
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(best, unit)}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
|
||||
{@render module(best)}
|
||||
{@render marks(best)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -106,7 +124,7 @@
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{title}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
||||
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
||||
@@ -126,10 +144,11 @@
|
||||
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
||||
<span class="label">{labelOf(p, a)}{#if (p.kind === 'text' || p.kind === 'title') && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
|
||||
{@render marks(p)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import type { BasketState } from '$lib/picks.svelte';
|
||||
import type { Lang } from '$lib/types';
|
||||
import type { Lang, Proposal } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import BallotPanel from './BallotPanel.svelte';
|
||||
import FormulaSettings from './FormulaSettings.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import OrderEditor from './OrderEditor.svelte';
|
||||
import ProposeView from './ProposeView.svelte';
|
||||
import TitlePropose from './TitlePropose.svelte';
|
||||
import VersionDetails from './VersionDetails.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
@@ -43,6 +45,11 @@
|
||||
const versions = $derived(
|
||||
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
|
||||
);
|
||||
// A module (ADR 0009) is proposed with its own gesture: another order, another title.
|
||||
const proposeLabel = $derived(unit?.kind === 'order' ? t('order.propose') : unit?.kind === 'title' ? t('title.propose') : t('doc.action.propose'));
|
||||
const nameOf = (id: string) => doc.articleTitle(id, lang, doc.view?.doc.sourceLang ?? 'fr');
|
||||
/** A version in the list: an order reads as its articles, in its order. */
|
||||
const nameOfVersion = (v: Proposal) => (v.kind === 'order' && v.order ? v.order.ids.map(nameOf).join(' · ') : titleOf(v, unit));
|
||||
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
|
||||
const tabs = $derived(
|
||||
(
|
||||
@@ -102,14 +109,18 @@
|
||||
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
||||
<span class="text">
|
||||
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<span class="title">{titleOf(v, unit)}</span>
|
||||
<span class="title">{nameOfVersion(v)}</span>
|
||||
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{t('doc.action.propose')}</button>
|
||||
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{proposeLabel}</button>
|
||||
{:else if panel.tab === 'propose' && unit?.kind === 'order'}
|
||||
<OrderEditor {doc} {unit} {nameOf} ondone={(id) => onvote(id, true)} oncancel={onclose} />
|
||||
{:else if panel.tab === 'propose' && unit?.kind === 'title'}
|
||||
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
|
||||
{:else if panel.tab === 'propose'}
|
||||
<ProposeView
|
||||
{doc}
|
||||
|
||||
@@ -15,6 +15,31 @@
|
||||
let error = $state('');
|
||||
const open = $derived(unit.proposals.filter((x) => x.kind === 'inertia' && x.status === 'open'));
|
||||
const level = (k: Inertia | null) => (k ? inertiaLevel(k) : '?');
|
||||
let group: HTMLElement | undefined = $state();
|
||||
|
||||
// A radiogroup with one stop in the tab order: the level chosen, else the current
|
||||
// one — reachable, read as « actuelle », never chosen. The arrows go from level to
|
||||
// level and choose the one they land on.
|
||||
function choose(k: Inertia) {
|
||||
if (k !== unit.inertia) target = k;
|
||||
}
|
||||
|
||||
function key(e: KeyboardEvent, k: Inertia) {
|
||||
const at = INERTIA_LEVELS.indexOf(k);
|
||||
const last = INERTIA_LEVELS.length - 1;
|
||||
const to =
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowDown' ? (at === last ? 0 : at + 1)
|
||||
: e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? (at === 0 ? last : at - 1)
|
||||
: e.key === 'Home' ? 0
|
||||
: e.key === 'End' ? last
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
const next = INERTIA_LEVELS[to];
|
||||
if (next === undefined) return;
|
||||
choose(next);
|
||||
group?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[to]?.focus();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!target) return;
|
||||
@@ -46,16 +71,19 @@
|
||||
{/each}
|
||||
<details>
|
||||
<summary>{t('inertia.change')}</summary>
|
||||
<div class="levels" role="radiogroup" aria-label={t('inertia.change.to')}>
|
||||
<div class="levels" role="radiogroup" aria-label={t('inertia.change.to')} bind:this={group}>
|
||||
{#each INERTIA_LEVELS as k (k)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={target === k}
|
||||
aria-disabled={k === unit.inertia}
|
||||
tabindex={k === (target ?? unit.inertia) ? 0 : -1}
|
||||
class:on={target === k}
|
||||
class:current={k === unit.inertia}
|
||||
disabled={k === unit.inertia || busy}
|
||||
onclick={() => (target = k)}
|
||||
disabled={busy}
|
||||
onclick={() => choose(k)}
|
||||
onkeydown={(e) => key(e, k)}
|
||||
>
|
||||
<span class="k">{k}</span>
|
||||
<span class="name">{t(`inertia.name.${k}`)}</span>
|
||||
@@ -118,7 +146,7 @@
|
||||
text-align: center;
|
||||
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
|
||||
}
|
||||
.levels button:hover:not(:disabled) {
|
||||
.levels button:hover:not(:disabled, .current) {
|
||||
background: var(--water-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -126,6 +154,10 @@
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.levels button:focus-visible {
|
||||
outline: 2px solid var(--water);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.levels button.on,
|
||||
.levels button.on:hover {
|
||||
background: var(--water);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
// Another order for a section, with a list one clicks (Yvv, 2026-10-30: « une
|
||||
// simple liste clipable »): an article, then its new place — or its arrows. With
|
||||
// the keyboard: Enter chooses an article, the up and down arrows move it. Then
|
||||
// « Proposer cet ordre »: the order goes to the vote, like an article (ADR 0009).
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaLevel } from '$lib/labels';
|
||||
import { moved, place, sameOrder, step } from '$lib/order';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Unit } from '$lib/types';
|
||||
import { errorText } from '$lib/vote';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
unit,
|
||||
nameOf,
|
||||
ondone,
|
||||
oncancel,
|
||||
}: {
|
||||
doc: DocState;
|
||||
/** The order module of the section. */
|
||||
unit: Unit;
|
||||
/** The title of an article, in the language it is read in. */
|
||||
nameOf: (unitId: string) => string;
|
||||
/** The order proposed: its vote unfolds. */
|
||||
ondone: (proposalId: string) => void;
|
||||
oncancel?: () => void;
|
||||
} = $props();
|
||||
|
||||
let list = $state<string[]>([]);
|
||||
let chosen = $state<string | null>(null);
|
||||
let said = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let root: HTMLElement | undefined = $state();
|
||||
const start = $derived(unit.members ?? []);
|
||||
const changed = $derived(moved(start, list));
|
||||
const unchanged = $derived(sameOrder(start, list));
|
||||
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Started again only for another section: a reload never wipes what is being moved.
|
||||
$effect(() => {
|
||||
void unit.id;
|
||||
untrack(() => {
|
||||
list = [...(unit.members ?? [])];
|
||||
chosen = null;
|
||||
error = '';
|
||||
});
|
||||
});
|
||||
|
||||
async function focus(id: string) {
|
||||
await tick();
|
||||
root?.querySelector<HTMLButtonElement>(`button.item[data-id="${CSS.escape(id)}"]`)?.focus();
|
||||
}
|
||||
|
||||
function tell(id: string) {
|
||||
said = t('order.edit.moved', { title: nameOf(id), n: list.indexOf(id) + 1, total: list.length });
|
||||
}
|
||||
|
||||
function pick(id: string) {
|
||||
error = '';
|
||||
if (chosen === null) {
|
||||
chosen = id;
|
||||
said = t('order.edit.chosen', { title: nameOf(id) });
|
||||
return;
|
||||
}
|
||||
if (chosen === id) {
|
||||
chosen = null;
|
||||
said = '';
|
||||
return;
|
||||
}
|
||||
const which = chosen;
|
||||
list = place(list, which, id);
|
||||
chosen = null;
|
||||
tell(which);
|
||||
void focus(which);
|
||||
}
|
||||
|
||||
function shift(id: string, by: -1 | 1) {
|
||||
error = '';
|
||||
list = step(list, id, by);
|
||||
tell(id);
|
||||
void focus(id);
|
||||
}
|
||||
|
||||
function key(e: KeyboardEvent, id: string) {
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
shift(id, e.key === 'ArrowUp' ? -1 : 1);
|
||||
} else if (e.key === 'Escape' && chosen) {
|
||||
e.stopPropagation();
|
||||
chosen = null;
|
||||
said = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (unchanged) {
|
||||
error = t('order.edit.unchanged');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const r = await api.post<{ id: string }>(`/units/${unit.id}/order`, { order: list });
|
||||
await doc.reload();
|
||||
toasts.show(t('order.submitted'));
|
||||
ondone(r.id);
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
|
||||
<ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
|
||||
{#each list as id, i (id)}
|
||||
{@const name = nameOf(id)}
|
||||
<li class:chosen={chosen === id} class:moved={changed.has(id)} animate:flip={{ duration: reduce ? 0 : 160 }}>
|
||||
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
|
||||
<span class="n" aria-hidden="true">{i + 1}</span>
|
||||
<span class="name">{name}</span>
|
||||
</button>
|
||||
<span class="arrows">
|
||||
<button type="button" class="arrow up" aria-label={t('order.edit.up', { title: name })} disabled={i === 0 || busy} onclick={() => shift(id, -1)}><Icon name="chevron" size={16} /></button>
|
||||
<button type="button" class="arrow down" aria-label={t('order.edit.down', { title: name })} disabled={i === list.length - 1 || busy} onclick={() => shift(id, 1)}><Icon name="chevron" size={16} /></button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="visually-hidden" aria-live="polite">{said}</p>
|
||||
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })}</p>
|
||||
<p class="faint small">{t('module.order.rule')}</p>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
{#if oncancel}<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>{/if}
|
||||
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t('order.edit.submit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
transition: background-color 0.15s var(--ease);
|
||||
}
|
||||
.list li.moved {
|
||||
background: var(--moss-wash);
|
||||
}
|
||||
.list li.chosen {
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 0.4rem 0.5rem 0.8rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.n {
|
||||
flex: none;
|
||||
min-width: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--water);
|
||||
}
|
||||
.chosen .n {
|
||||
color: inherit;
|
||||
}
|
||||
.name {
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.arrows {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 0.1rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-icon);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.arrow:hover:not(:disabled) {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.arrow:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.arrow.up :global(svg) {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.arrow.down :global(svg) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.list li {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// The order of the articles of a section, at its end (ADR 0009): a module at
|
||||
// inertia 2, read, voted and proposed with the gestures of an article.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, moduleName } from '$lib/labels';
|
||||
import type { Unit } from '$lib/types';
|
||||
import UnitBlock from './UnitBlock.svelte';
|
||||
|
||||
let {
|
||||
unit,
|
||||
nameOf,
|
||||
selected = false,
|
||||
voteOpen = false,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
/** The order module of the section. */
|
||||
unit: Unit;
|
||||
/** The title of an article, in the language it is read in. */
|
||||
nameOf: (unitId: string) => string;
|
||||
selected?: boolean;
|
||||
voteOpen?: boolean;
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(unit.inForce);
|
||||
const members = $derived(unit.members ?? []);
|
||||
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order').length);
|
||||
</script>
|
||||
|
||||
<UnitBlock
|
||||
text={{ title: moduleName(unit), body: '', translated: false, stale: false, missing: false }}
|
||||
module
|
||||
seedMinimal={!!p?.seedMinimal}
|
||||
enteredAt={p?.enteredAt ?? null}
|
||||
votes={(p?.tally.for ?? 0) + (p?.tally.against ?? 0)}
|
||||
{rivals}
|
||||
{selected}
|
||||
voteFor={p?.id ?? null}
|
||||
{voteOpen}
|
||||
proposeLabel={t('order.propose')}
|
||||
{fold}
|
||||
{onaction}
|
||||
>
|
||||
{#snippet meta()}
|
||||
<span class="meta faint">{i18n.plural('module.order.count', members.length)} · {inertiaBadge(unit.inertia)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<!-- In one breath, two lines at most: the whole list unfolds with its vote. -->
|
||||
<ol class="order" aria-label={t('module.order.inforce')}>
|
||||
{#each members as id, i (id)}<li><span class="n">{i + 1}</span> {nameOf(id)}</li>{/each}
|
||||
</ol>
|
||||
{/snippet}
|
||||
</UnitBlock>
|
||||
|
||||
<style>
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.order {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.order li {
|
||||
display: inline;
|
||||
}
|
||||
.order li + li::before {
|
||||
content: '·';
|
||||
margin: 0 0.45em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.n {
|
||||
font-weight: 800;
|
||||
color: var(--water);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { sectionLabel } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
|
||||
type Mode = 'replace' | 'remove' | 'new';
|
||||
@@ -28,7 +29,7 @@
|
||||
const draft = $derived(doc.proposal(draftId));
|
||||
let target = $state<string | null>(null);
|
||||
let mode = $state<Mode>('replace');
|
||||
let after = $state<string>('');
|
||||
let section = $state<string>('');
|
||||
let title = $state('');
|
||||
let body = $state('');
|
||||
let hints = $state<Hint[]>([]);
|
||||
@@ -37,7 +38,8 @@
|
||||
let savedId = $state<string | null>(null);
|
||||
|
||||
const unit = $derived(doc.unit(target));
|
||||
const units = $derived(doc.view?.units ?? []);
|
||||
// The sections of the document, in its order: a new article goes at the end of one (ADR 0009).
|
||||
const sections = $derived([...new Set((doc.view?.units ?? []).map((u) => u.section).filter((s): s is string => !!s))]);
|
||||
|
||||
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
|
||||
// which would wipe what the person is typing.
|
||||
@@ -49,7 +51,8 @@
|
||||
target = u0;
|
||||
savedId = d0;
|
||||
mode = u0 ? 'replace' : 'new';
|
||||
after = doc.inForce.at(-1)?.id ?? '';
|
||||
// The section of the article one comes from, else the last one.
|
||||
section = u?.section ?? doc.view?.units.at(-1)?.section ?? '';
|
||||
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
|
||||
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
|
||||
error = '';
|
||||
@@ -89,7 +92,7 @@
|
||||
const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, {
|
||||
kind: mode === 'remove' ? 'remove' : 'text',
|
||||
unitId: mode === 'new' ? null : target,
|
||||
afterUnitId: mode === 'new' ? after || null : null,
|
||||
section: mode === 'new' ? section || null : null,
|
||||
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
|
||||
body: mode === 'remove' ? '' : body,
|
||||
submit: false,
|
||||
@@ -162,14 +165,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'new'}
|
||||
{#if mode === 'new' && sections.length}
|
||||
<label class="field">
|
||||
<span>{t('propose.after')}</span>
|
||||
<select class="select" bind:value={after}>
|
||||
{#each units.filter((u) => u.inForce) as u (u.id)}<option value={u.id}>{u.inForce?.title}</option>{/each}
|
||||
<option value="">{t('propose.after.end')}</option>
|
||||
<span>{t('propose.section')}</span>
|
||||
<select class="select" bind:value={section}>
|
||||
{#each sections as s (s)}<option value={s}>{sectionLabel(s) ?? s}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="faint small">{t('propose.section.note')}</p>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'remove'}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
// The title of the document is a module (ADR 0009, inertia 4): under the title,
|
||||
// the gestures of an article — the vote, the proposals, another title.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, moduleName } from '$lib/labels';
|
||||
import type { Unit } from '$lib/types';
|
||||
|
||||
let {
|
||||
unit,
|
||||
stale = false,
|
||||
selected = false,
|
||||
voteOpen = false,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
/** The title module of the document. */
|
||||
unit: Unit;
|
||||
/** The title read is a translation of an older title. */
|
||||
stale?: boolean;
|
||||
selected?: boolean;
|
||||
voteOpen?: boolean;
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(unit.inForce);
|
||||
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
|
||||
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title').length);
|
||||
</script>
|
||||
|
||||
<div class="title-unit" class:selected class:unfolded={voteOpen}>
|
||||
<p class="bar">
|
||||
<span class="status faint">
|
||||
{moduleName(unit)} · {inertiaBadge(unit.inertia)}
|
||||
{#if p?.seedMinimal} · <span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
|
||||
</span>
|
||||
{#if stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{/if}
|
||||
<span class="links">
|
||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={p ? `vote-${p.id}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{t('title.propose')}</button>
|
||||
</span>
|
||||
</p>
|
||||
{#if voteOpen && fold}<div class="fold">{@render fold()}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.title-unit {
|
||||
margin: 0.5rem -1.3rem 0;
|
||||
padding: 0.2rem 1.3rem;
|
||||
border-radius: var(--r-card);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
.title-unit.selected,
|
||||
.title-unit.unfolded {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
padding-block: 0.5rem 0.9rem;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.status {
|
||||
font-weight: 700;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
/* Discreet as under an article: the gestures show when the head is pointed at. */
|
||||
:global(.head:hover) .links,
|
||||
.title-unit:focus-within .links,
|
||||
.title-unit.selected .links,
|
||||
.title-unit.unfolded .links {
|
||||
opacity: 1;
|
||||
}
|
||||
.links button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
padding: 0.3rem 0.6rem;
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links button:hover,
|
||||
.links button.on {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.fold {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.links {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.title-unit,
|
||||
.links {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
// Another title for the document (ADR 0009): a module at inertia 4, voted like
|
||||
// an article. Only the title in the source language is voted; its translations
|
||||
// stay editorial and will read « à revoir ».
|
||||
import { untrack } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaLevel } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Unit } from '$lib/types';
|
||||
import { errorText } from '$lib/vote';
|
||||
|
||||
let {
|
||||
doc,
|
||||
unit,
|
||||
ondone,
|
||||
}: {
|
||||
doc: DocState;
|
||||
/** The title module of the document. */
|
||||
unit: Unit;
|
||||
/** The title proposed: its vote unfolds. */
|
||||
ondone: (proposalId: string) => void;
|
||||
} = $props();
|
||||
|
||||
let title = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
const current = $derived(unit.inForce?.title ?? '');
|
||||
const ready = $derived(title.trim().length > 0 && title.trim() !== current);
|
||||
|
||||
$effect(() => {
|
||||
void unit.id;
|
||||
untrack(() => {
|
||||
title = unit.inForce?.title ?? '';
|
||||
error = '';
|
||||
});
|
||||
});
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const r = await api.post<{ id: string }>(`/units/${unit.id}/title`, { title: title.trim() });
|
||||
await doc.reload();
|
||||
toasts.show(t('title.submitted'));
|
||||
ondone(r.id);
|
||||
} catch (err) {
|
||||
error = errorText(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="form" onsubmit={submit}>
|
||||
<label class="field">
|
||||
<span>{t('title.propose.label')}</span>
|
||||
<input class="input" bind:value={title} maxlength="120" required />
|
||||
</label>
|
||||
<p class="faint small">{t('title.propose.note', { level: inertiaLevel(unit.inertia) })}</p>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
<button class="btn primary small" type="submit" disabled={busy || !ready}>{t('title.propose')}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-weight: 750;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,11 @@
|
||||
actions = true,
|
||||
voteFor = null,
|
||||
voteOpen = false,
|
||||
votes = 0,
|
||||
module = false,
|
||||
proposeLabel,
|
||||
meta,
|
||||
content,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
@@ -27,34 +32,53 @@
|
||||
/** The version « Le vote » unfolds, and whether it is unfolded. */
|
||||
voteFor?: string | null;
|
||||
voteOpen?: boolean;
|
||||
/** The votes on the version in force, shown next to the seed mark. */
|
||||
votes?: number;
|
||||
/** A module (ADR 0009): quieter than an article, its own words to propose. */
|
||||
module?: boolean;
|
||||
proposeLabel?: string;
|
||||
/** What the header adds after the title (a count, a level). */
|
||||
meta?: Snippet;
|
||||
/** In place of the text, for a module. */
|
||||
content?: Snippet;
|
||||
/** The vote, unfolded under the text. */
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="unit {mark}" class:selected class:unfolded={voteOpen}>
|
||||
<!-- A module is not an article of the text: a group named by its title. -->
|
||||
<svelte:element
|
||||
this={module ? 'div' : 'article'}
|
||||
class="unit {mark}"
|
||||
class:module
|
||||
class:selected
|
||||
class:unfolded={voteOpen}
|
||||
role={module ? 'group' : undefined}
|
||||
aria-label={module ? text.title : undefined}
|
||||
>
|
||||
<header>
|
||||
<h2 class="eyebrow">{text.title}</h2>
|
||||
{@render meta?.()}
|
||||
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
|
||||
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
|
||||
</header>
|
||||
<Prose text={text.body} />
|
||||
{#if content}{@render content()}{:else}<Prose text={text.body} />{/if}
|
||||
{#if actions}
|
||||
<footer class="actions">
|
||||
<span class="status faint">
|
||||
{#if seedMinimal}<span title={t('doc.seed.long')}>{t('doc.seed')} · 0 vote</span>
|
||||
{#if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||
</span>
|
||||
<span class="links">
|
||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
{#if voteOpen && fold}{@render fold()}{/if}
|
||||
</article>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.unit {
|
||||
@@ -74,6 +98,19 @@
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* A module closes its section: set apart, quieter than the articles. */
|
||||
.unit.module {
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-block: 0.85rem 0.6rem;
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.unit.module:hover,
|
||||
.unit.module:focus-within,
|
||||
.unit.module.selected,
|
||||
.unit.module.unfolded {
|
||||
background: var(--surface);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { shown, type DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { titleOf } from '$lib/labels';
|
||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import { announce } from '$lib/vote';
|
||||
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
|
||||
@@ -179,7 +179,7 @@
|
||||
<article class="details">
|
||||
<header>
|
||||
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
||||
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
|
||||
<h3>{p.kind === 'text' || 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.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||
@@ -200,6 +200,15 @@
|
||||
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
||||
{:else if p.kind === 'remove' && detail.inForce}
|
||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||
{:else if p.kind === 'order'}
|
||||
{@const order = doc.proposal(p.id)?.order}
|
||||
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
||||
{#each order?.ids ?? [] as id (id)}<li>{doc.articleTitle(id, lang, source)}</li>{/each}
|
||||
</ol>
|
||||
{#each orderNote({ ...p, order }) as note (note)}<p class="faint note">{note}</p>{/each}
|
||||
<p class="faint note">{t('module.order.rule')}</p>
|
||||
{:else if p.kind === 'title'}
|
||||
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
||||
{/if}
|
||||
|
||||
{#if votable}
|
||||
@@ -209,17 +218,17 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||
{#if (p.kind === 'text' || p.kind === 'title') && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
|
||||
<section class="sub">
|
||||
<details bind:open={typoOpen}>
|
||||
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
||||
<form class="form" onsubmit={saveTypo}>
|
||||
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
|
||||
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
{#if p.kind === 'text'}<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>{/if}
|
||||
{#if typoSubstantive}
|
||||
<p class="warn">{t('typo.substantive')}</p>
|
||||
<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>
|
||||
{#if p.kind === 'text'}<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>{/if}
|
||||
{/if}
|
||||
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
|
||||
</form>
|
||||
@@ -227,14 +236,14 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if lang !== source && p.kind === 'text'}
|
||||
{#if lang !== source && (p.kind === 'text' || p.kind === 'title')}
|
||||
<section class="sub">
|
||||
<details>
|
||||
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
|
||||
<form class="form" onsubmit={saveTranslation}>
|
||||
<p class="faint small">{t('translate.note')}</p>
|
||||
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
|
||||
<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>
|
||||
{#if p.kind === 'text'}<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>{/if}
|
||||
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -329,6 +338,13 @@
|
||||
.removed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.order {
|
||||
margin: 0;
|
||||
padding-left: 1.6rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font-family: var(--font-text);
|
||||
}
|
||||
.tally {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, titleOf } from '$lib/labels';
|
||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||
import { moved } from '$lib/order';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { LevelInfo, Proposal, Unit } from '$lib/types';
|
||||
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
@@ -55,6 +56,11 @@
|
||||
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
|
||||
const voted = $derived(p.myVote !== null && !p.myVoteStale);
|
||||
const ballot = $derived(p.myBallot);
|
||||
// An order (ADR 0009): what one votes on is the list — the moves of a proposal marked.
|
||||
const order = $derived(p.kind === 'order' ? p.order : undefined);
|
||||
const shifted = $derived(order && p.status !== 'in_force' ? moved(unit.members ?? [], order.ids) : new Set<string>());
|
||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
|
||||
|
||||
$effect(() => {
|
||||
const id = p.id;
|
||||
@@ -106,6 +112,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
|
||||
{#if order}
|
||||
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
||||
{#each order.ids as id (id)}<li class:shifted={shifted.has(id)}>{nameOf(id)}</li>{/each}
|
||||
</ol>
|
||||
{#each orderNote(p) as note (note)}<p class="faint small">{note}</p>{/each}
|
||||
{/if}
|
||||
|
||||
<div class="mine" aria-live="polite">
|
||||
{#if ballot?.status === 'pending'}
|
||||
@@ -190,6 +202,18 @@
|
||||
.state {
|
||||
font-weight: 700;
|
||||
}
|
||||
.order {
|
||||
margin: 0;
|
||||
padding-left: 1.6rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.order .shifted {
|
||||
color: var(--ink);
|
||||
font-weight: 750;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@@ -83,13 +83,25 @@ export class DocState {
|
||||
this.preview = r.units;
|
||||
}
|
||||
|
||||
/** The modules of the document (ADR 0009): its title, then the order of each section. */
|
||||
get modules(): Unit[] {
|
||||
const m = this.view?.modules;
|
||||
return m ? [...(m.title ? [m.title] : []), ...m.orders] : [];
|
||||
}
|
||||
|
||||
/** The order of a section, when it has something to decide (two articles or more). */
|
||||
orderOf(section: string | null): Unit | undefined {
|
||||
return this.view?.modules.orders.find((o) => o.section === section && !o.moot);
|
||||
}
|
||||
|
||||
/** An article or a module. */
|
||||
unit(id: string | null): Unit | undefined {
|
||||
return this.view?.units.find((u) => u.id === id);
|
||||
return this.view?.units.find((u) => u.id === id) ?? this.modules.find((u) => u.id === id);
|
||||
}
|
||||
|
||||
proposal(id: string | null): Proposal | undefined {
|
||||
if (!id || !this.view) return undefined;
|
||||
for (const u of this.view.units) {
|
||||
for (const u of [...this.view.units, ...this.modules]) {
|
||||
if (u.inForce?.id === id) return u.inForce;
|
||||
const p = u.proposals.find((x) => x.id === id);
|
||||
if (p) return p;
|
||||
@@ -101,15 +113,57 @@ export class DocState {
|
||||
return (this.view?.units ?? []).filter((u) => u.inForce);
|
||||
}
|
||||
|
||||
/** Open proposals, in document order; drafts apart. */
|
||||
/**
|
||||
* Articles and modules in document order (ADR 0009): the title first, then the
|
||||
* articles of each section followed by its order, when it has something to decide.
|
||||
*/
|
||||
get ordered(): Unit[] {
|
||||
const m = this.view?.modules;
|
||||
const out: Unit[] = m?.title ? [m.title] : [];
|
||||
const units = this.view?.units ?? [];
|
||||
units.forEach((u, i) => {
|
||||
out.push(u);
|
||||
if (units[i + 1]?.section === u.section) return;
|
||||
const order = this.orderOf(u.section);
|
||||
if (order) out.push(order);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text in force by section, each closed by its order (ADR 0009) — shown even
|
||||
* where no article is in force yet, as long as it has something to decide.
|
||||
*/
|
||||
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] {
|
||||
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = [];
|
||||
for (const u of this.view?.units ?? []) {
|
||||
let last = out.at(-1);
|
||||
if (!last || last.section !== u.section) {
|
||||
last = { section: u.section, units: [], order: this.orderOf(u.section) };
|
||||
out.push(last);
|
||||
}
|
||||
if (u.inForce) last.units.push(u);
|
||||
}
|
||||
return out.filter((s) => s.units.length || s.order);
|
||||
}
|
||||
|
||||
/** Open proposals, of articles and modules, in document order; drafts apart. */
|
||||
get open(): { unit: Unit; proposal: Proposal }[] {
|
||||
const out: { unit: Unit; proposal: Proposal }[] = [];
|
||||
for (const unit of this.view?.units ?? []) {
|
||||
for (const unit of this.ordered) {
|
||||
for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The title of an article as it is read: its text in force, else its latest open text. */
|
||||
articleTitle(id: string, lang: Lang, source: Lang): string {
|
||||
const u = this.view?.units.find((x) => x.id === id);
|
||||
if (!u) return id;
|
||||
const v = u.inForce ?? u.proposals.findLast((p) => p.status === 'open' && p.kind === 'text');
|
||||
return v ? shown(v, lang, source).title : u.code;
|
||||
}
|
||||
|
||||
get drafts(): { unit: Unit; proposal: Proposal }[] {
|
||||
const out: { unit: Unit; proposal: Proposal }[] = [];
|
||||
for (const unit of this.view?.units ?? []) {
|
||||
|
||||
+51
-11
@@ -116,7 +116,9 @@ export const en: Messages = {
|
||||
'doc.lang.stale': 'translation needs review',
|
||||
'doc.lang.missing': 'not translated yet',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': 'Came into force without a vote, so as not to start from a blank page. It remains open for voting: 0 votes so far.',
|
||||
'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.confirmed': 'confirmed by vote',
|
||||
'doc.adopted': 'adopted on {date}',
|
||||
'doc.action.vote': 'Voting',
|
||||
@@ -158,6 +160,34 @@ export const en: Messages = {
|
||||
'section.formule': 'Voting formula',
|
||||
'section.inertie': 'Inertia',
|
||||
'section.ordonnancement': 'Ordering',
|
||||
'module.title.name': 'Title of the document',
|
||||
'module.order.name': 'Order of the articles',
|
||||
'module.order.named': 'Order of the articles · {section}',
|
||||
'module.order.count.one': '{n} article',
|
||||
'module.order.count.other': '{n} articles',
|
||||
'module.order.inforce': 'Order in force',
|
||||
'module.order.proposal': 'Order proposed',
|
||||
'module.order.added.one': '{n} article added since, placed at the end',
|
||||
'module.order.added.other': '{n} articles added since, placed at the end',
|
||||
'module.order.dropped.one': '{n} article gone since',
|
||||
'module.order.dropped.other': '{n} articles gone since',
|
||||
'module.order.same': 'it is the order in force',
|
||||
'module.order.rule': 'A new article goes at the end of its section. If it arrives while an order is under vote, it is added at its end, and the votes stay.',
|
||||
'order.propose': 'Propose another order',
|
||||
'order.edit.label': 'New order of the articles',
|
||||
'order.edit.help': 'Click an article, then its new place, or use its arrows. With the keyboard: Enter to choose it, up and down arrows to move it.',
|
||||
'order.edit.up': 'Move “{title}” up',
|
||||
'order.edit.down': 'Move “{title}” down',
|
||||
'order.edit.chosen': '“{title}” chosen: click its new place.',
|
||||
'order.edit.moved': '“{title}” now in place {n} of {total}.',
|
||||
'order.edit.submit': 'Propose this order',
|
||||
'order.edit.unchanged': 'This is the order in force: move at least one article.',
|
||||
'order.edit.note': 'An order is voted at inertia {level}, like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre.',
|
||||
'order.submitted': 'Order submitted to vote.',
|
||||
'title.propose': 'Propose another title',
|
||||
'title.propose.label': 'New title',
|
||||
'title.propose.note': 'The title of the document is voted at inertia {level}. Its translations stay editorial: they will read “to review”.',
|
||||
'title.submitted': 'Title submitted to vote.',
|
||||
|
||||
'tally.short': '{for} for · {against} against',
|
||||
'tally.required': 'Threshold: {n} votes in favor',
|
||||
@@ -202,6 +232,8 @@ export const en: Messages = {
|
||||
'vote.entered': '“{title}” has come into force.',
|
||||
'vote.entered.remove': '“{title}” has been removed from the text in force.',
|
||||
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
|
||||
'vote.entered.order': 'The new order of “{section}” has come into force.',
|
||||
'vote.entered.title': 'The title “{title}” has come into force.',
|
||||
'vote.closed': 'This version is no longer open for voting.',
|
||||
'vote.fold': 'The vote on “{title}”',
|
||||
'vote.mine.none': 'You have not voted on this version.',
|
||||
@@ -305,8 +337,8 @@ export const en: Messages = {
|
||||
'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.bounded.one': 'Kept between {lo} and {hi} of the electorate: thresholds are worked out on {n} voter ({pct}).',
|
||||
'settings.usual.bounded.other': 'Kept between {lo} and {hi} of the electorate: thresholds are worked out on {n} voters ({pct}).',
|
||||
'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}.',
|
||||
'settings.usual.never': 'Measured once a day.',
|
||||
'settings.levels': 'The five levels',
|
||||
@@ -314,12 +346,12 @@ export const en: Messages = {
|
||||
'settings.col.share': 'Share asked',
|
||||
'settings.col.required': 'Votes for',
|
||||
'settings.current': 'this article',
|
||||
'settings.share.note.one': 'Share asked: the share of votes in favour beyond C when {n} person votes, as usual.',
|
||||
'settings.share.note.other': 'Share asked: the share of votes in favour beyond C when {n} people vote, as usual.',
|
||||
'settings.required.version.one': 'Votes for: those asked at the turnout of this version, {n} voter.',
|
||||
'settings.required.version.other': 'Votes for: those asked at the turnout of this version, {n} voters.',
|
||||
'settings.required.usual.one': 'Votes for: those asked at the usual turnout, {n} voter.',
|
||||
'settings.required.usual.other': 'Votes for: those asked at the usual turnout, {n} voters.',
|
||||
'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.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.',
|
||||
|
||||
'history.title': 'Corrections',
|
||||
@@ -352,8 +384,8 @@ export const en: Messages = {
|
||||
'propose.mode.replace': 'Change this text',
|
||||
'propose.mode.remove': 'Remove this article',
|
||||
'propose.mode.new': 'New article',
|
||||
'propose.after': 'Place after',
|
||||
'propose.after.end': 'at the end',
|
||||
'propose.section': 'In the section',
|
||||
'propose.section.note': 'The new article goes at the end of the section; its order is voted apart, at the end of the section.',
|
||||
'propose.title': 'Title',
|
||||
'propose.body': 'Text',
|
||||
'propose.remove.note': 'A removal proposal is voted on like any other; if it passes, the article leaves the text in force.',
|
||||
@@ -483,6 +515,14 @@ export const en: Messages = {
|
||||
'error.author': 'This account is not a Ğ1 address.',
|
||||
'error.not_votable': 'This version is no longer open for voting.',
|
||||
'error.members_only': 'Only members of this group vote here.',
|
||||
'error.module': 'A module is proposed in its own place: the title at the head of the document, the order at the end of its section.',
|
||||
'error.order': 'This order must hold every article of the section, once each: reload the page, then try again.',
|
||||
'error.same_order': 'This is already the order in force.',
|
||||
'error.order_fixed': 'An order is not corrected: propose another order.',
|
||||
'error.not_an_order': 'This module is not the order of a section.',
|
||||
'error.not_a_title': 'This module is not the title of the document.',
|
||||
'error.same_title': 'This is already the title in force.',
|
||||
'error.section': 'This section does not exist in this document.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
+51
-11
@@ -116,7 +116,9 @@ export const es: Messages = {
|
||||
'doc.lang.stale': 'traducción por revisar',
|
||||
'doc.lang.missing': 'aún sin traducir',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': 'Entró en vigor sin votación, para no partir de una página en blanco. Sigue en votación: 0 votos por ahora.',
|
||||
'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.confirmed': 'confirmado por la votación',
|
||||
'doc.adopted': 'adoptado el {date}',
|
||||
'doc.action.vote': 'Votación',
|
||||
@@ -158,6 +160,34 @@ export const es: Messages = {
|
||||
'section.formule': 'Fórmula de votación',
|
||||
'section.inertie': 'Inercia',
|
||||
'section.ordonnancement': 'Ordenación',
|
||||
'module.title.name': 'Título del documento',
|
||||
'module.order.name': 'Orden de los artículos',
|
||||
'module.order.named': 'Orden de los artículos · {section}',
|
||||
'module.order.count.one': '{n} artículo',
|
||||
'module.order.count.other': '{n} artículos',
|
||||
'module.order.inforce': 'Orden en vigor',
|
||||
'module.order.proposal': 'Orden propuesto',
|
||||
'module.order.added.one': '{n} artículo añadido desde entonces, al final',
|
||||
'module.order.added.other': '{n} artículos añadidos desde entonces, al final',
|
||||
'module.order.dropped.one': '{n} artículo salido desde entonces',
|
||||
'module.order.dropped.other': '{n} artículos salidos desde entonces',
|
||||
'module.order.same': 'es el orden en vigor',
|
||||
'module.order.rule': 'Un artículo nuevo se coloca al final de su sección. Si llega durante la votación de un orden, se añade al final, y los votos se mantienen.',
|
||||
'order.propose': 'Proponer otro orden',
|
||||
'order.edit.label': 'Nuevo orden de los artículos',
|
||||
'order.edit.help': 'Haz clic en un artículo y luego en su nuevo lugar, o usa sus flechas. Con el teclado: Intro para elegirlo, flechas arriba y abajo para moverlo.',
|
||||
'order.edit.up': 'Subir «{title}»',
|
||||
'order.edit.down': 'Bajar «{title}»',
|
||||
'order.edit.chosen': '«{title}» elegido: haz clic en su nuevo lugar.',
|
||||
'order.edit.moved': '«{title}» en el lugar {n} de {total}.',
|
||||
'order.edit.submit': 'Proponer este orden',
|
||||
'order.edit.unchanged': 'Es el orden en vigor: mueve al menos un artículo.',
|
||||
'order.edit.note': 'Un orden se vota con la inercia {level}, como un artículo: desplegado, en la cesta, por la cadena Ğ1 en Monnaie libre.',
|
||||
'order.submitted': 'Orden sometido a votación.',
|
||||
'title.propose': 'Proponer otro título',
|
||||
'title.propose.label': 'Nuevo título',
|
||||
'title.propose.note': 'El título del documento se vota con la inercia {level}. Sus traducciones siguen siendo editoriales: pasarán a «por revisar».',
|
||||
'title.submitted': 'Título sometido a votación.',
|
||||
|
||||
'tally.short': '{for} a favor · {against} en contra',
|
||||
'tally.required': 'Umbral: {n} votos a favor',
|
||||
@@ -202,6 +232,8 @@ export const es: Messages = {
|
||||
'vote.entered': '«{title}» ha entrado en vigor.',
|
||||
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
|
||||
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
|
||||
'vote.entered.order': 'El nuevo orden de «{section}» ha entrado en vigor.',
|
||||
'vote.entered.title': 'El título «{title}» ha entrado en vigor.',
|
||||
'vote.closed': 'Esta versión ya no está en votación.',
|
||||
'vote.fold': 'La votación de «{title}»',
|
||||
'vote.mine.none': 'No has votado esta versión.',
|
||||
@@ -305,8 +337,8 @@ export const es: Messages = {
|
||||
'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.bounded.one': 'Mantenida entre el {lo} y el {hi} del electorado: los umbrales se calculan sobre {n} votante ({pct}).',
|
||||
'settings.usual.bounded.other': 'Mantenida entre el {lo} y el {hi} del electorado: los umbrales se calculan sobre {n} votantes ({pct}).',
|
||||
'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}.',
|
||||
'settings.usual.never': 'Medida una vez al día.',
|
||||
'settings.levels': 'Los cinco niveles',
|
||||
@@ -314,12 +346,12 @@ export const es: Messages = {
|
||||
'settings.col.share': 'Proporción exigida',
|
||||
'settings.col.required': 'Votos a favor',
|
||||
'settings.current': 'este artículo',
|
||||
'settings.share.note.one': 'Proporción exigida: la parte de votos a favor más allá de C cuando vota {n} persona, como de costumbre.',
|
||||
'settings.share.note.other': 'Proporción exigida: la parte de votos a favor más allá de C cuando votan {n} personas, como de costumbre.',
|
||||
'settings.required.version.one': 'Votos a favor: los exigidos con la participación de esta versión, {n} votante.',
|
||||
'settings.required.version.other': 'Votos a favor: los exigidos con la participación de esta versión, {n} votantes.',
|
||||
'settings.required.usual.one': 'Votos a favor: los exigidos con la participación habitual, {n} votante.',
|
||||
'settings.required.usual.other': 'Votos a favor: los exigidos con la participación habitual, {n} votantes.',
|
||||
'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.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.',
|
||||
|
||||
'history.title': 'Correcciones',
|
||||
@@ -352,8 +384,8 @@ export const es: Messages = {
|
||||
'propose.mode.replace': 'Modificar este texto',
|
||||
'propose.mode.remove': 'Retirar este artículo',
|
||||
'propose.mode.new': 'Artículo nuevo',
|
||||
'propose.after': 'Colocar después de',
|
||||
'propose.after.end': 'al final',
|
||||
'propose.section': 'En la sección',
|
||||
'propose.section.note': 'El nuevo artículo se coloca al final de la sección; su orden se vota aparte, al final de la sección.',
|
||||
'propose.title': 'Título',
|
||||
'propose.body': 'Texto',
|
||||
'propose.remove.note': 'La propuesta de retirada se vota como cualquier otra; si se aprueba, el artículo sale del texto en vigor.',
|
||||
@@ -483,6 +515,14 @@ export const es: Messages = {
|
||||
'error.author': 'Esta cuenta no es una dirección Ğ1.',
|
||||
'error.not_votable': 'Esta versión ya no está en votación.',
|
||||
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
|
||||
'error.module': 'Un módulo se propone en su sitio: el título al principio del documento, el orden al final de su sección.',
|
||||
'error.order': 'Este orden debe incluir cada artículo de la sección, una sola vez: recarga la página y vuelve a intentarlo.',
|
||||
'error.same_order': 'Ya es el orden en vigor.',
|
||||
'error.order_fixed': 'Un orden no se corrige: propón otro orden.',
|
||||
'error.not_an_order': 'Este módulo no es el orden de una sección.',
|
||||
'error.not_a_title': 'Este módulo no es el título del documento.',
|
||||
'error.same_title': 'Ya es el título en vigor.',
|
||||
'error.section': 'Esta sección no existe en este documento.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
'common.cancel': 'Cancelar',
|
||||
|
||||
+51
-11
@@ -119,7 +119,9 @@ export const fr = {
|
||||
'doc.lang.stale': 'traduction à revoir',
|
||||
'doc.lang.missing': 'pas encore traduit',
|
||||
'doc.seed': 'seed a minima',
|
||||
'doc.seed.long': "Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote : 0 vote pour l'instant.",
|
||||
'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.confirmed': 'confirmé par le vote',
|
||||
'doc.adopted': 'adopté le {date}',
|
||||
'doc.action.vote': 'Le vote',
|
||||
@@ -161,6 +163,34 @@ export const fr = {
|
||||
'section.formule': 'Formule de vote',
|
||||
'section.inertie': 'Inertie',
|
||||
'section.ordonnancement': 'Ordonnancement',
|
||||
'module.title.name': 'Titre du document',
|
||||
'module.order.name': 'Ordre des articles',
|
||||
'module.order.named': 'Ordre des articles · {section}',
|
||||
'module.order.count.one': '{n} article',
|
||||
'module.order.count.other': '{n} articles',
|
||||
'module.order.inforce': 'Ordre en vigueur',
|
||||
'module.order.proposal': 'Ordre proposé',
|
||||
'module.order.added.one': '{n} article entré depuis, placé à la fin',
|
||||
'module.order.added.other': '{n} articles entrés depuis, placés à la fin',
|
||||
'module.order.dropped.one': '{n} article sorti depuis',
|
||||
'module.order.dropped.other': '{n} articles sortis depuis',
|
||||
'module.order.same': "c'est l'ordre en vigueur",
|
||||
'module.order.rule': "Un nouvel article se place à la fin de sa section. S'il arrive pendant le vote d'un ordre, il s'y ajoute à la fin, et les votes restent.",
|
||||
'order.propose': 'Proposer un autre ordre',
|
||||
'order.edit.label': 'Nouvel ordre des articles',
|
||||
'order.edit.help': 'Clique un article, puis sa nouvelle place, ou utilise ses flèches. Au clavier : Entrée pour le choisir, flèches haut et bas pour le déplacer.',
|
||||
'order.edit.up': 'Monter « {title} »',
|
||||
'order.edit.down': 'Descendre « {title} »',
|
||||
'order.edit.chosen': '« {title} » choisi : clique sa nouvelle place.',
|
||||
'order.edit.moved': '« {title} » en place {n} sur {total}.',
|
||||
'order.edit.submit': 'Proposer cet ordre',
|
||||
'order.edit.unchanged': "C'est l'ordre en vigueur : déplace au moins un article.",
|
||||
'order.edit.note': "Un ordre se vote à l'inertie {level}, comme un article : déplié, dans le panier, par la chaîne Ğ1 dans Monnaie libre.",
|
||||
'order.submitted': 'Ordre proposé au vote.',
|
||||
'title.propose': 'Proposer un autre titre',
|
||||
'title.propose.label': 'Nouveau titre',
|
||||
'title.propose.note': "Le titre du document se vote à l'inertie {level}. Ses traductions restent éditoriales : elles passeront « à revoir ».",
|
||||
'title.submitted': 'Titre proposé au vote.',
|
||||
|
||||
'tally.short': '{for} pour · {against} contre',
|
||||
'tally.required': 'Seuil : {n} voix pour',
|
||||
@@ -205,6 +235,8 @@ export const fr = {
|
||||
'vote.entered': '« {title} » est entré en vigueur.',
|
||||
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
|
||||
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
|
||||
'vote.entered.order': 'Le nouvel ordre de « {section} » est entré en vigueur.',
|
||||
'vote.entered.title': 'Le titre « {title} » est entré en vigueur.',
|
||||
'vote.closed': 'Cette version ne se vote plus.',
|
||||
'vote.fold': 'Le vote de « {title} »',
|
||||
'vote.mine.none': "Tu n'as pas voté sur cette version.",
|
||||
@@ -308,8 +340,8 @@ export const fr = {
|
||||
'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.bounded.one': "Ramenée entre {lo} et {hi} de l'électorat : les seuils se calculent sur {n} votant ({pct}).",
|
||||
'settings.usual.bounded.other': "Ramenée entre {lo} et {hi} de l'électorat : les seuils se calculent sur {n} votants ({pct}).",
|
||||
'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}.',
|
||||
'settings.usual.never': 'Mesurée une fois par jour.',
|
||||
'settings.levels': 'Les cinq crans',
|
||||
@@ -317,12 +349,12 @@ export const fr = {
|
||||
'settings.col.share': 'Part exigée',
|
||||
'settings.col.required': 'Voix pour',
|
||||
'settings.current': 'cet article',
|
||||
'settings.share.note.one': "Part exigée : la part de « pour » au-delà de C quand {n} personne vote, comme d'habitude.",
|
||||
'settings.share.note.other': "Part exigée : la part de « pour » au-delà de C quand {n} personnes votent, comme d'habitude.",
|
||||
'settings.required.version.one': 'Voix pour : celles exigées à la participation de cette version, {n} votant.',
|
||||
'settings.required.version.other': 'Voix pour : celles exigées à la participation de cette version, {n} votants.',
|
||||
'settings.required.usual.one': 'Voix pour : celles exigées à la participation habituelle, {n} votant.',
|
||||
'settings.required.usual.other': 'Voix pour : celles exigées à la participation habituelle, {n} votants.',
|
||||
'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.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.",
|
||||
|
||||
'history.title': 'Corrections',
|
||||
@@ -355,8 +387,8 @@ export const fr = {
|
||||
'propose.mode.replace': 'Modifier ce texte',
|
||||
'propose.mode.remove': 'Retirer cet article',
|
||||
'propose.mode.new': 'Nouvel article',
|
||||
'propose.after': 'Placer après',
|
||||
'propose.after.end': 'à la fin',
|
||||
'propose.section': 'Dans la section',
|
||||
'propose.section.note': 'Le nouvel article se place à la fin de la section ; son ordre se vote à part, en fin de section.',
|
||||
'propose.title': 'Titre',
|
||||
'propose.body': 'Texte',
|
||||
'propose.remove.note': 'La proposition de retrait se vote comme une autre ; si elle passe, l’article sort du texte en vigueur.',
|
||||
@@ -486,6 +518,14 @@ export const fr = {
|
||||
'error.author': "Ce compte n'est pas une adresse Ğ1.",
|
||||
'error.not_votable': 'Cette version ne se vote plus.',
|
||||
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
|
||||
'error.module': "Un module se propose à sa place : le titre en tête du document, l'ordre en fin de section.",
|
||||
'error.order': 'Cet ordre doit reprendre chaque article de la section, une seule fois : recharge la page, puis recommence.',
|
||||
'error.same_order': "C'est déjà l'ordre en vigueur.",
|
||||
'error.order_fixed': 'Un ordre ne se corrige pas : propose un autre ordre.',
|
||||
'error.not_an_order': "Ce module n'est pas l'ordre d'une section.",
|
||||
'error.not_a_title': "Ce module n'est pas le titre du document.",
|
||||
'error.same_title': "C'est déjà le titre en vigueur.",
|
||||
'error.section': "Cette section n'existe pas dans ce document.",
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
'common.cancel': 'Annuler',
|
||||
|
||||
+29
-2
@@ -1,4 +1,4 @@
|
||||
import { t } from './i18n/index.svelte';
|
||||
import { i18n, t, type Key } from './i18n/index.svelte';
|
||||
import type { Inertia, Proposal, Unit } from './types';
|
||||
|
||||
/** « 3 / 5 · moyenne ». */
|
||||
@@ -11,18 +11,45 @@ export function inertiaBadge(k: Inertia): string {
|
||||
return t('inertia.badge', { level: inertiaLevel(k) });
|
||||
}
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
|
||||
/** The name of a section of the seed, or null. */
|
||||
export function sectionLabel(s: string | null | undefined): string | null {
|
||||
return s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null;
|
||||
}
|
||||
|
||||
/** The name of a module (ADR 0009): « Titre du document », « Ordre des articles · Conclusion ». */
|
||||
export function moduleName(unit: Unit): string {
|
||||
if (unit.kind === 'title') return t('module.title.name');
|
||||
const section = sectionLabel(unit.section) ?? unit.section;
|
||||
return section ? t('module.order.named', { section }) : t('module.order.name');
|
||||
}
|
||||
|
||||
/** What a proposal is, in words, whatever its kind. */
|
||||
export function titleOf(p: Proposal, unit: Unit | undefined): string {
|
||||
if (p.kind === 'inertia' && p.inertiaTarget) {
|
||||
return t('deep.inertia', { level: inertiaLevel(p.inertiaTarget) });
|
||||
}
|
||||
if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title });
|
||||
if (p.kind === 'order') return unit ? moduleName(unit) : t('module.order.name');
|
||||
return p.title;
|
||||
}
|
||||
|
||||
/** Its relation to the text in force. */
|
||||
export function relationOf(p: Proposal, unit: Unit | undefined): string {
|
||||
if (p.kind !== 'text') return '';
|
||||
if (p.kind !== 'text' && p.kind !== 'title') return '';
|
||||
if (unit?.inForce && unit.inForce.id !== p.id) return t('deep.rival', { title: unit.inForce.title });
|
||||
return '';
|
||||
}
|
||||
|
||||
/** What an order version says of itself: completed at the end, or the order in force already. */
|
||||
export function orderNote(p: Proposal): string[] {
|
||||
const o = p.order;
|
||||
if (!o || p.status === 'in_force') return [];
|
||||
const notes: string[] = [];
|
||||
if (o.added.length) notes.push(i18n.plural('module.order.added', o.added.length));
|
||||
if (o.dropped.length) notes.push(i18n.plural('module.order.dropped', o.dropped.length));
|
||||
if (o.same) notes.push(t('module.order.same'));
|
||||
return notes;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// The order of the articles of a section, as a person proposes another one
|
||||
// (Yvv, 2026-10-30: « une simple liste clipable »): click an article, then its
|
||||
// new place — or move it with its arrows. The rules of the vote (who is in the
|
||||
// order, an order completed at the end) are the server's (ADR 0009).
|
||||
|
||||
/** The article `chosen` takes the place of `target`; the others close up. */
|
||||
export function place(list: string[], chosen: string, target: string): string[] {
|
||||
const from = list.indexOf(chosen);
|
||||
const to = list.indexOf(target);
|
||||
if (from < 0 || to < 0 || from === to) return list;
|
||||
const next = list.filter((id) => id !== chosen);
|
||||
next.splice(to, 0, chosen);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** One place up (-1) or down (1), never beyond the ends. */
|
||||
export function step(list: string[], id: string, by: -1 | 1): string[] {
|
||||
const from = list.indexOf(id);
|
||||
const to = from + by;
|
||||
if (from < 0 || to < 0 || to >= list.length) return list;
|
||||
const target = list[to];
|
||||
return target === undefined ? list : place(list, id, target);
|
||||
}
|
||||
|
||||
export function sameOrder(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((id, i) => b[i] === id);
|
||||
}
|
||||
|
||||
/** The articles whose place changed from `before` to `after`. */
|
||||
export function moved(before: string[], after: string[]): Set<string> {
|
||||
return new Set(after.filter((id, i) => before[i] !== id));
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import type { ParticipationInfo } from './types';
|
||||
/**
|
||||
* The usual participation as the settings tell it: the measured median, or the
|
||||
* reference (twice C). When the scale brings it back within its bounds, the
|
||||
* bounded value is what the formula uses, and is said apart.
|
||||
* bounded value is what the formula uses, and is said apart. The number of
|
||||
* voters is rounded for reading; its share is that of the value itself (a
|
||||
* median of 2,5 voters out of 4 is 62,5 %, not 3 out of 4).
|
||||
*/
|
||||
export function usualShown(p: ParticipationInfo, electorate: number, floor: number): { voters: number; ratio: number } {
|
||||
if (!p.bounded || electorate <= 0) return { voters: p.voters, ratio: p.ratio };
|
||||
const voters = p.source === 'median' && p.median !== null ? Math.round(p.median) : Math.round(2 * floor);
|
||||
return { voters, ratio: voters / electorate };
|
||||
const raw = p.source === 'median' && p.median !== null ? p.median : 2 * floor;
|
||||
return { voters: Math.round(raw), ratio: raw / electorate };
|
||||
}
|
||||
|
||||
+31
-2
@@ -74,10 +74,24 @@ export interface Translation {
|
||||
origin: 'seed' | 'member';
|
||||
}
|
||||
|
||||
/** What an order version gives its section now (ADR 0009): completed at the end. */
|
||||
export interface OrderInfo {
|
||||
/** The articles (unit ids), in this order. */
|
||||
ids: string[];
|
||||
/** Entered the section since it was proposed: at the end. */
|
||||
added: string[];
|
||||
/** Left the section since: dropped. */
|
||||
dropped: string[];
|
||||
/** The order in force already: it changes nothing. */
|
||||
same: boolean;
|
||||
unreadable?: boolean;
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
unitId: string;
|
||||
kind: 'text' | 'remove' | 'inertia';
|
||||
/** A text, a removal or a change of inertia of an article; the title or the order of a module. */
|
||||
kind: 'text' | 'remove' | 'inertia' | 'title' | 'order';
|
||||
inertiaTarget: Inertia | null;
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -105,6 +119,8 @@ export interface Proposal {
|
||||
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
|
||||
myBallot: MyBallot | null;
|
||||
translations: Partial<Record<Lang, Translation>>;
|
||||
/** An order version: the order it gives its section now. */
|
||||
order?: OrderInfo;
|
||||
}
|
||||
|
||||
export type Choice = 'for' | 'against' | 'none';
|
||||
@@ -162,6 +178,18 @@ export interface Unit {
|
||||
inertiaChanges: InertiaChange[];
|
||||
inForce: Proposal | null;
|
||||
proposals: Proposal[];
|
||||
/** A module (ADR 0009); an article has none. */
|
||||
kind?: 'title' | 'order';
|
||||
/** An order: the articles of its section, in the order they read now. */
|
||||
members?: string[];
|
||||
/** An order of fewer than two articles: nothing to decide, not shown. */
|
||||
moot?: boolean;
|
||||
}
|
||||
|
||||
/** What a document decides besides its articles: its title, the order of each section. */
|
||||
export interface Modules {
|
||||
title: Unit | null;
|
||||
orders: Unit[];
|
||||
}
|
||||
|
||||
export interface Publication {
|
||||
@@ -193,6 +221,7 @@ export interface DocView {
|
||||
publication: Publication;
|
||||
};
|
||||
units: Unit[];
|
||||
modules: Modules;
|
||||
/** The five levels, with the *for* votes asked at the usual turnout. */
|
||||
levels: LevelInfo[];
|
||||
}
|
||||
@@ -213,5 +242,5 @@ export interface Entered {
|
||||
proposalId: string;
|
||||
unitId: string;
|
||||
code: string;
|
||||
kind: 'text' | 'remove' | 'inertia';
|
||||
kind: 'text' | 'remove' | 'inertia' | 'title' | 'order';
|
||||
}
|
||||
|
||||
+10
-3
@@ -5,6 +5,7 @@ import { api, ApiError } from './api';
|
||||
import type { DocState } from './doc.svelte';
|
||||
import { i18n, t, type Key } from './i18n/index.svelte';
|
||||
import { toasts } from './toast.svelte';
|
||||
import { moduleName, sectionLabel } from './labels';
|
||||
import type { Entered } from './types';
|
||||
|
||||
/** A line of a vote: one version, for or against, or null to withdraw one's vote. */
|
||||
@@ -26,9 +27,15 @@ export function errorText(e: unknown): string {
|
||||
export function announce(doc: DocState, entered: Entered[]) {
|
||||
for (const e of entered) {
|
||||
const u = doc.unit(e.unitId);
|
||||
const title = u?.inForce?.title ?? u?.code ?? '';
|
||||
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
|
||||
toasts.show(t(key, { title }), 'rise', 6000);
|
||||
// A module is named for what it is (ADR 0009); an article, by its title.
|
||||
const title = u?.kind ? moduleName(u) : (u?.inForce?.title ?? u?.code ?? '');
|
||||
const said =
|
||||
e.kind === 'order'
|
||||
? t('vote.entered.order', { section: sectionLabel(u?.section) ?? u?.section ?? '' })
|
||||
: e.kind === 'title'
|
||||
? t('vote.entered.title', { title: u?.inForce?.title ?? '' })
|
||||
: t(e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered', { title });
|
||||
toasts.show(said, 'rise', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import OrderModule from '$lib/components/OrderModule.svelte';
|
||||
import TitleBar from '$lib/components/TitleBar.svelte';
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
@@ -28,7 +30,7 @@
|
||||
$effect(() => {
|
||||
const v = doc.view;
|
||||
if (!v) return;
|
||||
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
const known = new Set(sections(v.units, v.modules).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
|
||||
basket.load(v.doc.id, known);
|
||||
});
|
||||
|
||||
@@ -76,7 +78,7 @@
|
||||
const sign = (lines: Line[]) => (signing = lines);
|
||||
// While a ballot waits for the chain, the page looks again every ten seconds.
|
||||
const waitingChain = $derived(
|
||||
(doc.view?.units ?? []).some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
|
||||
doc.ordered.some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!waitingChain) return;
|
||||
@@ -93,6 +95,10 @@
|
||||
// One line per article; an article stays when one of its versions passes the filter.
|
||||
const deep = $derived(articles(doc.open, filter));
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
|
||||
// The title of the document, a module (ADR 0009): its gestures under the title.
|
||||
const titleUnit = $derived(view?.modules.title ?? null);
|
||||
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
||||
@@ -103,12 +109,14 @@
|
||||
openPanel({ unitId: u.id, proposalId: null, tab: action });
|
||||
}
|
||||
// The text shows the vote of its own versions; the list below, that of the others.
|
||||
// The modules in force are read in place too: the title at the head, each order at the end of its section.
|
||||
const textIds = $derived(
|
||||
new Set(
|
||||
doc.mode === 'inforce' || !doc.preview
|
||||
? doc.inForce.map((u) => u.inForce?.id)
|
||||
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
|
||||
),
|
||||
new Set([
|
||||
titleUnit?.inForce?.id,
|
||||
...(doc.mode === 'inforce' || !doc.preview
|
||||
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
|
||||
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id)),
|
||||
]),
|
||||
);
|
||||
const inText = $derived(!!vote && textIds.has(vote));
|
||||
const canVote = $derived(!!view?.vote.open);
|
||||
@@ -137,6 +145,18 @@
|
||||
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
|
||||
</p>
|
||||
<h1>{title}</h1>
|
||||
{#if titleUnit?.inForce}
|
||||
{@const tp = titleUnit.inForce}
|
||||
<TitleBar
|
||||
unit={titleUnit}
|
||||
stale={titleStale}
|
||||
selected={panel?.unitId === titleUnit.id}
|
||||
voteOpen={inText && vote === tp.id}
|
||||
onaction={(action) => onUnit(titleUnit, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
|
||||
</TitleBar>
|
||||
{/if}
|
||||
<p class="summary">{summary}</p>
|
||||
<p class="facts faint">
|
||||
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
|
||||
@@ -171,21 +191,37 @@
|
||||
|
||||
<section class="text" aria-label={t('doc.view.inforce')}>
|
||||
{#if doc.mode === 'inforce' || !doc.preview}
|
||||
{#each doc.inForce as u (u.id)}
|
||||
{#if u.inForce}
|
||||
{@const p = u.inForce}
|
||||
<UnitBlock
|
||||
text={shown(p, lang, source)}
|
||||
seedMinimal={p.seedMinimal}
|
||||
enteredAt={p.enteredAt}
|
||||
rivals={rivalsOf(u)}
|
||||
selected={panel?.unitId === u.id}
|
||||
voteFor={p.id}
|
||||
voteOpen={inText && vote === p.id}
|
||||
onaction={(action) => onUnit(u, action)}
|
||||
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
|
||||
{#each s.units as u (u.id)}
|
||||
{#if u.inForce}
|
||||
{@const p = u.inForce}
|
||||
<UnitBlock
|
||||
text={shown(p, lang, source)}
|
||||
seedMinimal={p.seedMinimal}
|
||||
enteredAt={p.enteredAt}
|
||||
votes={p.tally.for + p.tally.against}
|
||||
rivals={rivalsOf(u)}
|
||||
selected={panel?.unitId === u.id}
|
||||
voteFor={p.id}
|
||||
voteOpen={inText && vote === p.id}
|
||||
onaction={(action) => onUnit(u, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if s.order?.inForce}
|
||||
{@const o = s.order}
|
||||
{@const op = s.order.inForce}
|
||||
<OrderModule
|
||||
unit={o}
|
||||
{nameOf}
|
||||
selected={panel?.unitId === o.id}
|
||||
voteOpen={inText && vote === op.id}
|
||||
onaction={(action) => onUnit(o, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
|
||||
</OrderModule>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">{t('doc.empty')}</p>
|
||||
@@ -239,6 +275,7 @@
|
||||
vote={inText ? null : vote}
|
||||
ontoggle={(_, p) => toggleVote(p.id)}
|
||||
fold={foldOf}
|
||||
{nameOf}
|
||||
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
||||
/>
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
|
||||
@@ -63,6 +63,26 @@ describe('the basket of a vote', () => {
|
||||
expect(clearScope(fond, { i1: 'for', e2: 'for', e1k: 'against', e4: 'none' })).toEqual({ i1: 'for', e4: 'none' });
|
||||
});
|
||||
|
||||
it('the modules are voted like articles: the title first, each order at the end of its section', () => {
|
||||
// ADR 0009: the title of the document, the order of each section; an order of one article is left out.
|
||||
const title = { ...unit('T', null, prop('t', 'T', { kind: 'title', status: 'in_force' }), [prop('t2', 'T', { kind: 'title' })]), kind: 'title' as const };
|
||||
const order = (id: string, section: string, moot = false) => ({
|
||||
...unit(id, section, prop(`${id}-v`, id, { kind: 'order', status: 'in_force' }), []),
|
||||
kind: 'order' as const,
|
||||
moot,
|
||||
});
|
||||
const s = sections(DOC, { title, orders: [order('O-intro', 'introduction', true), order('O-fond', 'fondamental'), order('O-tech', 'technique')] });
|
||||
expect(s.map((x) => x.key)).toEqual([null, 'introduction', 'fondamental', 'technique']);
|
||||
expect(s[0]?.slots.map((x) => x.unit.id)).toEqual(['T']);
|
||||
expect(s[0]?.slots[0]?.versions.map((p) => p.id)).toEqual(['t', 't2']);
|
||||
expect(s[1]?.slots.map((x) => x.unit.id)).toEqual(['I1']);
|
||||
expect(s[2]?.slots.map((x) => x.unit.id)).toEqual(['E1', 'E2', 'O-fond']);
|
||||
expect(s[3]?.slots.map((x) => x.unit.id)).toEqual(['E3', 'E4', 'O-tech']);
|
||||
// « Tout pour » a section fills its order too (one version), never the title (two).
|
||||
expect(fill(s[2]?.slots ?? [], 'for', {})).toEqual({ e2: 'for', 'O-fond-v': 'for' });
|
||||
expect(waiting(s.flatMap((x) => x.slots), {}).map((x) => x.unit.id)).toEqual(['T', 'E1', 'E3']);
|
||||
});
|
||||
|
||||
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
|
||||
const known = new Set(['i1', 'e2', 'e4']);
|
||||
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The order of a section, proposed with a list one clicks (Yvv, 2026-10-30:
|
||||
// « une simple liste clipable »): an article, then its new place; or its arrows.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { moved, place, sameOrder, step } from '../../src/lib/order';
|
||||
|
||||
const section = ['T1', 'E1', 'E2', 'E3'];
|
||||
|
||||
describe('the order of a section, by clicks', () => {
|
||||
it('an article chosen takes the place clicked, the others close up', () => {
|
||||
expect(place(section, 'E2', 'T1')).toEqual(['E2', 'T1', 'E1', 'E3']);
|
||||
expect(place(section, 'T1', 'E3')).toEqual(['E1', 'E2', 'E3', 'T1']);
|
||||
expect(place(section, 'E1', 'E2')).toEqual(['T1', 'E2', 'E1', 'E3']);
|
||||
// Its own place, or an article not in the list: nothing moves.
|
||||
expect(place(section, 'E1', 'E1')).toEqual(section);
|
||||
expect(place(section, 'X9', 'E1')).toEqual(section);
|
||||
expect(place(section, 'E1', 'X9')).toEqual(section);
|
||||
});
|
||||
|
||||
it('the arrows move one place, never beyond the ends', () => {
|
||||
expect(step(section, 'E2', -1)).toEqual(['T1', 'E2', 'E1', 'E3']);
|
||||
expect(step(section, 'E2', 1)).toEqual(['T1', 'E1', 'E3', 'E2']);
|
||||
expect(step(section, 'T1', -1)).toEqual(section);
|
||||
expect(step(section, 'E3', 1)).toEqual(section);
|
||||
});
|
||||
|
||||
it('a list never loses nor doubles an article', () => {
|
||||
const once = place(step(place(section, 'E3', 'T1'), 'T1', 1), 'E1', 'E3');
|
||||
expect([...once].sort()).toEqual([...section].sort());
|
||||
});
|
||||
|
||||
it('tells the order in force from another, and what moved', () => {
|
||||
expect(sameOrder(section, [...section])).toBe(true);
|
||||
expect(sameOrder(section, step(section, 'E1', 1))).toBe(false);
|
||||
expect(sameOrder(section, section.slice(1))).toBe(false);
|
||||
expect([...moved(section, ['E1', 'T1', 'E2', 'E3'])].sort()).toEqual(['E1', 'T1']);
|
||||
expect(moved(section, section).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -30,8 +30,16 @@ describe('usual participation as shown', () => {
|
||||
const p = { ...base, source: 'reference' as const, median: null, sample: 0, ratio: 0.5, voters: 1, bounded: true };
|
||||
expect(usualShown(p, 2, 1)).toEqual({ voters: 2, ratio: 1 });
|
||||
});
|
||||
it('rounds a median between two numbers of voters', () => {
|
||||
it('rounds a median between two numbers of voters, never its share', () => {
|
||||
const p = { ...base, median: 2.5, ratio: 0.01, voters: 57, bounded: true };
|
||||
expect(usualShown(p, 5745, 50).voters).toBe(3);
|
||||
// Relecture of 71819f9: the share is that of the median itself — 2,5 voters out of 4 is
|
||||
// 62,5 %, not 3 out of 4 (75 %).
|
||||
const q = { ...base, median: 2.5, ratio: 0.5, voters: 2, bounded: true };
|
||||
expect(usualShown(q, 4, 1)).toEqual({ voters: 3, ratio: 0.625 });
|
||||
});
|
||||
it('takes the share of twice C as it is, whatever the rounding', () => {
|
||||
const p = { ...base, source: 'reference' as const, median: null, sample: 0, ratio: 0.5, voters: 2, bounded: true };
|
||||
expect(usualShown(p, 3, 0.75)).toEqual({ voters: 2, ratio: 0.5 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user