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:
Yvv
2026-10-31 05:35:02 +01:00
co-authored by Claude Opus 5.5
parent 3831ddb1bf
commit 785580c78f
31 changed files with 1480 additions and 136 deletions
+29 -11
View File
@@ -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;
}
+8 -6
View File
@@ -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 });
+29 -10
View File
@@ -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>
+14 -3
View File
@@ -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}
+36 -4
View File
@@ -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);
+253
View File
@@ -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>
+89
View File
@@ -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>&nbsp;{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>
+12 -9
View File
@@ -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'}
+114
View File
@@ -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>
+43 -6
View File
@@ -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;
+23 -7
View File
@@ -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;
+25 -1
View File
@@ -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;
}
+58 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+32
View File
@@ -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));
}
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}