Files
decision/web/src/lib/components/VoteFold.svelte
T

528 lines
17 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. A version held back by an
// objection says so, and tells each one what is theirs to do (ADR 0010); it
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). Lot U: how
// many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
// its other versions — each opens its own vote where it reads —, and the quorum its
// author may set before the first vote. The rest of a version (typos, translations,
// objections, history) lives in the panel, « 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 { needOf, orderNote, titleOf } from '$lib/labels';
import { holding } from '$lib/objections';
import { moved } from '$lib/order';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Crans from './Crans.svelte';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import QuorumSetting from './QuorumSetting.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposal: p,
unit,
onopen,
onsettings,
ondetails,
onanswer,
onsign,
}: {
doc: DocState;
proposal: Proposal;
unit: Unit;
/** Unfolds the vote of another version, where it reads (another text, a change of inertia). */
onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void;
ondetails: (proposalId: string) => void;
/** The author of a version held back answers its objection: modify the article repeated, or reword. */
onanswer: (objection: Objection) => 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[];
objections: Objection[];
}
let detail = $state<Detail | null>(null);
/** « Autres versions », folded until asked: the vote stays short. */
let othersOpen = $state(false);
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 live = $derived(p.status === 'open' || p.status === 'in_force');
/** The objections holding this version back, and what each one asks of whom. */
const held = $derived(p.blocked ? holding(detail?.objections ?? []) : []);
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);
/** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */
const need = $derived(needOf(p));
/** The other live versions of the module (its changes of inertia live with its inertia). */
const others = $derived(
[unit.inForce, ...unit.proposals.filter((x) => x.status === 'open' && x.kind !== 'inertia')].filter(
(x): x is Proposal => !!x && x.id !== p.id,
),
);
/** What tells another version apart, in a few words. */
function otherLabel(v: Proposal): string {
if (v.kind === 'order') {
if (v.status === 'in_force') return t('module.order.inforce');
return v.author?.name ? t('panel.by', { name: v.author.name }) : t('module.order.proposal');
}
if (v.kind === 'remove') return titleOf(v, unit);
if (v.label) return `${v.title} · ${t('deep.variant', { label: v.label })}`;
if (v.status !== 'in_force' && v.author?.name) return `${v.title} · ${t('panel.by', { name: v.author.name })}`;
return v.title;
}
$effect(() => {
const id = p.id;
void p.tally.for;
void p.tally.against;
void p.blocked;
void p.objections;
void p.revision;
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 settle(o: Objection, gesture: 'lift' | 'reiterate') {
error = '';
busy = true;
try {
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
await doc.reload();
announce(doc, r.entered ?? []);
void session.refreshUnread();
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
async function cast(choice: 'for' | 'against' | null) {
error = '';
const lines: Line[] = [{ proposalId: p.id, choice }];
if (chain) {
onsign(lines);
return;
}
busy = true;
try {
const r = await voteDirect(doc, lines);
// A simulated vote says so, never « enregistré » (ADR 0012).
toasts.show(t(!choice ? 'vote.retracted' : r.simulated ? 'vote.saved.simulated' : 'vote.saved'));
} 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 })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
{#if p.evaluation}
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small"><Crans level={p.votedAtInertia} /></p>
</div>
</div>
{#if p.blocked}
<div class="held" role="status">
<p class="lead">{t('vote.held')}</p>
{#each held as o (o.id)}
<div class="objection">
<p>
<b>{t(o.kind === 'redit' ? 'vote.held.redit' : 'vote.held.contredit', { other: o.other.title ?? '' })}</b>
<span class="faint">· {t('objection.by', { name: o.objector ?? '' })}</span>
</p>
<p class="reason">{o.reason}</p>
{#if o.mine}
<p>{o.phase === 'objector' ? t('vote.held.mine.asked', { date: i18n.date(o.dueAt) }) : t('vote.held.mine')}</p>
{:else if o.phase === 'objector'}
<p>{t('vote.held.waiting', { name: o.objector ?? '', date: i18n.date(o.dueAt) })}</p>
{:else if o.iAnswer}
<p>{t(o.kind === 'redit' ? 'vote.held.author.redit' : 'vote.held.author.contredit')}</p>
{/if}
<div class="gestures">
{#if o.iAnswer && !o.mine && o.phase === 'author'}
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
{/if}
{#if o.canLift}
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
{/if}
{#if o.canReiterate}
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
{/if}
</div>
</div>
{/each}
</div>
{/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.myVoteState !== 'pending'}
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : p.myVoteState === 'simulated' ? 'vote.mine.simulated' : '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}
{#if p.myVoteChain || ballot}<a class="faint small" href="/bulletins">{t('vote.mine.receipts')}</a>{/if}
</div>
{#if !live}
<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')}>
<!-- Held back by an objection: against or a withdrawal, never a « for » (Yvv, 2026-11-01). -->
{#if !p.blocked}
<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>
{/if}
<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>
{:else if access.simulated}
<p class="faint small">{t('vote.simulated.note')}</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 others.length}
<button type="button" class="disclose" aria-expanded={othersOpen} aria-controls="others-{p.id}" onclick={() => (othersOpen = !othersOpen)}>
<span class="tri" aria-hidden="true"></span>{t('vote.others.count', { n: others.length })}
</button>
{/if}
{#if others.length && othersOpen}
<section class="others" id="others-{p.id}" aria-label={t('vote.others')}>
<ul>
{#each others as v (v.id)}
<li>
<button type="button" class="other" onclick={() => onopen(v.id)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="about">
<span class="label">{otherLabel(v)}</span>
<span class="faint small">
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
{#if v.status === 'in_force'} · {t('basket.inforce')}{/if}
{#if v.seedProposal} · {t('doc.seedProposal')}{/if}
{#if v.blocked} · {t('deep.held')}{/if}
</span>
</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/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;
}
.need {
font-weight: 750;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
text-decoration: underline dotted color-mix(in srgb, var(--ink-faint) 60%, transparent);
text-underline-offset: 4px;
cursor: help;
}
.others {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.disclose {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.25rem;
padding: 0;
border: 0;
background: none;
color: var(--water);
font-weight: 750;
font-size: 0.92rem;
}
.tri {
width: 0;
height: 0;
border-block: 0.3rem solid transparent;
border-left: 0.45rem solid currentColor;
transition: transform 0.15s var(--ease);
}
.disclose[aria-expanded='true'] .tri {
transform: rotate(90deg);
}
.others ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.15rem;
}
.other {
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 2.75rem;
padding: 0.45rem 0.6rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
}
.other:hover {
background: var(--surface);
}
.about {
display: grid;
min-width: 0;
}
.label {
font-weight: 750;
font-size: 0.92rem;
}
.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;
}
.held {
display: grid;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
}
.lead {
font-weight: 750;
}
.objection {
display: grid;
gap: 0.35rem;
font-size: 0.92rem;
}
.reason {
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
}
.gestures {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.gestures:empty {
display: none;
}
.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;
}
.tri {
transition: none;
}
}
</style>