forked from yvv/decision
Une version retenue par une objection le dit sous l'article, à chacun selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé » pour une redite, « Reformuler » pour une contradiction) ; la personne qui objecte, lever ou réitérer avant l'échéance. Le panier la montre sans choix et ne l'envoie jamais. « Détails » : faire une objection (redit / contredit), les objections et où elles en sont, les coquilles proposées par d'autres avec leur diff resserré, valider / refuser / revenir, l'historique dit qui a validé. Une coquille se propose sur toute version ouverte ou en vigueur. Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message traité n'a plus de geste. Répondre à une objection lit son message. Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
10 KiB
Svelte
292 lines
10 KiB
Svelte
<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.
|
|
import type { Snippet } from 'svelte';
|
|
import { plain } from '$lib/markdown';
|
|
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
|
|
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 {
|
|
articles,
|
|
vote,
|
|
ontoggle,
|
|
fold,
|
|
onsection,
|
|
}: {
|
|
articles: Article[];
|
|
/** The version whose vote is unfolded. */
|
|
vote: string | null;
|
|
ontoggle: (unit: Unit, p: Proposal) => void;
|
|
fold: Snippet<[Unit, Proposal]>;
|
|
/** Opens the basket on a section; absent when this session does not vote here. */
|
|
onsection?: (section: string) => 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);
|
|
|
|
// 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));
|
|
|
|
/** 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.held')}</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 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}
|
|
<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 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 !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/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 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 !== 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.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>
|
|
{#if vote === p.id}{@render fold(unit, p)}{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</li>
|
|
{/if}
|
|
{:else}
|
|
<li class="faint none">{t('deep.none')}</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<style>
|
|
.deep {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
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;
|
|
border: 0;
|
|
border-radius: var(--r-card);
|
|
background: transparent;
|
|
text-align: left;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
.article:not(.open) .row:hover,
|
|
.row.current {
|
|
background: var(--surface);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.main {
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
min-width: 0;
|
|
}
|
|
.title {
|
|
font-weight: 780;
|
|
}
|
|
.excerpt {
|
|
font-family: var(--font-text);
|
|
color: var(--ink-soft);
|
|
font-size: 0.95rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem 0.8rem;
|
|
align-items: center;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
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 {
|
|
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;
|
|
}
|
|
@media (max-width: 560px) {
|
|
.versions {
|
|
padding-left: 0.6rem;
|
|
}
|
|
}
|
|
</style>
|