Files
decision/web/src/routes/g/[group]/docs/[doc]/+page.svelte
T
YvvandClaude Opus 5.5 9a9c99b93b Lot S : « Proposition seed » dans l'interface (fr, en, es), le texte sans article en vigueur le dit ; e2e repris
Interface au strict nécessaire (le lot U refera la présentation) : la marque
« Proposition seed » là où une version est montrée (liste des propositions,
Détails, panneau Propositions), seedProposal?: boolean dans types.ts ; au-dessus
des ordres, « Aucun article n'est en vigueur » quand c'est le cas.

e2e : C3 (aucun texte en vigueur, 33 lignes, trois Propositions seed au cran 2),
C9 et C18 (« si tout passe »), C20, C23, C24 et M1 par la liste des propositions
dans Monnaie libre ; dans le groupe d'essai, I1, E1 et E2 entrent d'abord par le
vote (seedsInForce : deux voix puis leur retrait, le texte reste en vigueur) pour
les contrôles qui lisent un texte en vigueur (C4 à C6, C20, C21, C24, C28 à C31).
Boucle e2e : 35 / 35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:27:41 +01:00

441 lines
16 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte';
import DeepList from '$lib/components/DeepList.svelte';
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 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 { i18n, LANGS, t } from '$lib/i18n/index.svelte';
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';
const doc = new DocState();
const basket = new BasketState();
const group = $derived(page.params.group ?? '');
const slug = $derived(page.params.doc ?? '');
$effect(() => {
void doc.load(group, slug);
});
// The basket of this document, kept for the tab; versions no longer open to vote
// leave it, and so does a « for » on a version held back by an objection.
$effect(() => {
const v = doc.view;
if (!v) return;
basket.load(v.doc.id, votableIds(sections(v.units, v.modules)));
});
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams;
const tab = TABS[q.get('onglet') ?? ''];
if (!tab) return null;
return {
unitId: q.get('article'),
proposalId: q.get('version'),
tab,
draftId: q.get('brouillon'),
section: q.get('section'),
prefill,
objectionId: q.get('objection'),
edit: q.get('modifier') === '1',
};
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
const vote = $derived.by(() => {
const q = page.url.searchParams;
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
});
function go(nextPanel: PanelState | null, nextVote: string | null) {
prefill = nextPanel?.prefill ?? null;
const search = panelSearch(nextPanel, nextVote);
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
noScroll: true,
keepFocus: true,
});
}
const openPanel = (next: PanelState | null) => go(next, vote);
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
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);
// On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines);
// While a ballot waits for the chain, the page looks again every ten seconds.
const waitingChain = $derived(
doc.ordered.some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
);
$effect(() => {
if (!waitingChain) return;
const every = setInterval(() => void doc.reload(), 10_000);
return () => clearInterval(every);
});
let filter = $state<Filter>('all');
const view = $derived(doc.view);
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
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.
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 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)),
]),
);
const inText = $derived(!!vote && textIds.has(vote));
const canVote = $derived(!!view?.vote.open);
</script>
{#snippet foldOf(unit: Unit, p: Proposal)}
<VoteFold
{doc}
proposal={p}
{unit}
onopen={(id) => go(panel, id)}
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
onanswer={(o) => openPanel(answerPanel(o, session.me?.id ?? null))}
onsign={sign}
/>
{/snippet}
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
<Guard code={doc.error} onretry={() => doc.load(group, slug)} />
{:else if view}
<div class="reader" class:with-panel={!!panel}>
<header class="head">
<p class="crumbs eyebrow">
<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}
<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>
{/if}
<p class="summary">{summary}</p>
<p class="facts faint">
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p>
{#if canVote}
<div class="basket-row">
<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>
</div>
{/if}
<div class="controls">
<div class="seg" 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}
</div>
<div class="seg small" role="radiogroup" aria-label={t('doc.lang.label')}>
{#each LANGS as l (l)}
<button type="button" role="radio" aria-checked={lang === l} class:on={lang === l} onclick={() => { doc.original = l === source && i18n.lang !== source; if (l !== source) i18n.set(l); }}>{l.toUpperCase()}</button>
{/each}
</div>
</div>
{#if lang !== source}
<p class="note faint">{t('doc.lang.translation')} <button class="linkish" type="button" onclick={() => (doc.original = true)}>{t('doc.lang.original')}</button></p>
{/if}
{#if doc.mode !== 'inforce'}<p class="note preview">{t(`doc.view.${doc.mode}.note`)}</p>{/if}
</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}
{/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}
{/each}
{:else}
{#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)}
<UnitBlock
text={shown(pu, lang, source)}
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
voteFor={id}
voteOpen={inText && !!id && vote === id}
onaction={(action) => u && onUnit(u, action, id)}
>
{#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}
</section>
<WaveRule />
<section class="deep" aria-labelledby="deep-title">
{#if doc.drafts.length}
<div class="drafts">
<h2 class="eyebrow">{t('deep.drafts')}</h2>
<p class="faint small">{t('deep.drafts.note')}</p>
<ul>
{#each doc.drafts as { unit, proposal } (proposal.id)}
<li><button type="button" class="linkish" onclick={() => openPanel({ unitId: unit.inForce ? unit.id : null, proposalId: null, tab: 'propose', draftId: proposal.id })}>{proposal.title}</button></li>
{/each}
</ul>
</div>
{/if}
<div class="deep-head">
<h2 id="deep-title">{t('deep.title')}</h2>
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</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>
{/each}
</div>
</div>
<DeepList
articles={deep}
vote={inText ? null : vote}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
{nameOf}
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>
</section>
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} {basket} {vote} 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)} />
{/if}
{/if}
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
<style>
.reader {
max-width: calc(var(--measure) + 2.6rem);
margin: 0 auto;
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 6rem;
transition: padding 0.2s var(--ease);
}
@media (min-width: 1180px) {
.reader.with-panel {
margin-right: calc(var(--panel) + 2.5rem);
}
}
.crumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.crumbs a {
color: inherit;
text-decoration: none;
}
.crumbs a:hover {
text-decoration: underline;
}
h1 {
margin-top: 0.8rem;
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.4rem + 2.4vw, 2.9rem);
line-height: 1.1;
letter-spacing: -0.012em;
font-variation-settings: 'opsz' 60;
}
.summary {
margin-top: 0.9rem;
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
font-size: 1.06rem;
line-height: 1.6;
}
.facts {
margin-top: 0.8rem;
font-size: 0.85rem;
font-weight: 700;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: space-between;
margin-top: 1.6rem;
}
.basket-row {
margin-top: 1.4rem;
}
.basket-row + .controls {
margin-top: 0.8rem;
}
.basket-btn {
background: var(--water);
color: var(--on-water);
}
.seg {
display: inline-flex;
flex-wrap: wrap;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
}
.seg button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.35rem 0.85rem;
min-height: 2.25rem;
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
}
.seg.small button {
padding-inline: 0.65rem;
font-size: 0.8rem;
}
.seg button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
}
.note {
margin-top: 0.8rem;
font-size: 0.86rem;
}
.note.preview {
color: var(--moss);
font-weight: 700;
}
.linkish {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
text-decoration: underline;
text-underline-offset: 3px;
}
.text {
margin-top: 2.2rem;
}
.deep {
padding-top: 2rem;
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
margin: 0 -1.3rem;
padding-inline: 1.3rem;
border-radius: 0 0 var(--r-card) var(--r-card);
}
.deep-head {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
}
.deep-head h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.55rem;
}
.deep-head .seg {
justify-self: start;
}
.drafts {
display: grid;
gap: 0.3rem;
margin-bottom: 2rem;
}
.drafts ul {
margin: 0.3rem 0 0;
padding-left: 1.1rem;
}
.small {
font-size: 0.84rem;
}
.new {
margin-top: 1.4rem;
}
</style>