forked from yvv/decision
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:
@@ -90,9 +90,10 @@
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const n = await voteDirect(doc, toSend);
|
||||
const r = await voteDirect(doc, toSend);
|
||||
basket.clear();
|
||||
toasts.show(i18n.plural('basket.sent', n));
|
||||
// A simulated vote says so, never « enregistré » (ADR 0012).
|
||||
toasts.show(i18n.plural(r.simulated ? 'basket.sent.simulated' : 'basket.sent', r.count));
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
@@ -115,8 +116,8 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<section class="basket" aria-labelledby="basket-title">
|
||||
<h3 id="basket-title">{t('basket.title')}</h3>
|
||||
<!-- Named by the head of the panel (« Panier de vote »): one name, one place. -->
|
||||
<section class="basket">
|
||||
<p class="muted">{t('basket.intro')}</p>
|
||||
{#if access && !access.open}
|
||||
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
|
||||
@@ -216,11 +217,6 @@
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.closed {
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
</script>
|
||||
|
||||
{#if view && p}
|
||||
<section class="settings" aria-labelledby="settings-title">
|
||||
<h3 id="settings-title">{t('settings.title')}</h3>
|
||||
<!-- Named by the head of the panel (« Réglages de la formule »): one name, one place. -->
|
||||
<section class="settings">
|
||||
<p class="muted">{t('settings.intro')}</p>
|
||||
|
||||
<ul class="values">
|
||||
@@ -113,11 +113,6 @@
|
||||
gap: 0.8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-text);
|
||||
font-weight: 500;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
// The order of a section, at its end (ADR 0009; Yvv, 2026-11-02: « la répétition de
|
||||
// "ordonnancement" est nulle. Il faudrait que ce soit en fin de section »): one line, never
|
||||
// the list of its articles again — the handle that marks an order, « Ordre de la section »,
|
||||
// its weight in notches, its state, the orders proposed. « Le vote » unfolds here the vote
|
||||
// of any of its versions (the list whole); « Réordonner » puts handles on the articles of
|
||||
// the section, in place.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { moduleName } from '$lib/labels';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import Crans from './Crans.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let {
|
||||
unit,
|
||||
vote,
|
||||
reordering = false,
|
||||
fold,
|
||||
reorder,
|
||||
onvote,
|
||||
onreorder,
|
||||
}: {
|
||||
/** The order module of the section. */
|
||||
unit: Unit;
|
||||
/** The version whose vote is unfolded, wherever it lives. */
|
||||
vote: string | null;
|
||||
reordering?: boolean;
|
||||
/** The vote of one of its versions, unfolded under the line. */
|
||||
fold: Snippet<[Proposal]>;
|
||||
/** The articles of the section with their handles, while reordering. */
|
||||
reorder: Snippet<[Unit]>;
|
||||
/** Unfolds (or folds) the vote of one of its versions. */
|
||||
onvote: (proposalId: string) => void;
|
||||
onreorder: () => void;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(unit.inForce);
|
||||
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order'));
|
||||
// Any version of the module unfolds here: its order in force, an order proposed, a change of its inertia.
|
||||
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
|
||||
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
|
||||
</script>
|
||||
|
||||
<div class="order-line" class:unfolded={!!shown || reordering} role="group" aria-label={moduleName(unit)}>
|
||||
<div class="bar">
|
||||
<span class="mark" aria-hidden="true"><Icon name="grip" size={16} /></span>
|
||||
<span class="name">{t('order.line')}</span>
|
||||
<Crans level={unit.inertia} />
|
||||
{#if p?.seedMinimal}<span class="faint" title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{:else if p?.enteredAt}<span class="faint">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||
{#if proposed.length && !reordering}
|
||||
{@const first = proposed[0]}
|
||||
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('order.proposed', proposed.length)}</button>
|
||||
{/if}
|
||||
{#if !reordering}
|
||||
<span class="links">
|
||||
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
|
||||
<button type="button" onclick={onreorder}>{t('order.reorder')}</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if reordering}
|
||||
{@render reorder(unit)}
|
||||
{:else if shown}
|
||||
{@render fold(shown)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.order-line {
|
||||
margin: 0.4rem -1.3rem 1.6rem;
|
||||
padding: 0.35rem 1.3rem;
|
||||
border-radius: var(--r-card);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
.order-line:hover,
|
||||
.order-line:focus-within,
|
||||
.order-line.unfolded {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.order-line.unfolded {
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.75rem;
|
||||
min-height: 2.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
color: var(--water);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.name {
|
||||
color: var(--ink);
|
||||
font-weight: 780;
|
||||
}
|
||||
.proposed {
|
||||
border: 0;
|
||||
background: var(--water-wash);
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
min-height: 2rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.links button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
padding: 0.35rem 0.6rem;
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links button:hover,
|
||||
.links button.on {
|
||||
background: var(--water-wash);
|
||||
}
|
||||
.order-line > :global(.reorder) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.links button,
|
||||
.proposed {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.order-line {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
// The order of the articles of a section, at its end (ADR 0009): a module at
|
||||
// inertia 2, read, voted and proposed with the gestures of an article.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, moduleName } from '$lib/labels';
|
||||
import type { Unit } from '$lib/types';
|
||||
import UnitBlock from './UnitBlock.svelte';
|
||||
|
||||
let {
|
||||
unit,
|
||||
nameOf,
|
||||
selected = false,
|
||||
voteOpen = false,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
/** The order module of the section. */
|
||||
unit: Unit;
|
||||
/** The title of an article, in the language it is read in. */
|
||||
nameOf: (unitId: string) => string;
|
||||
selected?: boolean;
|
||||
voteOpen?: boolean;
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(unit.inForce);
|
||||
const members = $derived(unit.members ?? []);
|
||||
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'order').length);
|
||||
</script>
|
||||
|
||||
<UnitBlock
|
||||
text={{ title: moduleName(unit), body: '', translated: false, stale: false, missing: false }}
|
||||
module
|
||||
seedMinimal={!!p?.seedMinimal}
|
||||
enteredAt={p?.enteredAt ?? null}
|
||||
votes={(p?.tally.for ?? 0) + (p?.tally.against ?? 0)}
|
||||
{rivals}
|
||||
{selected}
|
||||
voteFor={p?.id ?? null}
|
||||
{voteOpen}
|
||||
proposeLabel={t('order.propose')}
|
||||
{fold}
|
||||
{onaction}
|
||||
>
|
||||
{#snippet meta()}
|
||||
<span class="meta faint">{i18n.plural('module.order.count', members.length)} · {inertiaBadge(unit.inertia)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<!-- In one breath, two lines at most: the whole list unfolds with its vote. -->
|
||||
<ol class="order" aria-label={t('module.order.inforce')}>
|
||||
{#each members as id, i (id)}<li><span class="n">{i + 1}</span> {nameOf(id)}</li>{/each}
|
||||
</ol>
|
||||
{/snippet}
|
||||
</UnitBlock>
|
||||
|
||||
<style>
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.order {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.order li {
|
||||
display: inline;
|
||||
}
|
||||
.order li + li::before {
|
||||
content: '·';
|
||||
margin: 0 0.45em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.n {
|
||||
font-weight: 800;
|
||||
color: var(--water);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
// The quorum of an article, set by the person who wrote it before the first vote (ADR 0013;
|
||||
// Yvv, 2026-11-02: « L'auteur, avant le 1er vote »): shown only when the server says so
|
||||
// (`canSetQuorum`), with the default of its level recalled and the way back to it. The
|
||||
// server sets the bounds and refuses: out of bounds (400), a vote already there (409), not
|
||||
// the author (403) — each said in the person's words.
|
||||
import { untrack } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Unit } from '$lib/types';
|
||||
import { errorText } from '$lib/vote';
|
||||
|
||||
let { doc, unit }: { doc: DocState; unit: Unit } = $props();
|
||||
|
||||
const fallback = $derived(unit.quorumDefault ?? 0);
|
||||
const current = $derived(unit.quorum ?? fallback);
|
||||
let value = $state<number | null>(null);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
/** Refused because a vote arrived: nothing more to set here. */
|
||||
let closed = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const now = current;
|
||||
untrack(() => (value = now));
|
||||
});
|
||||
|
||||
function said(e: unknown): string {
|
||||
if (!(e instanceof ApiError)) return errorText(e);
|
||||
if (e.code === 'quorum') return t('quorum.error.quorum', { w: i18n.number(doc.view?.doc.electorate ?? 0) });
|
||||
if (e.code === 'voted') return t('quorum.error.voted');
|
||||
if (e.code === 'forbidden') return t('quorum.error.forbidden');
|
||||
return errorText(e);
|
||||
}
|
||||
|
||||
async function send(quorum: number | null) {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api.put<{ quorum: number | null; quorumDefault: number }>(`/units/${unit.id}/quorum`, { quorum });
|
||||
const n = r.quorum ?? r.quorumDefault;
|
||||
toasts.show(i18n.plural(r.quorum === null ? 'quorum.reset.done' : 'quorum.done', n));
|
||||
await doc.reload();
|
||||
} catch (e) {
|
||||
error = said(e);
|
||||
if (e instanceof ApiError && (e.code === 'voted' || e.code === 'forbidden')) closed = true;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="quorum" aria-labelledby="quorum-{unit.id}">
|
||||
<h4 id="quorum-{unit.id}">{i18n.plural('quorum.heading', current)}</h4>
|
||||
<p class="faint small">{t('quorum.explain')}</p>
|
||||
<p class="faint small">{i18n.plural('quorum.default', fallback)}</p>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="visually-hidden">{t('quorum.field')}</span>
|
||||
<input type="number" inputmode="numeric" min="1" step="1" bind:value disabled={busy || closed} />
|
||||
</label>
|
||||
<button class="btn small" type="button" disabled={busy || closed || value === null || value === current} onclick={() => value !== null && send(Math.trunc(value))}>{t('quorum.set')}</button>
|
||||
{#if unit.quorum !== null && unit.quorum !== undefined}
|
||||
<button class="btn quiet small" type="button" disabled={busy || closed} onclick={() => send(null)}>{t('quorum.reset', { n: fallback })}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quorum {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
input {
|
||||
width: 6.5rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 750;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--water);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
+127
-23
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
// Another order for a section, with a list one clicks (Yvv, 2026-10-30: « une
|
||||
// simple liste clipable »): an article, then its new place — or its arrows. With
|
||||
// the keyboard: Enter chooses an article, the up and down arrows move it. Then
|
||||
// « Proposer cet ordre »: the order goes to the vote, like an article (ADR 0009).
|
||||
// Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
|
||||
// "poignées" aux modules de la section. Et permette de voter. »): the articles of the
|
||||
// section fold to their titles, a handle before each. One drags a handle (mouse, pen or
|
||||
// finger: pointer events), or — without dragging — clicks an article then its new place,
|
||||
// or uses its arrows; with the keyboard, the up and down arrows move the article that has
|
||||
// the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the
|
||||
// vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order
|
||||
// already proposed is voted, never proposed twice.
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { api } from '$lib/api';
|
||||
@@ -27,13 +31,14 @@
|
||||
unit: Unit;
|
||||
/** The title of an article, in the language it is read in. */
|
||||
nameOf: (unitId: string) => string;
|
||||
/** The order proposed: its vote unfolds. */
|
||||
/** The order proposed, or the same one already proposed: its vote unfolds. */
|
||||
ondone: (proposalId: string) => void;
|
||||
oncancel?: () => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
let list = $state<string[]>([]);
|
||||
let chosen = $state<string | null>(null);
|
||||
let dragging = $state<string | null>(null);
|
||||
let said = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
@@ -41,6 +46,12 @@
|
||||
const start = $derived(unit.members ?? []);
|
||||
const changed = $derived(moved(start, list));
|
||||
const unchanged = $derived(sameOrder(start, list));
|
||||
/** The same order, already proposed: one votes it rather than proposing a double. */
|
||||
const existing = $derived(
|
||||
unit.proposals.find((p) => p.status === 'open' && p.kind === 'order' && sameOrder(p.order?.ids ?? [], list)),
|
||||
);
|
||||
/** An article of the section only proposed, not in force: said as such. */
|
||||
const proposed = (id: string) => !doc.unit(id)?.inForce;
|
||||
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Started again only for another section: a reload never wipes what is being moved.
|
||||
@@ -99,11 +110,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Dragging by the handle: the article takes the place of the row under the pointer.
|
||||
function grab(e: PointerEvent, id: string) {
|
||||
if (busy || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragging = id;
|
||||
chosen = null;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function drag(e: PointerEvent) {
|
||||
const id = dragging;
|
||||
if (!id || !root) return;
|
||||
for (const row of root.querySelectorAll<HTMLElement>('li[data-id]')) {
|
||||
const box = row.getBoundingClientRect();
|
||||
if (e.clientY < box.top || e.clientY >= box.bottom) continue;
|
||||
const target = row.dataset.id;
|
||||
if (target && target !== id) list = place(list, id, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function drop() {
|
||||
const id = dragging;
|
||||
if (!id) return;
|
||||
dragging = null;
|
||||
tell(id);
|
||||
void focus(id);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (unchanged) {
|
||||
error = t('order.edit.unchanged');
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
ondone(existing.id);
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
@@ -119,15 +164,19 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="reorder">
|
||||
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
|
||||
<ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
|
||||
{#each list as id, i (id)}
|
||||
{@const name = nameOf(id)}
|
||||
<li class:chosen={chosen === id} class:moved={changed.has(id)} animate:flip={{ duration: reduce ? 0 : 160 }}>
|
||||
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
|
||||
<li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
|
||||
<span class="grip" aria-hidden="true" onpointerdown={(e) => grab(e, id)} onpointermove={drag} onpointerup={drop} onpointercancel={drop}>
|
||||
<Icon name="grip" size={18} />
|
||||
</span>
|
||||
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} disabled={busy} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
|
||||
<span class="n" aria-hidden="true">{i + 1}</span>
|
||||
<span class="name">{name}</span>
|
||||
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
|
||||
</button>
|
||||
<span class="arrows">
|
||||
<button type="button" class="arrow up" aria-label={t('order.edit.up', { title: name })} disabled={i === 0 || busy} onclick={() => shift(id, -1)}><Icon name="chevron" size={16} /></button>
|
||||
@@ -137,20 +186,21 @@
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="visually-hidden" aria-live="polite">{said}</p>
|
||||
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })}</p>
|
||||
<p class="faint small">{t('module.order.rule')}</p>
|
||||
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('module.order.rule')}</p>
|
||||
{#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if}
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
{#if oncancel}<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>{/if}
|
||||
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t('order.edit.submit')}</button>
|
||||
<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>
|
||||
<button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
.reorder {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
animation: unfold 0.2s var(--ease);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
@@ -162,26 +212,52 @@
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
border-radius: var(--r-input);
|
||||
background: var(--surface-2);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color 0.15s var(--ease);
|
||||
}
|
||||
.list li.moved {
|
||||
background: var(--moss-wash);
|
||||
background: color-mix(in srgb, var(--surface) 55%, var(--moss-wash));
|
||||
}
|
||||
.list li.chosen {
|
||||
.list li.chosen,
|
||||
.list li.dragging {
|
||||
background: var(--water);
|
||||
color: var(--on-water);
|
||||
}
|
||||
.list li.dragging {
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
.grip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 2.4rem;
|
||||
min-height: 2.75rem;
|
||||
align-self: stretch;
|
||||
color: var(--ink-faint);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
border-radius: var(--r-input) 0 0 var(--r-input);
|
||||
}
|
||||
.grip:hover {
|
||||
color: var(--water);
|
||||
}
|
||||
.dragging .grip,
|
||||
.chosen .grip {
|
||||
color: inherit;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
gap: 0.3rem 0.6rem;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 0.4rem 0.5rem 0.8rem;
|
||||
padding: 0.45rem 0.3rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
@@ -191,18 +267,25 @@
|
||||
}
|
||||
.n {
|
||||
flex: none;
|
||||
min-width: 1.4rem;
|
||||
min-width: 1.2rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--water);
|
||||
}
|
||||
.chosen .n {
|
||||
.chosen .n,
|
||||
.dragging .n {
|
||||
color: inherit;
|
||||
}
|
||||
.name {
|
||||
font-weight: 700;
|
||||
font-family: var(--font-text);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chosen .tag,
|
||||
.dragging .tag {
|
||||
background: color-mix(in srgb, var(--on-water) 22%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
.arrows {
|
||||
display: flex;
|
||||
flex: none;
|
||||
@@ -234,6 +317,11 @@
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--water);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
@@ -245,7 +333,23 @@
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* On a phone: every target is big enough for a finger. */
|
||||
@media (max-width: 899px) {
|
||||
.arrow {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@keyframes unfold {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reorder {
|
||||
animation: none;
|
||||
}
|
||||
.list li {
|
||||
transition: none;
|
||||
}
|
||||
@@ -1,49 +1,60 @@
|
||||
<script lang="ts">
|
||||
// The title of the document is a module (ADR 0009, inertia 4): under the title,
|
||||
// the gestures of an article — the vote, the proposals, another title.
|
||||
// The title of the document is a module (ADR 0009, inertia 4): one line under the title,
|
||||
// of the same voice as the order of a section — its weight in notches, its state, the
|
||||
// titles proposed. « Le vote » unfolds here the vote of any of its versions; another title
|
||||
// is written in the panel.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, moduleName } from '$lib/labels';
|
||||
import type { Unit } from '$lib/types';
|
||||
import { moduleName } from '$lib/labels';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import Crans from './Crans.svelte';
|
||||
|
||||
let {
|
||||
unit,
|
||||
stale = false,
|
||||
selected = false,
|
||||
voteOpen = false,
|
||||
vote,
|
||||
fold,
|
||||
onaction,
|
||||
onvote,
|
||||
onpropose,
|
||||
}: {
|
||||
/** The title module of the document. */
|
||||
unit: Unit;
|
||||
/** The title read is a translation of an older title. */
|
||||
stale?: boolean;
|
||||
selected?: boolean;
|
||||
voteOpen?: boolean;
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
/** The version whose vote is unfolded, wherever it lives. */
|
||||
vote: string | null;
|
||||
fold: Snippet<[Proposal]>;
|
||||
onvote: (proposalId: string) => void;
|
||||
onpropose: () => void;
|
||||
} = $props();
|
||||
|
||||
const p = $derived(unit.inForce);
|
||||
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0));
|
||||
const rivals = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title').length);
|
||||
const proposed = $derived(unit.proposals.filter((x) => x.status === 'open' && x.kind === 'title'));
|
||||
const shown = $derived([p, ...unit.proposals.filter((x) => x.status === 'open')].find((x) => x?.id === vote) ?? null);
|
||||
</script>
|
||||
|
||||
<div class="title-unit" class:selected class:unfolded={voteOpen}>
|
||||
<div class="title-unit" class:selected class:unfolded={!!shown} role="group" aria-label={moduleName(unit)}>
|
||||
<p class="bar">
|
||||
<span class="status faint">
|
||||
{moduleName(unit)} · {inertiaBadge(unit.inertia)}
|
||||
{moduleName(unit)}
|
||||
{#if p?.seedMinimal} · <span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
|
||||
</span>
|
||||
<Crans level={unit.inertia} />
|
||||
{#if stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{/if}
|
||||
{#if proposed.length}
|
||||
{@const first = proposed[0]}
|
||||
<button type="button" class="proposed" onclick={() => first && onvote(first.id)}>{i18n.plural('title.proposed', proposed.length)}</button>
|
||||
{/if}
|
||||
<span class="links">
|
||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={p ? `vote-${p.id}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{t('title.propose')}</button>
|
||||
{#if p}<button type="button" class:on={shown?.id === p.id} aria-expanded={shown?.id === p.id} aria-controls="vote-{p.id}" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>{/if}
|
||||
<button type="button" onclick={onpropose}>{t('title.propose')}</button>
|
||||
</span>
|
||||
</p>
|
||||
{#if voteOpen && fold}<div class="fold">{@render fold()}</div>{/if}
|
||||
{#if shown}<div class="fold">{@render fold(shown)}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -65,11 +76,23 @@
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-soft);
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.status {
|
||||
font-weight: 700;
|
||||
}
|
||||
.proposed {
|
||||
border: 0;
|
||||
background: var(--water-wash);
|
||||
color: var(--water);
|
||||
font-weight: 750;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
min-height: 2rem;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -105,6 +128,12 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.links button,
|
||||
.proposed {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.title-unit,
|
||||
.links {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script lang="ts">
|
||||
// An article as the text reads it: its title, its body, its state; two gestures —
|
||||
// « Le vote » unfolds under it (its other versions inside), « Proposer une modification »
|
||||
// opens the panel, where one writes (lot U, UX-lecteur.md). A seed proposal (ADR 0011),
|
||||
// read at the head while nothing is in force, is marked so: never the bare body of a text
|
||||
// in force.
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Shown } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { needOf } from '$lib/labels';
|
||||
import type { Proposal } from '$lib/types';
|
||||
import Prose from './Prose.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
text,
|
||||
mark = 'unchanged',
|
||||
seedMinimal = false,
|
||||
seed = null,
|
||||
enteredAt = null,
|
||||
rivals = 0,
|
||||
selected = false,
|
||||
@@ -15,17 +24,17 @@
|
||||
voteFor = null,
|
||||
voteOpen = false,
|
||||
votes = 0,
|
||||
module = false,
|
||||
proposeLabel,
|
||||
meta,
|
||||
content,
|
||||
fold,
|
||||
onaction,
|
||||
}: {
|
||||
text: Shown;
|
||||
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
|
||||
seedMinimal?: boolean;
|
||||
/** A seed proposal at the head (ADR 0011): how far it is from passing, not in force. */
|
||||
seed?: Proposal | null;
|
||||
enteredAt?: string | null;
|
||||
/** Its other versions open to the vote: said in its state, read in « Le vote ». */
|
||||
rivals?: number;
|
||||
selected?: boolean;
|
||||
actions?: boolean;
|
||||
@@ -34,51 +43,45 @@
|
||||
voteOpen?: boolean;
|
||||
/** The votes on the version in force, shown next to the seed mark. */
|
||||
votes?: number;
|
||||
/** A module (ADR 0009): quieter than an article, its own words to propose. */
|
||||
module?: boolean;
|
||||
proposeLabel?: string;
|
||||
/** What the header adds after the title (a count, a level). */
|
||||
meta?: Snippet;
|
||||
/** In place of the text, for a module. */
|
||||
content?: Snippet;
|
||||
/** The vote, unfolded under the text. */
|
||||
fold?: Snippet;
|
||||
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
|
||||
onaction: (action: 'vote' | 'propose') => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- A module is not an article of the text: a group named by its title. -->
|
||||
<svelte:element
|
||||
this={module ? 'div' : 'article'}
|
||||
<article
|
||||
class="unit {mark}"
|
||||
class:module
|
||||
class:selected
|
||||
class:seed={!!seed}
|
||||
class:unfolded={voteOpen}
|
||||
role={module ? 'group' : undefined}
|
||||
aria-label={module ? text.title : undefined}
|
||||
>
|
||||
<header>
|
||||
{#if seed}<WaterLevel tally={seed.tally} evaluation={seed.evaluation} blocked={seed.blocked} />{/if}
|
||||
<h2 class="eyebrow">{text.title}</h2>
|
||||
{@render meta?.()}
|
||||
{#if seed}<span class="tag water" title={t('doc.seedProposal.long')}>{t('doc.seedProposal')}</span>{/if}
|
||||
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
|
||||
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
|
||||
</header>
|
||||
{#if content}{@render content()}{:else}<Prose text={text.body} />{/if}
|
||||
<Prose text={text.body} />
|
||||
{#if actions}
|
||||
<footer class="actions">
|
||||
<span class="status faint">
|
||||
{#if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{#if seed}
|
||||
{@const need = needOf(seed)}
|
||||
{t('tally.short', { for: seed.tally.for, against: seed.tally.against })}{#if need} · <span title={need.title}>{need.short}</span>{/if}
|
||||
{:else if seedMinimal}<span title={t('doc.seed.long')}>{i18n.plural('doc.seed.votes', votes)}</span>
|
||||
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
|
||||
{#if rivals > 0} · {i18n.plural('doc.others', rivals)}{/if}
|
||||
</span>
|
||||
<span class="links">
|
||||
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
|
||||
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button>
|
||||
<button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
{#if voteOpen && fold}{@render fold()}{/if}
|
||||
</svelte:element>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.unit {
|
||||
@@ -98,18 +101,12 @@
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* A module closes its section: set apart, quieter than the articles. */
|
||||
.unit.module {
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-block: 0.85rem 0.6rem;
|
||||
background: var(--water-wash);
|
||||
/* A seed proposal: the water of a vote under way, never the bare page of a text in force. */
|
||||
.unit.seed {
|
||||
background: color-mix(in srgb, var(--water-wash) 55%, transparent);
|
||||
}
|
||||
.unit.module:hover,
|
||||
.unit.module:focus-within,
|
||||
.unit.module.selected,
|
||||
.unit.module.unfolded {
|
||||
background: var(--surface);
|
||||
.unit.seed :global(.prose) {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
@@ -168,4 +165,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.links button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.unit,
|
||||
.links {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,22 +5,26 @@
|
||||
// never who voted what —, how far the threshold is, your vote and where it
|
||||
// stands, then the inertia and the formula. A version held back by an
|
||||
// objection says so, and tells each one what is theirs to do (ADR 0010); it
|
||||
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The
|
||||
// rest of a version (typos, translations, objections, history) lives in the
|
||||
// panel, tab « Détails ».
|
||||
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). Lot U: how
|
||||
// many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
|
||||
// its other versions — each opens its own vote where it reads —, and the quorum its
|
||||
// author may set before the first vote. The rest of a version (typos, translations,
|
||||
// objections, history) lives in the panel, « Détails ».
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { DocState } from '$lib/doc.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
||||
import { needOf, orderNote, titleOf } from '$lib/labels';
|
||||
import { holding } from '$lib/objections';
|
||||
import { moved } from '$lib/order';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toasts } from '$lib/toast.svelte';
|
||||
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
|
||||
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
|
||||
import Crans from './Crans.svelte';
|
||||
import Formula from './Formula.svelte';
|
||||
import InertiaPicker from './InertiaPicker.svelte';
|
||||
import QuorumSetting from './QuorumSetting.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
@@ -36,7 +40,7 @@
|
||||
doc: DocState;
|
||||
proposal: Proposal;
|
||||
unit: Unit;
|
||||
/** Unfolds the vote of another version (a change of inertia). */
|
||||
/** Unfolds the vote of another version, where it reads (another text, a change of inertia). */
|
||||
onopen: (proposalId: string) => void;
|
||||
onsettings: (proposalId: string) => void;
|
||||
ondetails: (proposalId: string) => void;
|
||||
@@ -72,6 +76,26 @@
|
||||
const shifted = $derived(order && p.status !== 'in_force' ? moved(unit.members ?? [], order.ids) : new Set<string>());
|
||||
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
||||
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source);
|
||||
/** « 9 oui pour passer · 4 non pour bloquer » — the quorum in its tooltip. */
|
||||
const need = $derived(needOf(p));
|
||||
/** The other live versions of the module (its changes of inertia live with its inertia). */
|
||||
const others = $derived(
|
||||
[unit.inForce, ...unit.proposals.filter((x) => x.status === 'open' && x.kind !== 'inertia')].filter(
|
||||
(x): x is Proposal => !!x && x.id !== p.id,
|
||||
),
|
||||
);
|
||||
|
||||
/** What tells another version apart, in a few words. */
|
||||
function otherLabel(v: Proposal): string {
|
||||
if (v.kind === 'order') {
|
||||
if (v.status === 'in_force') return t('module.order.inforce');
|
||||
return v.author?.name ? t('panel.by', { name: v.author.name }) : t('module.order.proposal');
|
||||
}
|
||||
if (v.kind === 'remove') return titleOf(v, unit);
|
||||
if (v.label) return `${v.title} · ${t('deep.variant', { label: v.label })}`;
|
||||
if (v.status !== 'in_force' && v.author?.name) return `${v.title} · ${t('panel.by', { name: v.author.name })}`;
|
||||
return v.title;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const id = p.id;
|
||||
@@ -119,8 +143,9 @@
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await voteDirect(doc, lines);
|
||||
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
|
||||
const r = await voteDirect(doc, lines);
|
||||
// A simulated vote says so, never « enregistré » (ADR 0012).
|
||||
toasts.show(t(!choice ? 'vote.retracted' : r.simulated ? 'vote.saved.simulated' : 'vote.saved'));
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
} finally {
|
||||
@@ -135,10 +160,10 @@
|
||||
<div class="numbers">
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
|
||||
{#if p.evaluation}
|
||||
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
|
||||
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
|
||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||
{/if}
|
||||
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
|
||||
<p class="faint small"><Crans level={p.votedAtInertia} /></p>
|
||||
</div>
|
||||
</div>
|
||||
{#if p.blocked}
|
||||
@@ -223,7 +248,31 @@
|
||||
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="more">
|
||||
{#if others.length}
|
||||
<section class="others" aria-labelledby="others-{p.id}">
|
||||
<h4 id="others-{p.id}">{t('vote.others')}</h4>
|
||||
<ul>
|
||||
{#each others as v (v.id)}
|
||||
<li>
|
||||
<button type="button" class="other" onclick={() => onopen(v.id)}>
|
||||
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
|
||||
<span class="about">
|
||||
<span class="label">{otherLabel(v)}</span>
|
||||
<span class="faint small">
|
||||
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
|
||||
{#if v.status === 'in_force'} · {t('basket.inforce')}{/if}
|
||||
{#if v.seedProposal} · {t('doc.seedProposal')}{/if}
|
||||
{#if v.blocked} · {t('deep.held')}{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
|
||||
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if}
|
||||
{#if detail && view}
|
||||
<Formula
|
||||
electorate={detail.electorate}
|
||||
@@ -269,6 +318,54 @@
|
||||
.state {
|
||||
font-weight: 700;
|
||||
}
|
||||
.need {
|
||||
font-weight: 750;
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--ink-faint) 60%, transparent);
|
||||
text-underline-offset: 4px;
|
||||
cursor: help;
|
||||
}
|
||||
.others {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.others h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.others ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.other {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.other:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.about {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
font-weight: 750;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.order {
|
||||
margin: 0;
|
||||
padding-left: 1.6rem;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
evaluation,
|
||||
blocked = false,
|
||||
size = 'row',
|
||||
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'row' | 'large' } = $props();
|
||||
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'mini' | 'row' | 'large' } = $props();
|
||||
|
||||
const ratio = $derived(
|
||||
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0,
|
||||
@@ -40,6 +40,12 @@
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A section folded: one small bar per article, where the support rises. */
|
||||
.level.mini {
|
||||
width: 5px;
|
||||
height: 1.1rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.level.large {
|
||||
width: 14px;
|
||||
height: 5.5rem;
|
||||
|
||||
+51
-17
@@ -8,8 +8,13 @@ import type { GroupView } from './group';
|
||||
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
|
||||
|
||||
export type View = 'inforce' | 'wellrated' | 'all';
|
||||
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
|
||||
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
|
||||
/**
|
||||
* The panel, one thing at a time (lot U, UX-lecteur.md): the basket of the vote, a new
|
||||
* proposal, the details of a version, the settings of the formula. What is read and decided
|
||||
* on one version — its vote, its other versions, its inertia, the order of its section —
|
||||
* unfolds in place.
|
||||
*/
|
||||
export type Tab = 'basket' | 'propose' | 'details' | 'settings';
|
||||
|
||||
export interface PanelState {
|
||||
unitId: string | null;
|
||||
@@ -25,9 +30,9 @@ export interface PanelState {
|
||||
edit?: boolean;
|
||||
}
|
||||
|
||||
/** The tabs as the URL names them. */
|
||||
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
/** The panel as the URL names it (`?onglet=`); an old `onglet=propositions` unfolds the vote instead. */
|
||||
export const TABS: Record<string, Tab> = { panier: 'basket', proposer: 'propose', details: 'details', reglages: 'settings' };
|
||||
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', propose: 'proposer', details: 'details', settings: 'reglages' };
|
||||
|
||||
/** The query of a reader's URL: the unfolded vote and the panel. */
|
||||
export function panelSearch(panel: PanelState | null, vote: string | null): string {
|
||||
@@ -66,6 +71,20 @@ export function shown(
|
||||
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
|
||||
}
|
||||
|
||||
/** A version as the text reads it: in force, or a seed proposal at the head (`seed`). */
|
||||
export interface Entry {
|
||||
unit: Unit;
|
||||
proposal: Proposal;
|
||||
seed: boolean;
|
||||
}
|
||||
|
||||
export interface TextSection {
|
||||
section: string | null;
|
||||
entries: Entry[];
|
||||
/** Its order, when it has something to decide (two articles or more). */
|
||||
order: Unit | undefined;
|
||||
}
|
||||
|
||||
export class DocState {
|
||||
view = $state<DocView | null>(null);
|
||||
error = $state<string | null>(null);
|
||||
@@ -155,20 +174,35 @@ export class DocState {
|
||||
}
|
||||
|
||||
/**
|
||||
* The text in force by section, each closed by its order (ADR 0009) — shown even
|
||||
* where no article is in force yet, as long as it has something to decide.
|
||||
* The seed proposals (ADR 0011), read at the head of the text while no article is in
|
||||
* force: one knows where to begin. Never once an article is in force.
|
||||
*/
|
||||
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] {
|
||||
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = [];
|
||||
for (const u of this.view?.units ?? []) {
|
||||
let last = out.at(-1);
|
||||
if (!last || last.section !== u.section) {
|
||||
last = { section: u.section, units: [], order: this.orderOf(u.section) };
|
||||
out.push(last);
|
||||
}
|
||||
if (u.inForce) last.units.push(u);
|
||||
get seedHead(): Entry[] {
|
||||
if (this.inForce.length) return [];
|
||||
const out: Entry[] = [];
|
||||
for (const unit of this.view?.units ?? []) {
|
||||
const proposal = unit.proposals.find((p) => p.status === 'open' && p.seedProposal);
|
||||
if (proposal) out.push({ unit, proposal, seed: true });
|
||||
}
|
||||
return out.filter((s) => s.units.length || s.order);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text as it reads, by section: the articles in force — or, while none is, the seed
|
||||
* proposals —, each section closed by its order (ADR 0009) when it has something to decide.
|
||||
* A section with nothing to read here has its order at its end among the proposals.
|
||||
*/
|
||||
get textSections(): TextSection[] {
|
||||
const entries: Entry[] = this.inForce.length
|
||||
? this.inForce.flatMap((unit) => (unit.inForce ? [{ unit, proposal: unit.inForce, seed: false }] : []))
|
||||
: this.seedHead;
|
||||
const out: TextSection[] = [];
|
||||
for (const e of entries) {
|
||||
const last = out.at(-1);
|
||||
if (last && last.section === e.unit.section) last.entries.push(e);
|
||||
else out.push({ section: e.unit.section, entries: [e], order: this.orderOf(e.unit.section) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Open proposals, of articles and modules, in document order; drafts apart. */
|
||||
|
||||
@@ -78,3 +78,36 @@ export function articles(items: { unit: Unit; proposal: Proposal }[], filter: Fi
|
||||
return { unit, versions: ranked, leads: !!best && !!next && compare(best, next) < 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** A section of « Propositions en cours » (lot U): its articles, and its order when it closes here. */
|
||||
export interface DeepSection {
|
||||
key: string | null;
|
||||
articles: Article[];
|
||||
/** The order of the section, when the section is not read in the text above. */
|
||||
order?: Unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open proposals by section, in the order of the document (`keys`, the sections as the
|
||||
* document lists them): each section holds its articles, then its order when the order
|
||||
* closes it here — a section read in the text above has its order there. A section with
|
||||
* only its order to show is kept.
|
||||
*/
|
||||
export function deepSections(articles: Article[], orders: Unit[], keys: (string | null)[]): DeepSection[] {
|
||||
const byKey = new Map<string | null, DeepSection>();
|
||||
for (const a of articles) {
|
||||
const s = byKey.get(a.unit.section) ?? { key: a.unit.section, articles: [] };
|
||||
s.articles.push(a);
|
||||
byKey.set(a.unit.section, s);
|
||||
}
|
||||
for (const order of orders) {
|
||||
const s = byKey.get(order.section) ?? { key: order.section, articles: [] };
|
||||
s.order = order;
|
||||
byKey.set(order.section, s);
|
||||
}
|
||||
const rank = (k: string | null) => {
|
||||
const i = keys.indexOf(k);
|
||||
return i < 0 ? keys.length : i;
|
||||
};
|
||||
return [...byKey.values()].sort((a, b) => rank(a.key) - rank(b.key));
|
||||
}
|
||||
|
||||
+8
-5
@@ -39,12 +39,15 @@ export function announce(doc: DocState, entered: Entered[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/** In a managed group: every line in one write on the server. Returns how many were written. */
|
||||
export async function voteDirect(doc: DocState, lines: Line[]): Promise<number> {
|
||||
/**
|
||||
* Every line in one write on the server: a managed group, or the simulation while the chain
|
||||
* is closed (ADR 0012). Returns how many were written, and whether they were simulated.
|
||||
*/
|
||||
export async function voteDirect(doc: DocState, lines: Line[]): Promise<{ count: number; simulated: boolean }> {
|
||||
const id = doc.view?.doc.id;
|
||||
if (!id) return 0;
|
||||
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
|
||||
if (!id) return { count: 0, simulated: false };
|
||||
const r = await api.post<{ count: number; entered: Entered[]; simulated?: boolean }>(`/docs/${id}/votes`, { lines });
|
||||
await doc.reload();
|
||||
announce(doc, r.entered);
|
||||
return r.count;
|
||||
return { count: r.count, simulated: !!r.simulated };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script lang="ts">
|
||||
// The reader of a document (lot U, docs/chantiers/acte-certification/UX-lecteur.md). In
|
||||
// place, what is read and decided on what one has before one's eyes: the vote of a version
|
||||
// and its other versions, its inertia, the order of a section (handles on its articles).
|
||||
// On the right, what is written, or works over several modules: proposing, the basket,
|
||||
// the details of a version, the settings of the formula. Short at a glance: the text, each
|
||||
// section closed by one line of order; the open proposals folded by section.
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ChainSign from '$lib/components/ChainSign.svelte';
|
||||
@@ -6,20 +12,22 @@
|
||||
import DocPanel from '$lib/components/DocPanel.svelte';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import OrderModule from '$lib/components/OrderModule.svelte';
|
||||
import OrderLine from '$lib/components/OrderLine.svelte';
|
||||
import Reorder from '$lib/components/Reorder.svelte';
|
||||
import TitleBar from '$lib/components/TitleBar.svelte';
|
||||
import UnitBlock from '$lib/components/UnitBlock.svelte';
|
||||
import VoteFold from '$lib/components/VoteFold.svelte';
|
||||
import WaveRule from '$lib/components/WaveRule.svelte';
|
||||
import { sections, votableIds } from '$lib/basket';
|
||||
import { DocState, panelSearch, shown, TABS, type PanelState, type View } from '$lib/doc.svelte';
|
||||
import { DocState, panelSearch, shown, TABS, type Entry, type PanelState, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import { sectionLabel } from '$lib/labels';
|
||||
import { answerPanel } from '$lib/objections';
|
||||
import { BasketState } from '$lib/picks.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import type { Line } from '$lib/vote';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
import { articles, deepSections, type Filter } from '$lib/versions';
|
||||
|
||||
const doc = new DocState();
|
||||
const basket = new BasketState();
|
||||
@@ -53,10 +61,20 @@
|
||||
edit: q.get('modifier') === '1',
|
||||
};
|
||||
});
|
||||
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
|
||||
/**
|
||||
* The version whose vote is unfolded where it reads (`?vote=`; the old `onglet=vote` too,
|
||||
* and the old `onglet=propositions`, whose versions now live inside the vote).
|
||||
*/
|
||||
const vote = $derived.by(() => {
|
||||
const q = page.url.searchParams;
|
||||
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
|
||||
const onglet = q.get('onglet');
|
||||
if (q.get('vote')) return q.get('vote');
|
||||
if (onglet === 'vote') return q.get('version');
|
||||
if (onglet === 'propositions') {
|
||||
const u = doc.unit(q.get('article'));
|
||||
return q.get('version') ?? u?.inForce?.id ?? u?.proposals.find((p) => p.status === 'open')?.id ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function go(nextPanel: PanelState | null, nextVote: string | null) {
|
||||
@@ -73,6 +91,22 @@
|
||||
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
|
||||
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
|
||||
|
||||
// Reordering a section: handles on its articles, in place (one at a time).
|
||||
let reordering = $state<string | null>(null);
|
||||
function startReorder(order: Unit) {
|
||||
reordering = order.id;
|
||||
// Its vote folds: the line now holds the articles with their handles.
|
||||
if ([order.inForce, ...order.proposals].some((p) => p?.id === vote)) go(panel, null);
|
||||
}
|
||||
// An old link « proposer un autre ordre » (onglet=proposer on an order) reorders in place.
|
||||
$effect(() => {
|
||||
if (panel?.tab !== 'propose') return;
|
||||
const u = doc.unit(panel.unitId);
|
||||
if (u?.kind !== 'order') return;
|
||||
reordering = u.id;
|
||||
openPanel(null);
|
||||
});
|
||||
|
||||
// On the Ğ1 chain: one signing at a time, over the page.
|
||||
let signing = $state<Line[] | null>(null);
|
||||
const sign = (lines: Line[]) => (signing = lines);
|
||||
@@ -92,34 +126,42 @@
|
||||
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
||||
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
||||
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
|
||||
// One line per article; an article stays when one of its versions passes the filter.
|
||||
const deep = $derived(articles(doc.open, filter));
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const nameOf = (id: string) => doc.articleTitle(id, lang, source);
|
||||
// The title of the document, a module (ADR 0009): its gestures under the title.
|
||||
// The title of the document, a module (ADR 0009): its line under the title.
|
||||
const titleUnit = $derived(view?.modules.title ?? null);
|
||||
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
|
||||
if (action === 'vote') {
|
||||
if (version) toggleVote(version);
|
||||
return;
|
||||
}
|
||||
openPanel({ unitId: u.id, proposalId: null, tab: action });
|
||||
}
|
||||
// The text shows the vote of its own versions; the list below, that of the others.
|
||||
// The modules in force are read in place too: the title at the head, each order at the end of its section.
|
||||
const reading = $derived(doc.mode === 'inforce' || !doc.preview);
|
||||
/** The text as it reads, by section, each closed by its order. */
|
||||
const textSections = $derived(reading ? doc.textSections : []);
|
||||
/** The versions the text shows: their vote unfolds there, never in the list below. */
|
||||
const textIds = $derived(
|
||||
new Set([
|
||||
titleUnit?.inForce?.id,
|
||||
...(doc.mode === 'inforce' || !doc.preview
|
||||
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id)
|
||||
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id)),
|
||||
]),
|
||||
new Set(
|
||||
reading
|
||||
? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
|
||||
: (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
|
||||
),
|
||||
);
|
||||
const inText = $derived(!!vote && textIds.has(vote));
|
||||
// « Propositions en cours »: the articles (the modules are voted at their place), the seed
|
||||
// proposals of the head apart; by section, each closed by its order when the text does not.
|
||||
const deep = $derived(articles(doc.open.filter((x) => !x.unit.kind && !textIds.has(x.proposal.id)), filter));
|
||||
const textKeys = $derived(new Set(textSections.map((s) => s.section)));
|
||||
const deepOrders = $derived(
|
||||
filter === 'all' ? (view?.modules.orders ?? []).filter((o) => !o.moot && !textKeys.has(o.section)) : [],
|
||||
);
|
||||
const sectionKeys = $derived([...new Set((view?.units ?? []).map((u) => u.section))]);
|
||||
const deepSecs = $derived(deepSections(deep, deepOrders, sectionKeys));
|
||||
const canVote = $derived(!!view?.vote.open);
|
||||
|
||||
/** The other versions of an article open to the vote, besides the one read. */
|
||||
const othersOf = (e: Entry) => e.unit.proposals.filter((p) => p.status === 'open' && p.kind !== 'inertia' && p.id !== e.proposal.id).length;
|
||||
|
||||
function onEntry(e: Entry, action: 'vote' | 'propose') {
|
||||
if (action === 'vote') toggleVote(e.proposal.id);
|
||||
// From a seed proposal (nothing in force): its text to start from.
|
||||
else openPanel({ unitId: e.unit.id, proposalId: null, tab: 'propose', prefill: e.seed ? { title: e.proposal.title, body: e.proposal.body } : null });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet foldOf(unit: Unit, p: Proposal)}
|
||||
@@ -135,6 +177,28 @@
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet moduleFold(p: Proposal)}
|
||||
{@const u = doc.unit(p.unitId)}
|
||||
{#if u}{@render foldOf(u, p)}{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet reorderOf(o: Unit)}
|
||||
<Reorder
|
||||
{doc}
|
||||
unit={o}
|
||||
{nameOf}
|
||||
oncancel={() => (reordering = null)}
|
||||
ondone={(id) => {
|
||||
reordering = null;
|
||||
go(panel, id);
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet orderLine(o: Unit)}
|
||||
<OrderLine unit={o} {vote} reordering={reordering === o.id} fold={moduleFold} reorder={reorderOf} onvote={(id) => toggleVote(id)} onreorder={() => startReorder(o)} />
|
||||
{/snippet}
|
||||
|
||||
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
|
||||
|
||||
{#if doc.error}
|
||||
@@ -146,17 +210,16 @@
|
||||
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
|
||||
</p>
|
||||
<h1>{title}</h1>
|
||||
{#if titleUnit?.inForce}
|
||||
{@const tp = titleUnit.inForce}
|
||||
{#if titleUnit}
|
||||
<TitleBar
|
||||
unit={titleUnit}
|
||||
stale={titleStale}
|
||||
selected={panel?.unitId === titleUnit.id}
|
||||
voteOpen={inText && vote === tp.id}
|
||||
onaction={(action) => onUnit(titleUnit, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet}
|
||||
</TitleBar>
|
||||
{vote}
|
||||
fold={moduleFold}
|
||||
onvote={(id) => toggleVote(id)}
|
||||
onpropose={() => openPanel({ unitId: titleUnit.id, proposalId: null, tab: 'propose' })}
|
||||
/>
|
||||
{/if}
|
||||
<p class="summary">{summary}</p>
|
||||
<p class="facts faint">
|
||||
@@ -170,10 +233,11 @@
|
||||
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
|
||||
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
|
||||
</button>
|
||||
{#if view.vote.simulated}<span class="tag" title={t('vote.simulated.note')}>{t('vote.simulated.mark')}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="controls">
|
||||
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
|
||||
<div class="seg views" role="radiogroup" aria-label={t('doc.view.label')}>
|
||||
{#each VIEWS as v (v)}
|
||||
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
|
||||
{/each}
|
||||
@@ -191,44 +255,37 @@
|
||||
</header>
|
||||
|
||||
<section class="text" aria-label={t('doc.view.inforce')}>
|
||||
{#if doc.mode === 'inforce' || !doc.preview}
|
||||
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. -->
|
||||
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if}
|
||||
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)}
|
||||
{#each s.units as u (u.id)}
|
||||
{#if u.inForce}
|
||||
{@const p = u.inForce}
|
||||
<UnitBlock
|
||||
text={shown(p, lang, source)}
|
||||
seedMinimal={p.seedMinimal}
|
||||
enteredAt={p.enteredAt}
|
||||
votes={p.tally.for + p.tally.against}
|
||||
rivals={rivalsOf(u)}
|
||||
selected={panel?.unitId === u.id}
|
||||
voteFor={p.id}
|
||||
voteOpen={inText && vote === p.id}
|
||||
onaction={(action) => onUnit(u, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{#if reading}
|
||||
<!-- Nothing in force (ADR 0011): the seed proposals at the head, said once. -->
|
||||
{#if !doc.inForce.length}<p class="lead">{t(textSections.length ? 'doc.empty.seeds' : 'doc.empty')}</p>{/if}
|
||||
{#each textSections as s, i (`${i}:${s.section}`)}
|
||||
{@const label = sectionLabel(s.section)}
|
||||
<div class="text-section">
|
||||
{#if label}<p class="section-name">{label}</p>{/if}
|
||||
{#if !s.order || reordering !== s.order.id}
|
||||
{#each s.entries as e (e.proposal.id)}
|
||||
{@const p = e.proposal}
|
||||
<UnitBlock
|
||||
text={shown(p, lang, source)}
|
||||
seed={e.seed ? p : null}
|
||||
seedMinimal={!e.seed && p.seedMinimal}
|
||||
enteredAt={e.seed ? null : p.enteredAt}
|
||||
votes={p.tally.for + p.tally.against}
|
||||
rivals={othersOf(e)}
|
||||
selected={panel?.unitId === e.unit.id}
|
||||
voteFor={p.id}
|
||||
voteOpen={vote === p.id}
|
||||
onaction={(action) => onEntry(e, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(e.unit, p)}{/snippet}
|
||||
</UnitBlock>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if s.order?.inForce}
|
||||
{@const o = s.order}
|
||||
{@const op = s.order.inForce}
|
||||
<OrderModule
|
||||
unit={o}
|
||||
{nameOf}
|
||||
selected={panel?.unitId === o.id}
|
||||
voteOpen={inText && vote === op.id}
|
||||
onaction={(action) => onUnit(o, action)}
|
||||
>
|
||||
{#snippet fold()}{@render foldOf(o, op)}{/snippet}
|
||||
</OrderModule>
|
||||
{/if}
|
||||
{#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each doc.preview as pu (pu.unitId)}
|
||||
{#each doc.preview ?? [] as pu (pu.unitId)}
|
||||
{@const u = doc.unit(pu.unitId)}
|
||||
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
|
||||
{@const p = doc.proposal(id)}
|
||||
@@ -237,13 +294,17 @@
|
||||
mark={pu.mark}
|
||||
selected={panel?.unitId === pu.unitId}
|
||||
voteFor={id}
|
||||
voteOpen={inText && !!id && vote === id}
|
||||
onaction={(action) => u && onUnit(u, action, id)}
|
||||
voteOpen={!!id && vote === id}
|
||||
onaction={(action) => {
|
||||
if (action === 'vote') {
|
||||
if (id) toggleVote(id);
|
||||
} else if (u) openPanel({ unitId: u.id, proposalId: null, tab: 'propose' });
|
||||
}}
|
||||
>
|
||||
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
|
||||
</UnitBlock>
|
||||
{/each}
|
||||
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
||||
{#if (doc.preview ?? []).every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -264,7 +325,7 @@
|
||||
|
||||
<div class="deep-head">
|
||||
<h2 id="deep-title">{t('deep.title')}</h2>
|
||||
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</p>
|
||||
<p class="muted">{t('deep.note')}</p>
|
||||
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
|
||||
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
|
||||
<button type="button" role="radio" aria-checked={filter === f} class:on={filter === f} onclick={() => (filter = f)}>{t(`deep.filter.${f}`)}</button>
|
||||
@@ -272,11 +333,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<DeepList
|
||||
articles={deep}
|
||||
vote={inText ? null : vote}
|
||||
sections={deepSecs}
|
||||
vote={textIds.has(vote ?? '') ? null : vote}
|
||||
{reordering}
|
||||
all={filter !== 'all'}
|
||||
ontoggle={(_, p) => toggleVote(p.id)}
|
||||
fold={foldOf}
|
||||
{nameOf}
|
||||
order={orderLine}
|
||||
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
|
||||
/>
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
@@ -284,7 +347,7 @@
|
||||
</div>
|
||||
|
||||
{#if panel}
|
||||
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
|
||||
<DocPanel {doc} {panel} {lang} {basket} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
|
||||
{/if}
|
||||
{#if signing && view.vote.chain}
|
||||
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
|
||||
@@ -344,10 +407,14 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 1.6rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
.basket-row {
|
||||
margin-top: 1.4rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
.basket-row + .controls {
|
||||
margin-top: 0.8rem;
|
||||
@@ -372,6 +439,7 @@
|
||||
font-weight: 750;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.seg.small button {
|
||||
padding-inline: 0.65rem;
|
||||
@@ -402,6 +470,22 @@
|
||||
.text {
|
||||
margin-top: 2.2rem;
|
||||
}
|
||||
.lead {
|
||||
margin-bottom: 1.4rem;
|
||||
font-weight: 650;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.text-section + .text-section {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
/* The name of a section: where one is, without a number — the italic of the text, softly. */
|
||||
.section-name {
|
||||
margin: 0 0 0.3rem;
|
||||
font-family: var(--font-text);
|
||||
font-style: italic;
|
||||
font-size: 1.12rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.deep {
|
||||
padding-top: 2rem;
|
||||
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
|
||||
@@ -412,7 +496,7 @@
|
||||
.deep-head {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.deep-head h2 {
|
||||
font-family: var(--font-text);
|
||||
@@ -437,4 +521,21 @@
|
||||
.new {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
/* On a phone: the three readings on one line that scrolls, the targets big enough for a finger. */
|
||||
@media (max-width: 899px) {
|
||||
.seg.views {
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.seg button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reader {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user