v3 : les versions d'un article tiennent une seule ligne (L8)

- « Propositions en cours » : une ligne par article. Un article à plusieurs versions montre
  leur nombre et la meilleure en tête (niveau d'eau, pour / contre, « en tête » quand elle
  devance vraiment) ; la ligne se déplie (bouton à aria-expanded, clavier) sur ses versions,
  chacune avec son étiquette (variante, auteur), son niveau, ses marques, et un extrait qui
  part de là où les versions divergent ; un clic ouvre son vote comme avant.
- Règle pure dans versions.ts (vitest) : ordre du document, meilleure d'abord (niveau d'eau,
  puis pour, puis contre ; une version bloquée jamais en tête), un article reste dans un
  filtre dès qu'une de ses versions passe.
- e2e : C3 compte 30 lignes, C14 déplie « Connaissance suffisante » au clavier ; C11 passe
  par un article à une seule version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 10:42:27 +01:00
co-authored by Claude Opus 5.5
parent a5b92faa96
commit edc88111e0
10 changed files with 397 additions and 40 deletions
+168 -28
View File
@@ -1,45 +1,126 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version opens its vote; one with several shows the best of them and
// unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote.
import { plain } from '$lib/markdown';
import { relationOf, titleOf } from '$lib/labels';
import { t, type Key } from '$lib/i18n/index.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions';
import Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
items,
articles,
selected,
onopen,
}: { items: { unit: Unit; proposal: Proposal }[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
const variants = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
// Unfolded by hand, or because the version open in the panel is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
function headline(a: Article): string {
const text = a.versions.find((p) => p.kind === 'text');
const best = a.versions[0];
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
}
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
function labelOf(p: Proposal, a: Article): string {
if (p.kind !== 'text') return titleOf(p, a.unit);
if (p.label) return t('deep.variant', { label: p.label });
if (p.author?.name) return t('panel.by', { name: p.author.name });
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
return t('deep.version', { n: arrival + 1 });
}
/** The excerpt of each text version, from where it parts ways with its siblings. */
function toldApart(a: Article): Map<string, string> {
const textual = a.versions.filter((p) => p.kind === 'text');
const told = excerpts(textual.map((p) => plain(p.body)));
return new Map(textual.map((p, k) => [p.id, told[k] ?? plain(p.body)]));
}
</script>
{#snippet marks(p: Proposal)}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet}
<ul class="deep">
{#each items as { unit, proposal: p }, i (p.id)}
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section}
{#each articles as a, i (a.unit.id)}
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
{/if}
<li>
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="title">{titleOf(p, unit)}</span>
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if}
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{#if best && a.versions.length === 1}
<li class="article">
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
{@render marks(best)}
</span>
</span>
</span>
</button>
</li>
</button>
</li>
{:else if best}
{@const open = isOpen(a)}
{@const title = headline(a)}
{@const told = toldApart(a)}
<li class="article" class:open>
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{title}</span>
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
<span class="meta">
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
</span>
</span>
<span class="chevron"><Icon name="chevron" size={18} /></span>
</button>
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
{#if p.kind === 'text'}<span class="excerpt">{told.get(p.id)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{@render marks(p)}
</span>
</span>
</button>
</li>
{/each}
</ul>
</li>
{/if}
{:else}
<li class="faint none">{t('deep.none')}</li>
{/each}
@@ -53,21 +134,28 @@
display: grid;
gap: 0.25rem;
}
.article {
margin: 0 -1rem;
border-radius: var(--r-card);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.article.open {
background: var(--surface);
box-shadow: var(--shadow);
}
.row {
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
padding: 0.85rem 1rem;
margin: 0 -1rem;
width: calc(100% + 2rem);
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease, transform 0.12s ease;
transition: background-color 0.15s ease;
}
.row:hover,
.article:not(.open) .row:hover,
.row.current {
background: var(--surface);
box-shadow: var(--shadow);
@@ -98,6 +186,53 @@
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.count {
color: var(--water);
}
.chevron {
flex: none;
margin-left: auto;
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.open .chevron {
transform: rotate(90deg);
}
.versions {
list-style: none;
margin: 0;
padding: 0 0.6rem 0.7rem 1.6rem;
display: grid;
gap: 0.15rem;
}
.versions[hidden] {
display: none;
}
.version {
display: flex;
gap: 0.85rem;
align-items: center;
width: 100%;
padding: 0.6rem 0.8rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.version:hover,
.version.current {
background: var(--surface-2);
}
.label {
font-weight: 750;
font-size: 0.92rem;
}
.other {
font-weight: 600;
color: var(--ink-soft);
}
.section {
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
@@ -108,4 +243,9 @@
.none {
padding: 0.8rem 0;
}
@media (max-width: 560px) {
.versions {
padding-left: 0.6rem;
}
}
</style>
+4
View File
@@ -130,6 +130,10 @@ export const en: Messages = {
'deep.filter.mine': 'Mine',
'deep.none': 'No proposals in this filter.',
'deep.variant': 'variant {label}',
'deep.versions.one': '{n} version',
'deep.versions.other': '{n} versions',
'deep.version': 'version {n}',
'deep.lead': 'leading: {label}',
'section.introduction': 'Introduction',
'section.fondamental': 'Fundamental commitments',
'section.technique': 'Technical commitments',
+4
View File
@@ -130,6 +130,10 @@ export const es: Messages = {
'deep.filter.mine': 'Las mías',
'deep.none': 'Ninguna propuesta en este filtro.',
'deep.variant': 'variante {label}',
'deep.versions.one': '{n} versión',
'deep.versions.other': '{n} versiones',
'deep.version': 'versión {n}',
'deep.lead': 'en cabeza: {label}',
'section.introduction': 'Introducción',
'section.fondamental': 'Compromisos fundamentales',
'section.technique': 'Compromisos técnicos',
+4
View File
@@ -133,6 +133,10 @@ export const fr = {
'deep.filter.mine': 'Les miennes',
'deep.none': 'Aucune proposition dans ce filtre.',
'deep.variant': 'variante {label}',
'deep.versions.one': '{n} version',
'deep.versions.other': '{n} versions',
'deep.version': 'version {n}',
'deep.lead': 'en tête : {label}',
'section.introduction': 'Introduction',
'section.fondamental': 'Engagements fondamentaux',
'section.technique': 'Engagements techniques',
+79
View File
@@ -0,0 +1,79 @@
// The versions of an article, for the list of open proposals (Yvv, 2026-10-30:
// « Quand un article a plusieurs versions, faut pas ajouter un article dans la
// liste »): one line per article, its best version first, the others inside.
import type { Proposal, Unit } from './types';
export type Filter = 'all' | 'wellrated' | 'mine';
export interface Article {
unit: Unit;
/** The open proposals of the article, best first. */
versions: Proposal[];
/** The best version is strictly ahead of the next one (not a tie). */
leads: boolean;
}
/** How far a version has risen toward its threshold, from 0 to 1: its water level. */
export function level(p: Proposal): number {
const required = p.evaluation?.required ?? 0;
return required > 0 ? Math.min(1, p.tally.for / required) : 0;
}
/**
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
* against. A blocked version comes after the others: it cannot enter into force.
*/
export function compare(a: Proposal, b: Proposal): number {
return (
Number(a.blocked) - Number(b.blocked) ||
level(b) - level(a) ||
b.tally.for - a.tally.for ||
a.tally.against - b.tally.against
);
}
/**
* Plain excerpts of sibling versions, each starting a few words before the versions
* part ways — two variants often share a long opening, and a one-line excerpt would
* show only that.
*/
export function excerpts(texts: string[]): string[] {
const LEAD = 3;
const words = texts.map((t) => t.split(/\s+/).filter(Boolean));
const [first] = words;
if (!first || words.length < 2) return texts;
let common = 0;
while (common < first.length && words.every((w) => w[common] === first[common])) common++;
const start = common - LEAD;
// A short common start reads well as it is.
if (start < 4) return texts;
return words.map((w) => `… ${w.slice(start).join(' ')}`);
}
export function passes(p: Proposal, filter: Filter): boolean {
if (filter === 'wellrated') return !!p.evaluation?.wellRated;
if (filter === 'mine') return p.mine;
return true;
}
/**
* One article per unit, in the order of the document; an article is kept when at
* least one of its versions passes the filter, and then shows all of them.
*/
export function articles(items: { unit: Unit; proposal: Proposal }[], filter: Filter = 'all'): Article[] {
const byUnit = new Map<string, { unit: Unit; versions: Proposal[] }>();
for (const { unit, proposal } of items) {
const entry = byUnit.get(unit.id) ?? { unit, versions: [] };
entry.versions.push(proposal);
byUnit.set(unit.id, entry);
}
return [...byUnit.values()]
.filter(({ versions }) => versions.some((p) => passes(p, filter)))
.map(({ unit, versions }) => {
// Array.prototype.sort is stable: equal versions keep the order of the document.
const ranked = [...versions].sort(compare);
const [best, next] = ranked;
return { unit, versions: ranked, leads: !!best && !!next && compare(best, next) < 0 };
});
}