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,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>
|
||||
|
||||
Reference in New Issue
Block a user