Files
decision/web/src/routes/g/[group]/docs/[doc]/+page.svelte
T
YvvandClaude Opus 5.5 8528a269fc Lot U3 : l'interface de l'Acte d'engagement (Yvv, 03/11) — avant le texte, hors vote, « Info ouverture doc de réf. » (OpeningInfo, lib/opening.ts) : le paragraphe d'Yvv mot pour mot à la place de doc.empty.seeds (sa première phrase tant qu'aucun article n'est en vigueur, puis « entrent en vigueur » où « y » n'avait plus rien à reprendre), le cran d'ouverture des Propositions d'amorce, du titre, de l'ordre de chaque section et des autres articles (avant tout changement voté) et le quorum de chaque cran, lus dans l'API ; une séparation (un filet de la couleur de la clé de voûte, jamais une bordure) ; plus les titres « Introduction » et « Engagements fondamentaux » dans le texte. Les informations de F1, F1.1, F1.2, N1, N1.1 et O1 là où elles servent : vote.permanent, vote.rival, vote.others.rule, formula.Tprime, formula.round, formula.B.wot, formula.G, settings.share.note, settings.usual.quorum, settings.gradient, order.edit.effect ; les termes définis de T3c (« à la participation habituelle », « quorum de l'article », « part exigée », « repose sur moins de N propositions votées depuis un an »), plus de « comme d'habitude ». « Proposition d'amorce » (Starter proposal, Propuesta de arranque). Le mot d'Yvv pour qui n'est pas invité, à l'accueil sans session (à la place du bouton Ğ1 tant que la connexion Ğ1 n'est pas active) et sur « Se connecter » (signin.g1.pending sans redite). Réordonner : la ligne sous le pointeur lue dans la mise en page (offsetTop), pas là où animate:flip la montre — une poignée tirée sur plusieurs lignes ne revient plus en arrière ; le pointeur capturé par la liste. API : seedFeatured sur chaque version (seul changement sous crates/). C1, C3, C20, C27, C35, C38 suivent les mots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:28:41 +01:00

568 lines
21 KiB
Svelte

<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';
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 OpeningInfo from '$lib/components/OpeningInfo.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 Entry, type PanelState, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { readerSectionLabel } 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, deepSections, 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 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;
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) {
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);
// 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);
// 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 ?? '') : '');
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
// 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'];
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(
reading
? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
: (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
),
);
// « 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 facts of the head say what the page shows (lot U2): the seed proposals read at the head
// while nothing is in force, apart; then the proposals of « Propositions en cours » — the
// articles' versions, never the orders and titles proposed, voted at their module's line.
const headSeeds = $derived(doc.seedHead.length);
const openArticles = $derived(doc.open.filter((x) => !x.unit.kind).length - headSeeds);
/** 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, besides: string[] = [])}
<VoteFold
{doc}
proposal={p}
{unit}
{besides}
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}
{#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}
<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}
<TitleBar
unit={titleUnit}
stale={titleStale}
selected={panel?.unitId === titleUnit.id}
{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">
{i18n.plural('docs.inForce', doc.inForce.length)} · {#if headSeeds}{i18n.plural('docs.seeds', headSeeds)}{' · '}{/if}{i18n.plural('docs.open', openArticles)}
{#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>
<!-- One line of tools — the basket, the language of the text —, then the readings. -->
<div class="tools">
{#if canVote}
<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}
{/if}
<div class="seg small langs" 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>
<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}
</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>
<!-- Before the text, out of the vote: the settings of the author at the opening (lot U3); then a
separation, a soft line of the mood, never a border; then the first article. -->
<OpeningInfo {view} inForce={doc.inForce.length} />
<hr class="opening-rule" />
<section class="text" aria-label={t('doc.view.inforce')}>
{#if reading}
<!-- Nothing in force and nothing to start from: said once. With the Propositions d'amorce
at the head (ADR 0011), the chapter says it, in Yvv's words. -->
{#if !doc.inForce.length && !textSections.length}<p class="lead">{t('doc.empty')}</p>{/if}
{#each textSections as s, i (`${i}:${s.section}`)}
{@const label = readerSectionLabel(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}
{#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
</div>
{/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={!!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}
</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')}</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
sections={deepSecs}
vote={textIds.has(vote ?? '') ? null : vote}
{reordering}
all={filter !== 'all'}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
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>
</section>
</div>
{#if panel}
<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)} />
{/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;
}
.tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 1.2rem;
}
.langs {
margin-left: auto;
}
.views {
margin-top: 0.7rem;
}
.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);
white-space: nowrap;
}
.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: 0;
}
/* The separation before the text (Yvv, 2026-11-03): a soft line in the keystone's colour of the
mood, fading at its ends — drawn by its background, never a border. */
.opening-rule {
height: 2px;
margin: 2.2rem 0 1.6rem;
border: 0;
border-radius: 2px;
background: linear-gradient(
to right,
transparent,
color-mix(in srgb, var(--cle) 45%, transparent) 18%,
color-mix(in srgb, var(--cle) 45%, transparent) 82%,
transparent
);
}
.lead {
margin-bottom: 1.2rem;
font-size: 0.92rem;
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);
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: 0.6rem;
}
.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;
}
/* On a phone: the three readings go to the line rather than hide « Si tout passe » off the
edge (lot U2); each row fills the width, the targets big enough for a finger. */
@media (max-width: 899px) {
.seg.views {
display: flex;
gap: 2px;
}
.seg.views button {
flex: 1 1 auto;
}
.seg button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.reader {
transition: none;
}
}
</style>