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
+38 -124
View File
@@ -1,30 +1,28 @@
<script lang="ts">
// The side panel: the basket of the vote, the versions of an article, a new
// proposal, the details of a version, the settings of the formula. The vote of
// one version unfolds under its article, not here.
// The side panel, one thing at a time (lot U, UX-lecteur.md): what is written, or works
// over several modules, or is read beside the text — the basket of the vote, a new
// proposal, the details of a version, the settings of the formula. Named at its head,
// with its cross; no tabs. What is read and decided on one version (its vote, its other
// versions, its inertia, the order of its section) unfolds in place, never here.
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import { answerPanel } from '$lib/objections';
import type { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte';
import type { Lang, Proposal } from '$lib/types';
import type { Lang } from '$lib/types';
import type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte';
import OrderEditor from './OrderEditor.svelte';
import ProposeView from './ProposeView.svelte';
import TitlePropose from './TitlePropose.svelte';
import VersionDetails from './VersionDetails.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
panel,
lang,
basket,
vote,
onchange,
onclose,
onvote,
@@ -34,61 +32,41 @@
panel: PanelState;
lang: Lang;
basket: BasketState;
/** The version whose vote is unfolded under its article. */
vote: string | null;
onchange: (p: PanelState) => void;
onclose: () => void;
/** Unfolds the vote of a version under its article; `close` closes the panel. */
/** Unfolds the vote of a version where it reads; `close` closes the panel. */
onvote: (proposalId: string, close: boolean) => void;
onsign: (lines: Line[]) => void;
} = $props();
const unit = $derived(doc.unit(panel.unitId));
const versions = $derived(
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
);
// A module (ADR 0009) is proposed with its own gesture: another order, another title.
const proposeLabel = $derived(unit?.kind === 'order' ? t('order.propose') : unit?.kind === 'title' ? t('title.propose') : t('doc.action.propose'));
const nameOf = (id: string) => doc.articleTitle(id, lang, doc.view?.doc.sourceLang ?? 'fr');
/** A version in the list: an order reads as its articles, in its order. */
const nameOfVersion = (v: Proposal) => (v.kind === 'order' && v.order ? v.order.ids.map(nameOf).join(' · ') : titleOf(v, unit));
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
const tabs = $derived(
(
[
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true],
// A version: reached from its vote, or from the list of versions.
['details', t('panel.details'), !!panel.proposalId],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const
).filter(([, , show]) => show),
/** One name per function, the same one that opened it. */
const heading = $derived(
panel.tab === 'basket'
? t('basket.title')
: panel.tab === 'settings'
? t('settings.title')
: panel.tab === 'details'
? t('panel.details')
: unit?.kind === 'title'
? t('title.propose')
: panel.unitId
? t('doc.action.propose')
: t('deep.new'),
);
let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => {
void panel.tab;
void panel.proposalId;
body?.scrollTo({ top: 0 });
});
// On a narrow sheet the tabs scroll: the open one stays in sight.
$effect(() => {
void panel.tab;
tablist?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
</script>
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
<aside class="panel card" aria-labelledby="panel-title">
<div class="bar">
<div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null, objectionId: null, edit: false })}>{label}</button>
{/each}
</div>
<h2 id="panel-title">{heading}</h2>
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
</div>
@@ -108,24 +86,6 @@
if (next) onchange(next);
}}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
{#each versions as v (v.id)}
<li>
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="text">
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<span class="title">{nameOfVersion(v)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : v.seedProposal ? t('doc.seedProposal') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}{#if v.blocked} · <b class="held">{t('deep.held')}</b>{/if}</span>
</span>
</button>
</li>
{/each}
</ul>
<button class="btn small" type="button" onclick={() => onchange({ ...panel, tab: 'propose', prefill: null, draftId: null })}><Icon name="plus" size={16} />{proposeLabel}</button>
{:else if panel.tab === 'propose' && unit?.kind === 'order'}
<OrderEditor {doc} {unit} {nameOf} ondone={(id) => onvote(id, true)} oncancel={onclose} />
{:else if panel.tab === 'propose' && unit?.kind === 'title'}
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
{:else if panel.tab === 'propose'}
@@ -139,7 +99,9 @@
ondone={(r) =>
r.proposalId && r.submitted
? onvote(r.proposalId, true)
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
: r.proposalId
? onchange({ unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' })
: onclose()}
/>
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
@@ -165,38 +127,20 @@
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
padding: 0.9rem 0.7rem 0.4rem 1.2rem;
}
.tabs {
display: flex;
gap: 0.2rem;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
overflow-x: auto;
}
.tabs button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.35rem 0.8rem;
min-height: 2.1rem;
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
white-space: nowrap;
}
.tabs button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
line-height: 1.2;
}
.close {
display: grid;
place-items: center;
flex: none;
width: 2.25rem;
height: 2.25rem;
width: 2.75rem;
height: 2.75rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
@@ -211,41 +155,6 @@
padding: 0.6rem 1.2rem 1.6rem;
overscroll-behavior: contain;
}
.versions {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: grid;
gap: 0.4rem;
}
.version {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.7rem 0.8rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
}
.version:hover,
.version.current {
background: var(--water-wash);
}
.text {
display: grid;
gap: 0.1rem;
}
.title {
font-weight: 750;
}
.small {
font-size: 0.82rem;
}
.held {
color: var(--ochre);
}
@keyframes enter {
from {
transform: translateX(16px);
@@ -270,4 +179,9 @@
}
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
animation: none;
}
}
</style>