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>
309 lines
9.3 KiB
Svelte
309 lines
9.3 KiB
Svelte
<script lang="ts">
|
|
// The vote, unfolded under the article (Yvv, 2026-10-30: « il faut voir son
|
|
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
|
|
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
|
|
// never who voted what —, how far the threshold is, your vote and where it
|
|
// stands, then the inertia and the formula. The rest of a version (typos,
|
|
// translations, flags, history) lives in the panel, tab « Détails ».
|
|
import { onMount } from 'svelte';
|
|
import { api } from '$lib/api';
|
|
import type { DocState } from '$lib/doc.svelte';
|
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
|
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';
|
|
import Formula from './Formula.svelte';
|
|
import InertiaPicker from './InertiaPicker.svelte';
|
|
import WaterLevel from './WaterLevel.svelte';
|
|
|
|
let {
|
|
doc,
|
|
proposal: p,
|
|
unit,
|
|
onopen,
|
|
onsettings,
|
|
ondetails,
|
|
onsign,
|
|
}: {
|
|
doc: DocState;
|
|
proposal: Proposal;
|
|
unit: Unit;
|
|
/** Unfolds the vote of another version (a change of inertia). */
|
|
onopen: (proposalId: string) => void;
|
|
onsettings: (proposalId: string) => void;
|
|
ondetails: (proposalId: string) => void;
|
|
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
|
|
onsign: (lines: Line[]) => void;
|
|
} = $props();
|
|
|
|
interface Detail {
|
|
electorate: number;
|
|
voteFloor: number;
|
|
levels: LevelInfo[];
|
|
}
|
|
|
|
let detail = $state<Detail | null>(null);
|
|
let busy = $state(false);
|
|
let error = $state('');
|
|
let root: HTMLElement | undefined = $state();
|
|
|
|
const view = $derived(doc.view);
|
|
const access = $derived(view?.vote);
|
|
const chain = $derived(access?.way === 'chain');
|
|
const votable = $derived(p.status === 'open' || p.status === 'in_force');
|
|
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;
|
|
void p.tally.for;
|
|
void p.tally.against;
|
|
api
|
|
.get<Detail>(`/proposals/${id}`)
|
|
.then((d) => {
|
|
if (p.id === id) detail = d;
|
|
})
|
|
.catch(() => (detail = null));
|
|
});
|
|
|
|
onMount(() => {
|
|
// Opened by a link: brought into sight, without a jump if it already is.
|
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
|
});
|
|
|
|
async function cast(choice: 'for' | 'against' | null) {
|
|
error = '';
|
|
const lines: Line[] = [{ proposalId: p.id, choice }];
|
|
if (chain) {
|
|
onsign(lines);
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
await voteDirect(doc, lines);
|
|
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
|
} catch (e) {
|
|
error = errorText(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="fold" id="vote-{p.id}" aria-label={t('vote.fold', { title })} bind:this={root}>
|
|
<div class="level">
|
|
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
|
<div class="numbers">
|
|
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
|
|
{#if p.evaluation}
|
|
<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">{inertiaBadge(p.votedAtInertia)}</p>
|
|
</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'}
|
|
<p class="pending"><span class="dot" aria-hidden="true"></span>{t('vote.mine.pending', { choice: t(`vote.choice.${ballot.choice}`) })}</p>
|
|
{/if}
|
|
{#if p.myVoteStale}
|
|
<p class="warn">{t('vote.stale')}</p>
|
|
{:else if p.myVote}
|
|
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
|
|
{:else if ballot?.status !== 'pending'}
|
|
<p class="muted">{t('vote.mine.none')}</p>
|
|
{/if}
|
|
{#if ballot?.status === 'refused'}
|
|
<p class="warn">{t('vote.mine.refused', { reason: t(`ballot.reason.${ballot.reason ?? 'transfer'}`) })}</p>
|
|
{:else if ballot?.status === 'expired'}
|
|
<p class="warn">{t('vote.mine.expired')}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !votable}
|
|
<p class="muted">{t('vote.closed')}</p>
|
|
{:else if access && !access.open}
|
|
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
|
{:else if access}
|
|
<div class="choice" role="group" aria-label={t('vote.yours')}>
|
|
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
|
|
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
|
|
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
|
|
</div>
|
|
{#if chain && access.chain}
|
|
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
|
|
<p class="faint small">{t('vote.chain.counts')}</p>
|
|
{/if}
|
|
{/if}
|
|
<p class="faint small">{t('vote.private')}</p>
|
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
|
|
|
<div class="more">
|
|
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
|
|
{#if detail && view}
|
|
<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={() => onsettings(p.id)}
|
|
/>
|
|
{/if}
|
|
<button class="linkish" type="button" onclick={() => ondetails(p.id)}>{t('vote.details')}</button>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.fold {
|
|
display: grid;
|
|
gap: 0.8rem;
|
|
margin-top: 0.8rem;
|
|
padding: 1.1rem 1.1rem 1.2rem;
|
|
border-radius: var(--r-card);
|
|
background: var(--water-wash);
|
|
animation: unfold 0.22s var(--ease);
|
|
scroll-margin: 1.5rem;
|
|
}
|
|
.level {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
.numbers {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
}
|
|
.tally {
|
|
font-size: 1.15rem;
|
|
font-weight: 800;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.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;
|
|
}
|
|
.mine {
|
|
display: grid;
|
|
gap: 0.3rem;
|
|
}
|
|
.voted {
|
|
font-weight: 800;
|
|
}
|
|
.voted.for {
|
|
color: var(--moss);
|
|
}
|
|
.voted.against {
|
|
color: var(--ochre);
|
|
}
|
|
.how {
|
|
font-weight: 650;
|
|
color: var(--ink-soft);
|
|
}
|
|
.pending {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-weight: 750;
|
|
color: var(--water);
|
|
}
|
|
.dot {
|
|
width: 0.55rem;
|
|
height: 0.55rem;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
animation: breathe 1.6s ease-in-out infinite;
|
|
}
|
|
.choice {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
}
|
|
.btn.for.on {
|
|
background: var(--moss);
|
|
color: var(--on-water);
|
|
}
|
|
.btn.against.on {
|
|
background: var(--ochre);
|
|
color: var(--on-water);
|
|
}
|
|
.closed {
|
|
font-weight: 700;
|
|
color: var(--ink-soft);
|
|
}
|
|
.warn {
|
|
color: var(--ochre);
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
}
|
|
.more {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
padding-top: 0.4rem;
|
|
}
|
|
.linkish {
|
|
justify-self: start;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0.2rem 0;
|
|
min-height: 2.25rem;
|
|
color: var(--water);
|
|
font-weight: 750;
|
|
text-align: left;
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
@keyframes unfold {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
}
|
|
@keyframes breathe {
|
|
50% {
|
|
opacity: 0.35;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.fold,
|
|
.dot {
|
|
animation: none;
|
|
}
|
|
}
|
|
</style>
|