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
+265 -129
View File
@@ -1,52 +1,67 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version unfolds its vote below it; one with several shows the best of
// them and unfolds the others, each with its label, its water level and its
// marks — and each unfolds its own vote.
// The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
// coup d'œil »): each section folded to one line — its name, how many articles are
// proposed, a small water level for each, so one sees where support rises without opening
// anything. A gesture unfolds one, « Tout déplier » all of them; a filter, or a vote
// unfolded by the URL, opens the section that holds it. Inside, one line per article (Yvv,
// 2026-10-30): one with a single version unfolds its vote below it; one with several shows
// the best and unfolds the others. A section not read in the text above is closed here by
// its order.
import type { Snippet } from 'svelte';
import { plain } from '$lib/markdown';
import { inertiaBadge, moduleName, orderNote, relationOf, sectionLabel, titleOf } from '$lib/labels';
import { needOf, relationOf, sectionLabel, titleOf } from '$lib/labels';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions';
import { excerpts, type Article, type DeepSection } from '$lib/versions';
import Crans from './Crans.svelte';
import Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
articles,
sections,
vote,
reordering = null,
all = false,
ontoggle,
fold,
order,
onsection,
nameOf = (id) => id,
}: {
/** Articles and modules (ADR 0009) with open proposals, in document order. */
articles: Article[];
/** The articles with open proposals, by section, each with its order when it closes here. */
sections: DeepSection[];
/** The version whose vote is unfolded. */
vote: string | null;
/** The order module being reordered: its section stays open. */
reordering?: string | null;
/** Every section open (a filter is on). */
all?: boolean;
ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>;
/** The order line of a section, at its end. */
order: Snippet<[Unit]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: string) => void;
/** The title of an article, for the orders proposed. */
nameOf?: (unitId: string) => string;
} = $props();
/** What a line shows of a version: the start of its text, or the order it proposes. */
function excerptOf(p: Proposal): string {
if (p.kind === 'text') return plain(p.body);
if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · ');
return '';
const keyOf = (s: DeepSection) => s.key ?? '';
// Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
let unfolded = $state<Record<string, boolean>>({});
let versionsOpen = $state<Record<string, boolean>>({});
const holds = (s: DeepSection) =>
s.articles.some((a) => a.versions.some((p) => p.id === vote)) ||
(!!s.order && (s.order.id === reordering || [s.order.inForce, ...s.order.proposals].some((p) => p?.id === vote)));
const isOpen = (s: DeepSection) => all || (unfolded[keyOf(s)] ?? holds(s));
const everyOpen = $derived(sections.every((s) => isOpen(s)));
const toggle = (s: DeepSection) => (unfolded[keyOf(s)] = !isOpen(s));
function setAll(open: boolean) {
unfolded = Object.fromEntries(sections.map((s) => [keyOf(s), open]));
}
// Unfolded by hand, or because the version whose vote is unfolded is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
function headline(a: Article): string {
if (a.unit.kind) return moduleName(a.unit);
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;
@@ -66,15 +81,15 @@
const textual = a.versions.filter((p) => p.kind === 'text');
const told = excerpts(textual.map((p) => plain(p.body)));
return new Map([
...a.versions.map((p): [string, string] => [p.id, excerptOf(p)]),
...a.versions.map((p): [string, string] => [p.id, p.kind === 'text' ? plain(p.body) : '']),
...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
]);
}
</script>
{#snippet module(p: Proposal)}
{#if p.kind === 'title'}<span class="faint">{t('module.title.name')}</span>{/if}
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
{#snippet need(p: Proposal)}
{@const n = needOf(p)}
{#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
{/snippet}
{#snippet marks(p: Proposal)}
@@ -85,97 +100,219 @@
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet}
{#if sections.length && !all}
<div class="all">
<button type="button" class="linkish" onclick={() => setAll(!everyOpen)}>{t(everyOpen ? 'deep.fold' : 'deep.unfold')}</button>
</div>
{/if}
<ul class="deep">
{#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">
<span class="eyebrow">{sectionLabel(unit.section)}</span>
{#if onsection && unit.section}
{@const key = unit.section}
{#each sections as s, i (keyOf(s))}
{@const open = isOpen(s)}
{@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))}
<li class="section" class:open>
<div class="head">
<button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
<span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
<span class="name">{label}</span>
{#if s.articles.length}
<span class="count">{i18n.plural('deep.section.count', s.articles.length)}</span>
<span class="waters" aria-hidden="true">
{#each s.articles as a (a.unit.id)}
{@const best = a.versions[0]}
{#if best}<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} size="mini" />{/if}
{/each}
</span>
{/if}
</button>
{#if onsection && s.key && s.articles.length}
{@const key = s.key}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</li>
{/if}
{#if best && a.versions.length === 1}
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</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 !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{@render module(best)}
{@render marks(best)}
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</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 excerptOf(best)}<span class="excerpt">{excerptOf(best)}</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 !== 3}<span class="faint">{inertiaBadge(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={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(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.kind === 'title') && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
{#if told.get(p.id)}<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>
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each}
{@render marks(p)}
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
</li>
{/if}
</div>
{#if open}
<div class="body" id="deep-sec-{i}">
{#if s.order?.id !== reordering}
<ul class="rows">
{#each s.articles as a (a.unit.id)}
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if best && a.versions.length === 1}
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(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>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{@render marks(best)}
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const vopen = isVersionsOpen(a)}
{@const title = headline(a)}
{@const told = toldApart(a)}
<li class="article" class:open={vopen}>
<button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{title}</span>
{#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</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>
{@render need(best)}
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/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={!vopen}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(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 told.get(p.id)}<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 need(p)}
{@render marks(p)}
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
{/if}
{#if s.order}{@render order(s.order)}{/if}
</div>
{/if}
</li>
{:else}
<li class="faint none">{t('deep.none')}</li>
{/each}
</ul>
<style>
.deep {
.all {
display: flex;
justify-content: flex-end;
margin-bottom: 0.2rem;
}
.linkish {
border: 0;
background: none;
padding: 0.3rem 0;
min-height: 2.25rem;
color: var(--water);
font-weight: 750;
font-size: 0.86rem;
text-decoration: underline;
text-underline-offset: 3px;
}
.deep,
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.article {
.section {
margin: 0 -1rem;
border-radius: var(--r-card);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.6rem;
}
.toggle {
flex: 1 1 16rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.7rem;
min-height: 3rem;
padding: 0.55rem 1rem;
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease;
}
.toggle:hover {
background: var(--surface);
}
.name {
font-family: var(--font-text);
font-style: italic;
font-size: 1.12rem;
color: var(--ink);
}
.count {
font-size: 0.8rem;
font-weight: 750;
color: var(--water);
}
.waters {
display: inline-flex;
align-items: flex-end;
gap: 3px;
}
.head .chevron {
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.section.open .head .chevron {
transform: rotate(90deg);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
margin-right: 0.6rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.body {
display: grid;
gap: 0.25rem;
padding: 0 0 0.9rem;
animation: unfold 0.2s var(--ease);
}
.body > :global(.order-line) {
margin: 0.3rem 0 0.4rem;
}
.article {
border-radius: var(--r-card);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.article.open {
@@ -225,17 +362,17 @@
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.count {
color: var(--water);
.need {
color: var(--ink);
}
.chevron {
.row .chevron {
flex: none;
margin-left: auto;
display: inline-flex;
color: var(--ink-faint);
transition: transform 0.2s var(--ease);
}
.open .chevron {
.article.open .row .chevron {
transform: rotate(90deg);
}
.versions {
@@ -272,40 +409,39 @@
font-weight: 600;
color: var(--ink-soft);
}
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.3rem 1rem;
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.fold {
padding: 0 1rem 1rem;
}
.section:first-child {
margin-top: 0.4rem;
}
.none {
padding: 0.8rem 0;
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (max-width: 560px) {
.versions {
padding-left: 0.6rem;
}
}
@media (max-width: 899px) {
.vote-section {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.body {
animation: none;
}
.head .chevron,
.row .chevron,
.toggle,
.row,
.version,
.article {
transition: none;
}
}
</style>