v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1

- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 22:47:26 +01:00
co-authored by Claude Opus 5.5
parent 3541e1b1a6
commit b260596cfd
36 changed files with 4353 additions and 234 deletions
+52 -12
View File
@@ -1,8 +1,9 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version opens its vote; one with several shows the best of them and
// unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote.
// single version unfolds its vote below it; one with several shows the best of
// them and unfolds the others, each with its label, its water level and its
// 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';
@@ -13,16 +14,26 @@
let {
articles,
selected,
onopen,
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
vote,
ontoggle,
fold,
onsection,
}: {
articles: Article[];
/** The version whose vote is unfolded. */
vote: string | null;
ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: 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);
// Unfolded by hand, or because the version open in the panel is inside.
// Unfolded by hand, or because the version whose vote is unfolded is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
@@ -61,11 +72,17 @@
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
<li class="section">
<span class="eyebrow">{sectionLabel(unit.section)}</span>
{#if onsection && unit.section}
{@const key = unit.section}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</li>
{/if}
{#if best && a.versions.length === 1}
<li class="article">
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
@@ -78,6 +95,7 @@
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const open = isOpen(a)}
@@ -105,7 +123,7 @@
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<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>
@@ -116,6 +134,7 @@
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
@@ -234,9 +253,30 @@
color: var(--ink-soft);
}
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.3rem 1rem;
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.fold {
padding: 0 1rem 1rem;
}
.section:first-child {
margin-top: 0.4rem;
}