forked from yvv/decision
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user