v3 : inertie à cinq crans — interface (L10)

L'inertie se lit en crans, « 3 / 5 · moyenne » (très basse, basse,
moyenne, haute, très haute ; fr, en, es). Le sélecteur offre les cinq
crans, l'actuel marqué ; le changement se vote au cran de l'article.
L'article dit le passé de son inertie (vote ou réglage de départ).

« Réglages de la formule », en lecture seule, depuis l'en-tête du doc et
depuis le vote d'un article (onglet « Réglages » du panneau, dans
l'URL) : W et sa source, C, B, la participation habituelle (médiane ou
référence, sur combien de propositions, quand), et les cinq crans : M,
G, part exigée, voix pour exigées à la participation de la version
regardée (ou habituelle depuis l'en-tête).

Composants à part (InertiaPicker, FormulaSettings), branchés par
quelques lignes dans VoteView. La formule dépliée ne fait plus déborder
le panneau. e2e : C4 (cran de l'article dans la formule), C20 (réglages
et cinq crans), M1 (réglages lisibles sur mobile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 16:42:11 +01:00
co-authored by Claude Opus 5.5
parent d762301706
commit db64405e1e
17 changed files with 671 additions and 109 deletions
+7 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers'; import { DOC, signIn } from './helpers';
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => {
await signIn(page, 'Chiara'); await signIn(page, 'Chiara');
await page.goto('/'); await page.goto('/');
const brand = page.locator('header.topbar a.brand'); const brand = page.locator('header.topbar a.brand');
@@ -18,4 +18,10 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann
const first = page.locator('section.text article.unit').first(); const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click(); await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible(); await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(fits).toBe(true);
}); });
+57
View File
@@ -60,6 +60,9 @@ test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', asyn
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible(); await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click(); await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible(); await expect(panel.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL. // The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/); await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
@@ -242,3 +245,57 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0); await expect(versions).toHaveCount(0);
}); });
test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
// From the head of the document: the scale of the group, at its usual turnout.
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page).toHaveURL(/onglet=reglages/);
const panel = page.locator('aside.panel');
await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible();
await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible();
await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible();
// No vote yet: the reference, twice C.
await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible();
const rows = panel.locator('table tbody tr');
await expect(rows).toHaveCount(5);
await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]);
await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']);
await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']);
// At 100 voters: C plus each level's share of the 50 votes beyond C.
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 page.keyboard.press('Escape');
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('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 page.keyboard.press('Escape');
// The five levels to choose from, in the test group; the change is voted at the article's level.
await page.goto(TEST_DOC);
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});
+3 -3
View File
@@ -4,7 +4,7 @@
// unfolds the others, each with its label, its water level and its marks — and // unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote. // each opens its own vote.
import { plain } from '$lib/markdown'; import { plain } from '$lib/markdown';
import { relationOf, titleOf } from '$lib/labels'; import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types'; import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions'; import { excerpts, type Article } from '$lib/versions';
@@ -73,7 +73,7 @@
<span class="meta"> <span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <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 relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if} {#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{@render marks(best)} {@render marks(best)}
</span> </span>
</span> </span>
@@ -94,7 +94,7 @@
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if} {#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <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 relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if} {#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if} {#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if} {#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if} {#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
+14 -1
View File
@@ -3,6 +3,7 @@
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels'; import { relationOf, titleOf } from '$lib/labels';
import type { Lang } from '$lib/types'; import type { Lang } from '$lib/types';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte'; import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte'; import VoteView from './VoteView.svelte';
@@ -27,21 +28,30 @@
['vote', t('panel.vote'), !!voteId], ['vote', t('panel.vote'), !!voteId],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit], ['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true], ['propose', t('panel.propose'), true],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const ] as const
).filter(([, , show]) => show), ).filter(([, , show]) => show),
); );
let body: HTMLElement | undefined = $state(); let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => { $effect(() => {
void panel.tab; void panel.tab;
void panel.proposalId; void panel.proposalId;
body?.scrollTo({ top: 0 }); body?.scrollTo({ top: 0 });
}); });
// On a narrow sheet the tabs scroll: the open one stays in sight.
$effect(() => {
void panel.tab;
tablist?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
</script> </script>
<aside class="panel card" aria-label={t('panel.vote')}> <aside class="panel card" aria-label={t('panel.vote')}>
<div class="bar"> <div class="bar">
<div class="tabs" role="tablist"> <div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)} {#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button> <button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
{/each} {/each}
@@ -57,6 +67,7 @@
{lang} {lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })} onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })} onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
/> />
{:else if panel.tab === 'proposals' && unit} {:else if panel.tab === 'proposals' && unit}
<ul class="versions"> <ul class="versions">
@@ -82,6 +93,8 @@
prefill={panel.prefill ?? null} prefill={panel.prefill ?? null}
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })} ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
/> />
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
{/if} {/if}
</div> </div>
</aside> </aside>
+28 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
// The threshold formula, typeset with native MathML — no KaTeX needed. // The threshold formula, typeset with native MathML — no KaTeX needed.
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import type { Evaluation, Inertia, Tally } from '$lib/types'; import { inertiaBadge } from '$lib/labels';
import type { Evaluation, Inertia, LevelInfo, Tally } from '$lib/types';
let { let {
electorate, electorate,
@@ -9,21 +10,24 @@
tally, tally,
evaluation, evaluation,
inertia, inertia,
presets, levels,
floor, floor,
base, base,
onsettings,
}: { }: {
electorate: number; electorate: number;
source: 'wot' | 'group'; source: 'wot' | 'group';
tally: Tally; tally: Tally;
evaluation: Evaluation | null; evaluation: Evaluation | null;
inertia: Inertia; inertia: Inertia;
presets: { key: Inertia; majorityPct: number; gradient: number }[]; levels: LevelInfo[];
floor: number; floor: number;
base: number; base: number;
/** Opens the settings of the formula (the five levels, the usual turnout). */
onsettings?: () => void;
} = $props(); } = $props();
const p = $derived(presets.find((x) => x.key === inertia)); const p = $derived(levels.find((x) => x.level === inertia));
</script> </script>
<details class="formula"> <details class="formula">
@@ -41,18 +45,23 @@
<li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li> <li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.T', { n: tally.for + tally.against })}</li> <li>{t('formula.T', { n: tally.for + tally.against })}</li>
{#if p} {#if p}
<li>{t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}</li> <li>{t('formula.M', { n: i18n.number(p.majorityPct) })} — {inertiaBadge(inertia)}</li>
<li>{t('formula.G', { n: p.gradient })}</li> <li>{t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}</li>
{/if} {/if}
<li>{t('formula.B', { n: base })}</li> <li>{t('formula.B', { n: i18n.number(base) })}</li>
<li>{t('formula.C', { n: floor })}</li> <li>{t('formula.C', { n: i18n.number(floor) })}</li>
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if} {#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
</ul> </ul>
<p class="faint">{t('formula.reading')}</p> <p class="faint">{t('formula.reading')}</p>
</details> </details>
{#if onsettings}
<button class="settings-link" type="button" onclick={onsettings}>{t('settings.title')}</button>
{/if}
<style> <style>
.formula { .formula {
/* A grid item may shrink below its widest line: the formula scrolls, the panel does not. */
min-width: 0;
border-radius: var(--r-input); border-radius: var(--r-input);
background: var(--surface-2); background: var(--surface-2);
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
@@ -86,4 +95,15 @@
.faint { .faint {
font-size: 0.85rem; font-size: 0.85rem;
} }
.settings-link {
justify-self: start;
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
}
</style> </style>
@@ -0,0 +1,195 @@
<script lang="ts">
// The settings of the formula, read only: W and its source, C, B, the usual
// turnout of the group (median or reference) and the five levels on its
// scale. From the head of the document, the *for* votes asked are those of
// the usual turnout; from a version, those of its own turnout.
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Inertia, LevelInfo } from '$lib/types';
let { doc, proposalId = null }: { doc: DocState; proposalId?: string | null } = $props();
interface Version {
levels: LevelInfo[];
proposal: { votedAtInertia: Inertia };
}
let version = $state<Version | null>(null);
$effect(() => {
const id = proposalId;
void doc.view;
version = null;
if (!id) return;
api
.get<Version>(`/proposals/${id}`)
.then((d) => {
if (proposalId === id) version = d;
})
.catch(() => {
version = null;
});
});
const view = $derived(doc.view);
const p = $derived(view?.doc.participation);
const levels = $derived(version?.levels ?? view?.levels ?? []);
const cast = $derived(levels[0]?.cast ?? 0);
const current = $derived(version?.proposal.votedAtInertia ?? null);
const pct = (ratio: number) => t('common.pct', { n: i18n.number(Math.round(ratio * 10000) / 100) });
const whole = (n: number) => t('common.pct', { n: i18n.number(n) });
const gradient = (g: number) => i18n.number(Math.round(g * 1000) / 1000);
</script>
{#if view && p}
<section class="settings" aria-labelledby="settings-title">
<h3 id="settings-title">{t('settings.title')}</h3>
<p class="muted">{t('settings.intro')}</p>
<ul class="values">
<li>{t('formula.W', { n: i18n.number(view.doc.electorate), source: t(view.doc.electorateSource === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.C', { n: i18n.number(view.doc.voteFloor) })}</li>
<li>{t('formula.B', { n: i18n.number(view.doc.formula.base) })}</li>
</ul>
<div class="usual">
<h4>{t('settings.usual')}</h4>
<p>
{#if p.source === 'median'}
{i18n.plural('settings.usual.median', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), sample: p.sample })}
{:else}
{i18n.plural('settings.usual.reference', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), min: p.minSample, sample: p.sample })}
{/if}
</p>
{#if p.bounded}<p class="faint small">{t('settings.usual.bounded', { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]) })}</p>{/if}
<p class="faint small">{p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}</p>
</div>
<h4>{t('settings.levels')}</h4>
<div class="scroll">
<table>
<thead>
<tr>
<th scope="col">{t('settings.col.level')}</th>
<th scope="col">M</th>
<th scope="col">G</th>
<th scope="col">{t('settings.col.share')}</th>
<th scope="col">{t('settings.col.required')}</th>
</tr>
</thead>
<tbody>
{#each levels as l (l.level)}
<tr class:current={l.level === current}>
<th scope="row">
<span class="lvl">
<span class="k">{l.level}</span>
<span class="name">{t(`inertia.name.${l.level}`)}</span>
{#if l.level === current}<span class="tag water">{t('settings.current')}</span>{/if}
</span>
</th>
<td>{whole(l.majorityPct)}</td>
<td>{gradient(l.gradient)}</td>
<td>{whole(l.sharePct)}</td>
<td class="required">{l.required === null ? '—' : i18n.number(l.required)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="faint small">{i18n.plural('settings.share.note', p.voters)}</p>
<p class="faint small">{i18n.plural(version ? 'settings.required.version' : 'settings.required.usual', cast)}</p>
<p class="faint small">{t('settings.reading')}</p>
</section>
{/if}
<style>
.settings {
display: grid;
gap: 0.8rem;
min-width: 0;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 800;
}
.small {
font-size: 0.84rem;
}
.values {
margin: 0;
padding-left: 1.1rem;
font-size: 0.92rem;
display: grid;
gap: 0.15rem;
}
.usual {
display: grid;
gap: 0.3rem;
padding: 0.8rem 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
font-size: 0.92rem;
}
.scroll {
overflow-x: auto;
border-radius: var(--r-input);
background: var(--surface-2);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
}
th,
td {
padding: 0.45rem 0.55rem;
text-align: right;
white-space: nowrap;
}
thead th {
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--ink-soft);
}
th[scope='row'],
thead th:first-child {
text-align: left;
}
th[scope='row'] {
font-weight: 600;
}
.lvl {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
}
tbody tr:nth-child(odd) {
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
tr.current {
background: var(--water-wash) !important;
}
.k {
font-weight: 800;
font-size: 1rem;
}
.name {
color: var(--ink-soft);
font-size: 0.8rem;
}
.required {
font-weight: 800;
}
.tag {
font-size: 0.68rem;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
// The inertia of an article: its level out of five, its past, the changes
// under vote, and the choice of a new level. A change of inertia is voted at
// the level of the article itself (ADR 0007).
import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { inertiaLevel } from '$lib/labels';
import { INERTIA_LEVELS, type Inertia, type Unit } from '$lib/types';
let { doc, unit, onopen }: { doc: DocState; unit: Unit; onopen: (proposalId: string) => void } = $props();
let target = $state<Inertia | null>(null);
let busy = $state(false);
let error = $state('');
const open = $derived(unit.proposals.filter((x) => x.kind === 'inertia' && x.status === 'open'));
const level = (k: Inertia | null) => (k ? inertiaLevel(k) : '?');
async function submit() {
if (!target) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target });
target = null;
error = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
} finally {
busy = false;
}
}
</script>
<section class="inertia" aria-labelledby="inertia-{unit.id}">
<h4 id="inertia-{unit.id}">{t('inertia.title')} · <span class="muted">{inertiaLevel(unit.inertia)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each unit.inertiaChanges as c (c.at + c.to)}
<p class="faint small">{t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}</p>
{/each}
{#each open as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>
{t('inertia.change.open', { level: level(ip.inertiaTarget) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}
</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="levels" role="radiogroup" aria-label={t('inertia.change.to')}>
{#each INERTIA_LEVELS as k (k)}
<button
type="button"
role="radio"
aria-checked={target === k}
class:on={target === k}
class:current={k === unit.inertia}
disabled={k === unit.inertia || busy}
onclick={() => (target = k)}
>
<span class="k">{k}</span>
<span class="name">{t(`inertia.name.${k}`)}</span>
{#if k === unit.inertia}<span class="cur">{t('inertia.change.current')}</span>{/if}
</button>
{/each}
</div>
<button class="btn small" type="button" disabled={!target || busy} onclick={submit}>{t('inertia.change.submit')}</button>
<p class="faint small">{t('inertia.change.note', { level: inertiaLevel(unit.inertia) })}</p>
</details>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</section>
<style>
.inertia {
display: grid;
gap: 0.5rem;
min-width: 0;
}
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 800;
}
.small {
font-size: 0.84rem;
}
details {
display: grid;
gap: 0.6rem;
}
details > summary {
cursor: pointer;
font-weight: 750;
color: var(--water);
font-size: 0.92rem;
}
details[open] > summary {
margin-bottom: 0.6rem;
}
details > :not(summary) + :not(summary) {
margin-top: 0.6rem;
}
.levels {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
}
.levels button {
display: grid;
justify-items: center;
align-content: start;
gap: 0.1rem;
min-height: 4rem;
padding: 0.5rem 0.25rem;
border: 0;
border-radius: var(--r-input);
background: var(--surface-2);
color: var(--ink-soft);
text-align: center;
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
}
.levels button:hover:not(:disabled) {
background: var(--water-wash);
color: var(--ink);
}
.levels button.current {
background: transparent;
cursor: default;
}
.levels button.on,
.levels button.on:hover {
background: var(--water);
color: var(--on-water);
}
.k {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.name {
font-size: 0.74rem;
font-weight: 700;
line-height: 1.15;
}
.cur {
font-size: 0.68rem;
font-weight: 800;
color: var(--water);
}
.btn {
justify-self: start;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
@media (prefers-reduced-motion: reduce) {
.levels button {
transition: none;
}
}
</style>
+9 -57
View File
@@ -2,11 +2,12 @@
import { api, ApiError } from '$lib/api'; import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte'; import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { titleOf } from '$lib/labels'; import { inertiaBadge, titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte'; import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types'; import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte'; import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte'; import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import Prose from './Prose.svelte'; import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte'; import WaterLevel from './WaterLevel.svelte';
@@ -16,12 +17,14 @@
lang, lang,
onopen, onopen,
onpropose, onpropose,
onsettings,
}: { }: {
doc: DocState; doc: DocState;
proposalId: string; proposalId: string;
lang: Lang; lang: Lang;
onopen: (proposalId: string) => void; onopen: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void; onpropose: (prefill: { title: string; body: string }) => void;
onsettings?: () => void;
} = $props(); } = $props();
interface Detail { interface Detail {
@@ -36,13 +39,13 @@
}[]; }[];
electorate: number; electorate: number;
voteFloor: number; voteFloor: number;
levels: LevelInfo[];
} }
let detail = $state<Detail | null>(null); let detail = $state<Detail | null>(null);
let busy = $state(false); let busy = $state(false);
let showDiff = $state(false); let showDiff = $state(false);
let error = $state(''); let error = $state('');
let inertiaTarget = $state<Inertia | ''>('');
let typoOpen = $state(false); let typoOpen = $state(false);
let typoTitle = $state(''); let typoTitle = $state('');
let typoBody = $state(''); let typoBody = $state('');
@@ -58,7 +61,6 @@
const source = $derived(view?.doc.sourceLang ?? 'fr'); const source = $derived(view?.doc.sourceLang ?? 'fr');
const text = $derived(p ? shown(p, lang, source) : null); const text = $derived(p ? shown(p, lang, source) : null);
const votable = $derived(p?.status === 'open' || p?.status === 'in_force'); const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open'));
const signalTargets = $derived( const signalTargets = $derived(
(view?.units ?? []).flatMap((u) => (view?.units ?? []).flatMap((u) =>
[u.inForce, ...u.proposals] [u.inForce, ...u.proposals]
@@ -122,21 +124,6 @@
} }
} }
async function proposeInertia() {
if (!unit || !inertiaTarget) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget });
inertiaTarget = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) { async function saveTypo(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
busy = true; busy = true;
@@ -250,7 +237,7 @@
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p> <p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p> <p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if} {/if}
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p> <p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div> </div>
</div> </div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if} {#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
@@ -271,30 +258,9 @@
{/if} {/if}
</section> </section>
{#if p.kind !== 'inertia' && unit} {#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/if}
<section class="sub">
<h4>{t('inertia.title')} · <span class="muted">{t(`inertia.level.${unit.inertia}`)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each inertiaOpen as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>{t('inertia.change.open', { level: t(`inertia.level.${ip.inertiaTarget ?? 'standard'}`) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="row">
<select class="select" bind:value={inertiaTarget} aria-label={t('inertia.change.to')}>
<option value="" disabled>{t('inertia.change.to')}</option>
{#each ['low', 'standard', 'high', 'max'] as const as level (level)}
{#if level !== unit.inertia}<option value={level}>{t(`inertia.level.${level}`)}</option>{/if}
{/each}
</select>
<button class="btn small" type="button" disabled={!inertiaTarget || busy} onclick={proposeInertia}>{t('inertia.change.submit')}</button>
</div>
<p class="faint small">{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}</p>
</details>
</section>
{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} presets={view.inertia} floor={detail.voteFloor} base={view.doc.formula.base} /> <Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} levels={detail.levels} floor={detail.voteFloor} base={view.doc.formula.base} {onsettings} />
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable} {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub"> <section class="sub">
@@ -474,11 +440,6 @@
color: var(--water); color: var(--water);
font-size: 0.92rem; font-size: 0.92rem;
} }
.row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form { .form {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@@ -490,15 +451,6 @@
.textarea.short { .textarea.short {
min-height: 5rem; min-height: 5rem;
} }
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.history { .history {
margin: 0; margin: 0;
padding-left: 1rem; padding-left: 1rem;
+1 -1
View File
@@ -7,7 +7,7 @@ import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all'; export type View = 'inforce' | 'wellrated' | 'all';
export type Tab = 'vote' | 'proposals' | 'propose'; export type Tab = 'vote' | 'proposals' | 'propose' | 'settings';
export interface PanelState { export interface PanelState {
unitId: string | null; unitId: string | null;
+36 -10
View File
@@ -134,7 +134,7 @@ export const en: Messages = {
'deep.rival': 'would replace “{title}”', 'deep.rival': 'would replace “{title}”',
'deep.addition': 'new article', 'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”', 'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia: {level}', 'deep.inertia': 'inertia to {level}',
'deep.blocked': 'blocked', 'deep.blocked': 'blocked',
'deep.mine': 'your proposal', 'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming', 'deep.stale': 'needs reconfirming',
@@ -163,19 +163,19 @@ export const en: Messages = {
'tally.missing.other': '{n} more votes in favor needed.', 'tally.missing.other': '{n} more votes in favor needed.',
'tally.reached': 'Threshold reached.', 'tally.reached': 'Threshold reached.',
'tally.level': 'Level: {pct}% of the threshold', 'tally.level': 'Level: {pct}% of the threshold',
'inertia.name.1': 'very low',
'inertia.name.2': 'low',
'inertia.name.3': 'medium',
'inertia.name.4': 'high',
'inertia.name.5': 'very high',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inertia {level}',
'inertia.low': 'low inertia',
'inertia.standard': 'standard inertia',
'inertia.high': 'high inertia',
'inertia.max': 'maximum inertia',
'inertia.level.low': 'low',
'inertia.level.standard': 'standard',
'inertia.level.high': 'high',
'inertia.level.max': 'maximum',
'panel.vote': 'Voting', 'panel.vote': 'Voting',
'panel.proposals': 'Proposals', 'panel.proposals': 'Proposals',
'panel.propose': 'Propose', 'panel.propose': 'Propose',
'panel.settings': 'Settings',
'panel.close': 'Close panel', 'panel.close': 'Close panel',
'panel.inforce': 'In force', 'panel.inforce': 'In force',
'panel.proposal': 'Proposal', 'panel.proposal': 'Proposal',
@@ -205,9 +205,12 @@ export const en: Messages = {
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
'inertia.change': 'Propose an inertia change', 'inertia.change': 'Propose an inertia change',
'inertia.change.to': 'New inertia', 'inertia.change.to': 'New inertia',
'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.', 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.",
'inertia.change.submit': 'Submit to vote', 'inertia.change.submit': 'Submit to vote',
'inertia.change.open': 'Inertia change in progress: to {level}', 'inertia.change.open': 'Inertia change in progress: to {level}',
'inertia.change.current': 'current',
'inertia.history.vote': 'Went from {from} to {to} on {date}, by a vote.',
'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.',
'formula.title': 'The threshold formula', 'formula.title': 'The threshold formula',
'formula.intro': 'The number of votes in favor required, R, depends on turnout:', 'formula.intro': 'The number of votes in favor required, R, depends on turnout:',
@@ -221,6 +224,29 @@ export const en: Messages = {
'formula.C': 'C — minimum votes in favor: {n}', 'formula.C': 'C — minimum votes in favor: {n}',
'formula.R': 'R — current threshold: {n} votes in favor', 'formula.R': 'R — current threshold: {n} votes in favor',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.', 'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.',
'common.pct': '{n}%',
'settings.title': 'Formula settings',
'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.',
'settings.usual': 'Usual turnout',
'settings.usual.median.one': '{n} voter out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.',
'settings.usual.median.other': '{n} voters out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.',
'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).',
'settings.usual.bounded': 'Kept between {lo} and {hi} of the electorate.',
'settings.usual.when': 'Measured once a day, last on {date}.',
'settings.usual.never': 'Measured once a day.',
'settings.levels': 'The five levels',
'settings.col.level': 'Level',
'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.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', 'history.title': 'Corrections',
'history.initial': 'initial text', 'history.initial': 'initial text',
+36 -10
View File
@@ -134,7 +134,7 @@ export const es: Messages = {
'deep.rival': 'reemplazaría «{title}»', 'deep.rival': 'reemplazaría «{title}»',
'deep.addition': 'artículo nuevo', 'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»', 'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia: {level}', 'deep.inertia': 'inercia a {level}',
'deep.blocked': 'bloqueada', 'deep.blocked': 'bloqueada',
'deep.mine': 'tu propuesta', 'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar', 'deep.stale': 'por reconfirmar',
@@ -163,19 +163,19 @@ export const es: Messages = {
'tally.missing.other': 'Faltan {n} votos a favor.', 'tally.missing.other': 'Faltan {n} votos a favor.',
'tally.reached': 'Umbral alcanzado.', 'tally.reached': 'Umbral alcanzado.',
'tally.level': 'Nivel: {pct} % del umbral', 'tally.level': 'Nivel: {pct} % del umbral',
'inertia.name.1': 'muy baja',
'inertia.name.2': 'baja',
'inertia.name.3': 'media',
'inertia.name.4': 'alta',
'inertia.name.5': 'muy alta',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inercia {level}',
'inertia.low': 'inercia baja',
'inertia.standard': 'inercia estándar',
'inertia.high': 'inercia alta',
'inertia.max': 'inercia máxima',
'inertia.level.low': 'baja',
'inertia.level.standard': 'estándar',
'inertia.level.high': 'alta',
'inertia.level.max': 'máxima',
'panel.vote': 'Votación', 'panel.vote': 'Votación',
'panel.proposals': 'Propuestas', 'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer', 'panel.propose': 'Proponer',
'panel.settings': 'Ajustes',
'panel.close': 'Cerrar el panel', 'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor', 'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta', 'panel.proposal': 'Propuesta',
@@ -205,9 +205,12 @@ export const es: Messages = {
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
'inertia.change': 'Proponer un cambio de inercia', 'inertia.change': 'Proponer un cambio de inercia',
'inertia.change.to': 'Nueva inercia', 'inertia.change.to': 'Nueva inercia',
'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.', 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia del artículo: {level}.',
'inertia.change.submit': 'Someter a votación', 'inertia.change.submit': 'Someter a votación',
'inertia.change.open': 'Cambio de inercia en curso: a {level}', 'inertia.change.open': 'Cambio de inercia en curso: a {level}',
'inertia.change.current': 'actual',
'inertia.history.vote': 'Pasó de {from} a {to} el {date}, por votación.',
'inertia.history.amendment': 'Pasó de {from} a {to} el {date}: ajuste inicial del texto.',
'formula.title': 'La fórmula del umbral', 'formula.title': 'La fórmula del umbral',
'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:', 'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:',
@@ -221,6 +224,29 @@ export const es: Messages = {
'formula.C': 'C — mínimo de votos a favor: {n}', 'formula.C': 'C — mínimo de votos a favor: {n}',
'formula.R': 'R — umbral actual: {n} votos a favor', 'formula.R': 'R — umbral actual: {n} votos a favor',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.', 'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.',
'common.pct': '{n} %',
'settings.title': 'Ajustes de la fórmula',
'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.',
'settings.usual': 'Participación habitual',
'settings.usual.median.one': '{n} votante de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.',
'settings.usual.median.other': '{n} votantes de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.',
'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).',
'settings.usual.bounded': 'Mantenida entre el {lo} y el {hi} del electorado.',
'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',
'settings.col.level': 'Nivel',
'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.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', 'history.title': 'Correcciones',
'history.initial': 'texto inicial', 'history.initial': 'texto inicial',
+36 -10
View File
@@ -137,7 +137,7 @@ export const fr = {
'deep.rival': 'remplacerait « {title} »', 'deep.rival': 'remplacerait « {title} »',
'deep.addition': 'nouvel article', 'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »', 'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie : {level}', 'deep.inertia': 'inertie vers {level}',
'deep.blocked': 'bloquée', 'deep.blocked': 'bloquée',
'deep.mine': 'ta proposition', 'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer', 'deep.stale': 'à reconfirmer',
@@ -166,19 +166,19 @@ export const fr = {
'tally.missing.other': 'Il manque {n} voix pour.', 'tally.missing.other': 'Il manque {n} voix pour.',
'tally.reached': 'Seuil atteint.', 'tally.reached': 'Seuil atteint.',
'tally.level': 'Niveau : {pct} % du seuil', 'tally.level': 'Niveau : {pct} % du seuil',
'inertia.name.1': 'très basse',
'inertia.name.2': 'basse',
'inertia.name.3': 'moyenne',
'inertia.name.4': 'haute',
'inertia.name.5': 'très haute',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inertie {level}',
'inertia.low': 'inertie faible',
'inertia.standard': 'inertie standard',
'inertia.high': 'inertie haute',
'inertia.max': 'inertie maximale',
'inertia.level.low': 'faible',
'inertia.level.standard': 'standard',
'inertia.level.high': 'haute',
'inertia.level.max': 'maximale',
'panel.vote': 'Le vote', 'panel.vote': 'Le vote',
'panel.proposals': 'Propositions', 'panel.proposals': 'Propositions',
'panel.propose': 'Proposer', 'panel.propose': 'Proposer',
'panel.settings': 'Réglages',
'panel.close': 'Fermer le panneau', 'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur', 'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition', 'panel.proposal': 'Proposition',
@@ -208,9 +208,12 @@ export const fr = {
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
'inertia.change': "Proposer un changement d'inertie", 'inertia.change': "Proposer un changement d'inertie",
'inertia.change.to': 'Nouvelle inertie', 'inertia.change.to': 'Nouvelle inertie',
'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.", 'inertia.change.note': "Un changement d'inertie se vote lui-même à l'inertie de l'article : {level}.",
'inertia.change.submit': 'Soumettre au vote', 'inertia.change.submit': 'Soumettre au vote',
'inertia.change.open': "Changement d'inertie en cours : vers {level}", 'inertia.change.open': "Changement d'inertie en cours : vers {level}",
'inertia.change.current': 'actuelle',
'inertia.history.vote': 'Passée de {from} à {to} le {date}, par un vote.',
'inertia.history.amendment': 'Passée de {from} à {to} le {date} : réglage de départ du texte.',
'formula.title': 'La formule du seuil', 'formula.title': 'La formule du seuil',
'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :', 'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :',
@@ -224,6 +227,29 @@ export const fr = {
'formula.C': 'C — minimum de voix pour : {n}', 'formula.C': 'C — minimum de voix pour : {n}',
'formula.R': 'R — seuil actuel : {n} voix pour', 'formula.R': 'R — seuil actuel : {n} voix pour',
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.", 'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.",
'common.pct': '{n} %',
'settings.title': 'Réglages de la formule',
'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.",
'settings.usual': 'Participation habituelle',
'settings.usual.median.one': '{n} votant sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.',
'settings.usual.median.other': '{n} votants sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.',
'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).",
'settings.usual.bounded': "Ramenée entre {lo} et {hi} de l'électorat.",
'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',
'settings.col.level': 'Cran',
'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.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', 'history.title': 'Corrections',
'history.initial': 'texte initial', 'history.initial': 'texte initial',
+12 -2
View File
@@ -1,10 +1,20 @@
import { t } from './i18n/index.svelte'; import { t } from './i18n/index.svelte';
import type { Proposal, Unit } from './types'; import type { Inertia, Proposal, Unit } from './types';
/** « 3 / 5 · moyenne ». */
export function inertiaLevel(k: Inertia): string {
return t('inertia.level', { k, name: t(`inertia.name.${k}`) });
}
/** « inertie 3 / 5 · moyenne ». */
export function inertiaBadge(k: Inertia): string {
return t('inertia.badge', { level: inertiaLevel(k) });
}
/** What a proposal is, in words, whatever its kind. */ /** What a proposal is, in words, whatever its kind. */
export function titleOf(p: Proposal, unit: Unit | undefined): string { export function titleOf(p: Proposal, unit: Unit | undefined): string {
if (p.kind === 'inertia' && p.inertiaTarget) { if (p.kind === 'inertia' && p.inertiaTarget) {
return t('deep.inertia', { level: t(`inertia.level.${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 === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title });
return p.title; return p.title;
+39 -2
View File
@@ -1,6 +1,40 @@
export type Lang = 'fr' | 'en' | 'es'; export type Lang = 'fr' | 'en' | 'es';
export type LangMap = Partial<Record<Lang, string>>; export type LangMap = Partial<Record<Lang, string>>;
export type Inertia = 'low' | 'standard' | 'high' | 'max'; /** The five levels of inertia, 1 (lightest) to 5 (heaviest); 3 by default. */
export type Inertia = 1 | 2 | 3 | 4 | 5;
export const INERTIA_LEVELS: Inertia[] = [1, 2, 3, 4, 5];
/** One level on the group's scale: M and G of the formula, s_k, and the *for* votes asked at `cast` votes. */
export interface LevelInfo {
level: Inertia;
majorityPct: number;
gradient: number;
sharePct: number;
cast: number;
required: number | null;
}
/** p̃ — the usual turnout of the group, measured once a day (or the reference 2C/W). */
export interface ParticipationInfo {
ratio: number;
source: 'median' | 'reference';
sample: number;
median: number | null;
bounded: boolean;
voters: number;
minSample: number;
windowDays: number;
everyHours: number;
bounds: [number, number];
measuredAt: string | null;
}
export interface InertiaChange {
from: Inertia | null;
to: Inertia | null;
cause: 'vote' | 'amendment';
at: string;
}
export interface Me { export interface Me {
id: string; id: string;
@@ -75,6 +109,7 @@ export interface Unit {
section: string | null; section: string | null;
position: number; position: number;
inertia: Inertia; inertia: Inertia;
inertiaChanges: InertiaChange[];
inForce: Proposal | null; inForce: Proposal | null;
proposals: Proposal[]; proposals: Proposal[];
} }
@@ -103,10 +138,12 @@ export interface DocView {
electorate: number; electorate: number;
electorateSource: 'wot' | 'group'; electorateSource: 'wot' | 'group';
formula: { base: number }; formula: { base: number };
participation: ParticipationInfo;
publication: Publication; publication: Publication;
}; };
units: Unit[]; units: Unit[];
inertia: { key: Inertia; majorityPct: number; gradient: number }[]; /** The five levels, with the *for* votes asked at the usual turnout. */
levels: LevelInfo[];
} }
export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string }; export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string };
@@ -20,8 +20,8 @@
}); });
// The panel lives in the URL: a link (or a feedback report) reopens it. // The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose' }; const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer' }; const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(null); let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => { const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams; const q = page.url.searchParams;
@@ -77,6 +77,7 @@
<p class="facts faint"> <p class="facts faint">
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)} {i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if} {#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p> </p>
<div class="controls"> <div class="controls">
+22
View File
@@ -0,0 +1,22 @@
// The five levels of inertia, as the screen names them (ADR 0007).
import { describe, expect, it } from 'vitest';
import { en } from '../../src/lib/i18n/en';
import { es } from '../../src/lib/i18n/es';
import { fr } from '../../src/lib/i18n/fr';
import { INERTIA_LEVELS } from '../../src/lib/types';
describe('five levels of inertia', () => {
it('go from 1 to 5, 3 in the middle', () => {
expect(INERTIA_LEVELS).toEqual([1, 2, 3, 4, 5]);
});
it('each level has its own short name, in the three languages', () => {
expect(INERTIA_LEVELS.map((k) => fr[`inertia.name.${k}`])).toEqual(['très basse', 'basse', 'moyenne', 'haute', 'très haute']);
expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']);
expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']);
});
it('a change of inertia is said to be voted at the level of the article, no longer one above', () => {
for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}');
expect(fr['inertia.change.note']).toContain("à l'inertie de l'article");
expect(fr['inertia.change.note']).not.toContain('au-dessus');
});
});
+2 -2
View File
@@ -8,13 +8,13 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
...over, ...over,
}; };
} }
function unit(id: string, proposals: Proposal[]): Unit { function unit(id: string, proposals: Proposal[]): Unit {
return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals }; return { id, code: id, section: null, position: 0, inertia: 3, inertiaChanges: [], inForce: null, proposals };
} }
/** The reader's list: every open proposal, unit by unit, in document order. */ /** The reader's list: every open proposal, unit by unit, in document order. */