Lot U : le lecteur refait — l'ordre en une ligne au bout de sa section, poignées sur place (Reorder), le titre et l'ordre votés à leur place ; Propositions seed en tête quand rien n'est en vigueur ; Propositions en cours repliées par section ; panneau sans onglets ; oui pour passer · non pour bloquer ; quorum de l'auteur ; votes simulés dits simulés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 07:59:24 +01:00
co-authored by Claude Opus 5.5
parent 70f7bb7247
commit 4cf4e47ee9
17 changed files with 1198 additions and 541 deletions
+180 -79
View File
@@ -1,4 +1,10 @@
<script lang="ts">
// The reader of a document (lot U, docs/chantiers/acte-certification/UX-lecteur.md). In
// place, what is read and decided on what one has before one's eyes: the vote of a version
// and its other versions, its inertia, the order of a section (handles on its articles).
// On the right, what is written, or works over several modules: proposing, the basket,
// the details of a version, the settings of the formula. Short at a glance: the text, each
// section closed by one line of order; the open proposals folded by section.
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte';
@@ -6,20 +12,22 @@
import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import OrderModule from '$lib/components/OrderModule.svelte';
import OrderLine from '$lib/components/OrderLine.svelte';
import Reorder from '$lib/components/Reorder.svelte';
import TitleBar from '$lib/components/TitleBar.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { sections, votableIds } from '$lib/basket';
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
import { DocState, panelSearch, shown, TABS, type Entry, type PanelState, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { sectionLabel } from '$lib/labels';
import { answerPanel } from '$lib/objections';
import { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote';
import { articles, type Filter } from '$lib/versions';
import { articles, deepSections, type Filter } from '$lib/versions';
const doc = new DocState();
const basket = new BasketState();
@@ -53,10 +61,20 @@
edit: q.get('modifier') === '1',
};
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
/**
* The version whose vote is unfolded where it reads (`?vote=`; the old `onglet=vote` too,
* and the old `onglet=propositions`, whose versions now live inside the vote).
*/
const vote = $derived.by(() => {
const q = page.url.searchParams;
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
const onglet = q.get('onglet');
if (q.get('vote')) return q.get('vote');
if (onglet === 'vote') return q.get('version');
if (onglet === 'propositions') {
const u = doc.unit(q.get('article'));
return q.get('version') ?? u?.inForce?.id ?? u?.proposals.find((p) => p.status === 'open')?.id ?? null;
}
return null;
});
function go(nextPanel: PanelState | null, nextVote: string | null) {
@@ -73,6 +91,22 @@
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
// Reordering a section: handles on its articles, in place (one at a time).
let reordering = $state<string | null>(null);
function startReorder(order: Unit) {
reordering = order.id;
// Its vote folds: the line now holds the articles with their handles.
if ([order.inForce, ...order.proposals].some((p) => p?.id === vote)) go(panel, null);
}
// An old link « proposer un autre ordre » (onglet=proposer on an order) reorders in place.
$effect(() => {
if (panel?.tab !== 'propose') return;
const u = doc.unit(panel.unitId);
if (u?.kind !== 'order') return;
reordering = u.id;
openPanel(null);
});
// On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines);
@@ -92,34 +126,42 @@
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
// One line per article; an article stays when one of its versions passes the filter.
const deep = $derived(articles(doc.open, filter));
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
// The title of the document, a module (ADR 0009): its gestures under the title.
// The title of the document, a module (ADR 0009): its line under the title.
const titleUnit = $derived(view?.modules.title ?? null);
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
if (action === 'vote') {
if (version) toggleVote(version);
return;
}
openPanel({ unitId: u.id, proposalId: null, tab: action });
}
// The text shows the vote of its own versions; the list below, that of the others.
// The modules in force are read in place too: the title at the head, each order at the end of its section.
const reading = $derived(doc.mode === 'inforce' || !doc.preview);
/** The text as it reads, by section, each closed by its order. */
const textSections = $derived(reading ? doc.textSections : []);
/** The versions the text shows: their vote unfolds there, never in the list below. */
const textIds = $derived(
new Set([
titleUnit?.inForce?.id,
...(doc.mode === 'inforce' || !doc.preview
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id)),
]),
new Set(
reading
? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
: (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
),
);
const inText = $derived(!!vote && textIds.has(vote));
// « Propositions en cours »: the articles (the modules are voted at their place), the seed
// proposals of the head apart; by section, each closed by its order when the text does not.
const deep = $derived(articles(doc.open.filter((x) => !x.unit.kind && !textIds.has(x.proposal.id)), filter));
const textKeys = $derived(new Set(textSections.map((s) => s.section)));
const deepOrders = $derived(
filter === 'all' ? (view?.modules.orders ?? []).filter((o) => !o.moot && !textKeys.has(o.section)) : [],
);
const sectionKeys = $derived([...new Set((view?.units ?? []).map((u) => u.section))]);
const deepSecs = $derived(deepSections(deep, deepOrders, sectionKeys));
const canVote = $derived(!!view?.vote.open);
/** The other versions of an article open to the vote, besides the one read. */
const othersOf = (e: Entry) => e.unit.proposals.filter((p) => p.status === 'open' && p.kind !== 'inertia' && p.id !== e.proposal.id).length;
function onEntry(e: Entry, action: 'vote' | 'propose') {
if (action === 'vote') toggleVote(e.proposal.id);
// From a seed proposal (nothing in force): its text to start from.
else openPanel({ unitId: e.unit.id, proposalId: null, tab: 'propose', prefill: e.seed ? { title: e.proposal.title, body: e.proposal.body } : null });
}
</script>
{#snippet foldOf(unit: Unit, p: Proposal)}
@@ -135,6 +177,28 @@
/>
{/snippet}
{#snippet moduleFold(p: Proposal)}
{@const u = doc.unit(p.unitId)}
{#if u}{@render foldOf(u, p)}{/if}
{/snippet}
{#snippet reorderOf(o: Unit)}
<Reorder
{doc}
unit={o}
{nameOf}
oncancel={() => (reordering = null)}
ondone={(id) => {
reordering = null;
go(panel, id);
}}
/>
{/snippet}
{#snippet orderLine(o: Unit)}
<OrderLine unit={o} {vote} reordering={reordering === o.id} fold={moduleFold} reorder={reorderOf} onvote={(id) => toggleVote(id)} onreorder={() => startReorder(o)} />
{/snippet}
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
@@ -146,17 +210,16 @@
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
</p>
<h1>{title}</h1>
{#if titleUnit?.inForce}
{@const tp = titleUnit.inForce}
{#if titleUnit}
<TitleBar
unit={titleUnit}
stale={titleStale}
selected={panel?.unitId === titleUnit.id}
voteOpen={inText && vote === tp.id}
onaction={(action) => onUnit(titleUnit, action)}
>
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
</TitleBar>
{vote}
fold={moduleFold}
onvote={(id) => toggleVote(id)}
onpropose={() => openPanel({ unitId: titleUnit.id, proposalId: null, tab: 'propose' })}
/>
{/if}
<p class="summary">{summary}</p>
<p class="facts faint">
@@ -170,10 +233,11 @@
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
</button>
{#if view.vote.simulated}<span class="tag" title={t('vote.simulated.note')}>{t('vote.simulated.mark')}</span>{/if}
</div>
{/if}
<div class="controls">
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
<div class="seg views" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
{/each}
@@ -191,44 +255,37 @@
</header>
<section class="text" aria-label={t('doc.view.inforce')}>
{#if doc.mode === 'inforce' || !doc.preview}
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
{#each s.units as u (u.id)}
{#if u.inForce}
{@const p = u.inForce}
<UnitBlock
text={shown(p, lang, source)}
seedMinimal={p.seedMinimal}
enteredAt={p.enteredAt}
votes={p.tally.for + p.tally.against}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
voteFor={p.id}
voteOpen={inText && vote === p.id}
onaction={(action) => onUnit(u, action)}
>
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
</UnitBlock>
{#if reading}
<!-- Nothing in force (ADR 0011): the seed proposals at the head, said once. -->
{#if !doc.inForce.length}<p class="lead">{t(textSections.length ? 'doc.empty.seeds' : 'doc.empty')}</p>{/if}
{#each textSections as s, i (`${i}:${s.section}`)}
{@const label = sectionLabel(s.section)}
<div class="text-section">
{#if label}<p class="section-name">{label}</p>{/if}
{#if !s.order || reordering !== s.order.id}
{#each s.entries as e (e.proposal.id)}
{@const p = e.proposal}
<UnitBlock
text={shown(p, lang, source)}
seed={e.seed ? p : null}
seedMinimal={!e.seed && p.seedMinimal}
enteredAt={e.seed ? null : p.enteredAt}
votes={p.tally.for + p.tally.against}
rivals={othersOf(e)}
selected={panel?.unitId === e.unit.id}
voteFor={p.id}
voteOpen={vote === p.id}
onaction={(action) => onEntry(e, action)}
>
{#snippet fold()}{@render foldOf(e.unit, p)}{/snippet}
</UnitBlock>
{/each}
{/if}
{/each}
{#if s.order?.inForce}
{@const o = s.order}
{@const op = s.order.inForce}
<OrderModule
unit={o}
{nameOf}
selected={panel?.unitId === o.id}
voteOpen={inText && vote === op.id}
onaction={(action) => onUnit(o, action)}
>
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
</OrderModule>
{/if}
{#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
</div>
{/each}
{:else}
{#each doc.preview as pu (pu.unitId)}
{#each doc.preview ?? [] as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
{@const p = doc.proposal(id)}
@@ -237,13 +294,17 @@
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
voteFor={id}
voteOpen={inText && !!id && vote === id}
onaction={(action) => u && onUnit(u, action, id)}
voteOpen={!!id && vote === id}
onaction={(action) => {
if (action === 'vote') {
if (id) toggleVote(id);
} else if (u) openPanel({ unitId: u.id, proposalId: null, tab: 'propose' });
}}
>
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
</UnitBlock>
{/each}
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{#if (doc.preview ?? []).every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{/if}
</section>
@@ -264,7 +325,7 @@
<div class="deep-head">
<h2 id="deep-title">{t('deep.title')}</h2>
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</p>
<p class="muted">{t('deep.note')}</p>
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
<button type="button" role="radio" aria-checked={filter === f} class:on={filter === f} onclick={() => (filter = f)}>{t(`deep.filter.${f}`)}</button>
@@ -272,11 +333,13 @@
</div>
</div>
<DeepList
articles={deep}
vote={inText ? null : vote}
sections={deepSecs}
vote={textIds.has(vote ?? '') ? null : vote}
{reordering}
all={filter !== 'all'}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
{nameOf}
order={orderLine}
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
/>
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
@@ -284,7 +347,7 @@
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
<DocPanel {doc} {panel} {lang} {basket} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
{/if}
{#if signing && view.vote.chain}
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
@@ -344,10 +407,14 @@
flex-wrap: wrap;
gap: 0.6rem;
justify-content: space-between;
margin-top: 1.6rem;
margin-top: 1.4rem;
}
.basket-row {
margin-top: 1.4rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 1.2rem;
}
.basket-row + .controls {
margin-top: 0.8rem;
@@ -372,6 +439,7 @@
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
white-space: nowrap;
}
.seg.small button {
padding-inline: 0.65rem;
@@ -402,6 +470,22 @@
.text {
margin-top: 2.2rem;
}
.lead {
margin-bottom: 1.4rem;
font-weight: 650;
color: var(--ink-soft);
}
.text-section + .text-section {
margin-top: 0.6rem;
}
/* The name of a section: where one is, without a number — the italic of the text, softly. */
.section-name {
margin: 0 0 0.3rem;
font-family: var(--font-text);
font-style: italic;
font-size: 1.12rem;
color: var(--ink-soft);
}
.deep {
padding-top: 2rem;
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
@@ -412,7 +496,7 @@
.deep-head {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
margin-bottom: 0.6rem;
}
.deep-head h2 {
font-family: var(--font-text);
@@ -437,4 +521,21 @@
.new {
margin-top: 1.4rem;
}
/* On a phone: the three readings on one line that scrolls, the targets big enough for a finger. */
@media (max-width: 899px) {
.seg.views {
flex-wrap: nowrap;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.seg button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.reader {
transition: none;
}
}
</style>