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
+3 -3
View File
@@ -4,7 +4,7 @@
// unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote.
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 type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions';
@@ -73,7 +73,7 @@
<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 !== '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)}
</span>
</span>
@@ -94,7 +94,7 @@
{#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>
{#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 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}
+14 -1
View File
@@ -3,6 +3,7 @@
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import type { Lang } from '$lib/types';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte';
@@ -27,21 +28,30 @@
['vote', t('panel.vote'), !!voteId],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['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
).filter(([, , show]) => show),
);
let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => {
void panel.tab;
void panel.proposalId;
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>
<aside class="panel card" aria-label={t('panel.vote')}>
<div class="bar">
<div class="tabs" role="tablist">
<div class="tabs" role="tablist" bind:this={tablist}>
{#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>
{/each}
@@ -57,6 +67,7 @@
{lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
@@ -82,6 +93,8 @@
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' })}
/>
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
{/if}
</div>
</aside>
+28 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts">
// The threshold formula, typeset with native MathML — no KaTeX needed.
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 {
electorate,
@@ -9,21 +10,24 @@
tally,
evaluation,
inertia,
presets,
levels,
floor,
base,
onsettings,
}: {
electorate: number;
source: 'wot' | 'group';
tally: Tally;
evaluation: Evaluation | null;
inertia: Inertia;
presets: { key: Inertia; majorityPct: number; gradient: number }[];
levels: LevelInfo[];
floor: number;
base: number;
/** Opens the settings of the formula (the five levels, the usual turnout). */
onsettings?: () => void;
} = $props();
const p = $derived(presets.find((x) => x.key === inertia));
const p = $derived(levels.find((x) => x.level === inertia));
</script>
<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.T', { n: tally.for + tally.against })}</li>
{#if p}
<li>{t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}</li>
<li>{t('formula.G', { n: p.gradient })}</li>
<li>{t('formula.M', { n: i18n.number(p.majorityPct) })} — {inertiaBadge(inertia)}</li>
<li>{t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}</li>
{/if}
<li>{t('formula.B', { n: base })}</li>
<li>{t('formula.C', { n: floor })}</li>
<li>{t('formula.B', { n: i18n.number(base) })}</li>
<li>{t('formula.C', { n: i18n.number(floor) })}</li>
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
</ul>
<p class="faint">{t('formula.reading')}</p>
</details>
{#if onsettings}
<button class="settings-link" type="button" onclick={onsettings}>{t('settings.title')}</button>
{/if}
<style>
.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);
background: var(--surface-2);
padding: 0.7rem 0.9rem;
@@ -86,4 +95,15 @@
.faint {
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>
@@ -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 { shown, type DocState } from '$lib/doc.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 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 Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
@@ -16,12 +17,14 @@
lang,
onopen,
onpropose,
onsettings,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
onopen: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
onsettings?: () => void;
} = $props();
interface Detail {
@@ -36,13 +39,13 @@
}[];
electorate: number;
voteFloor: number;
levels: LevelInfo[];
}
let detail = $state<Detail | null>(null);
let busy = $state(false);
let showDiff = $state(false);
let error = $state('');
let inertiaTarget = $state<Inertia | ''>('');
let typoOpen = $state(false);
let typoTitle = $state('');
let typoBody = $state('');
@@ -58,7 +61,6 @@
const source = $derived(view?.doc.sourceLang ?? 'fr');
const text = $derived(p ? shown(p, lang, source) : null);
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(
(view?.units ?? []).flatMap((u) =>
[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) {
e.preventDefault();
busy = true;
@@ -250,7 +237,7 @@
<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>
{/if}
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p>
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
@@ -271,30 +258,9 @@
{/if}
</section>
{#if p.kind !== 'inertia' && unit}
<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}
{#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/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}
<section class="sub">
@@ -474,11 +440,6 @@
color: var(--water);
font-size: 0.92rem;
}
.row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form {
display: grid;
gap: 0.6rem;
@@ -490,15 +451,6 @@
.textarea.short {
min-height: 5rem;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.history {
margin: 0;
padding-left: 1rem;