Lot U : le lecteur refait — l'ordre en une ligne au bout de sa section, poignées sur place (Reorder), le titre et l'ordre votés à leur place ; Propositions seed en tête quand rien n'est en vigueur ; Propositions en cours repliées par section ; panneau sans onglets ; oui pour passer · non pour bloquer ; quorum de l'auteur ; votes simulés dits simulés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 07:59:24 +01:00
co-authored by Claude Opus 5.5
parent 70f7bb7247
commit 4cf4e47ee9
17 changed files with 1198 additions and 541 deletions
+5 -9
View File
@@ -90,9 +90,10 @@
} }
busy = true; busy = true;
try { try {
const n = await voteDirect(doc, toSend); const r = await voteDirect(doc, toSend);
basket.clear(); 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) { } catch (e) {
error = errorText(e); error = errorText(e);
} finally { } finally {
@@ -115,8 +116,8 @@
{/if} {/if}
{/snippet} {/snippet}
<section class="basket" aria-labelledby="basket-title"> <!-- Named by the head of the panel (« Panier de vote »): one name, one place. -->
<h3 id="basket-title">{t('basket.title')}</h3> <section class="basket">
<p class="muted">{t('basket.intro')}</p> <p class="muted">{t('basket.intro')}</p>
{#if access && !access.open} {#if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p> <p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
@@ -216,11 +217,6 @@
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
} }
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.4rem;
}
.closed { .closed {
font-weight: 700; font-weight: 700;
color: var(--ink-soft); color: var(--ink-soft);
+265 -129
View File
@@ -1,52 +1,67 @@
<script lang="ts"> <script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a // The open proposals, by section (lot U, UX-lecteur.md: « une page courte, lisible d'un
// single version unfolds its vote below it; one with several shows the best of // coup d'œil »): each section folded to one line — its name, how many articles are
// them and unfolds the others, each with its label, its water level and its // proposed, a small water level for each, so one sees where support rises without opening
// marks — and each unfolds its own vote. // 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 type { Snippet } from 'svelte';
import { plain } from '$lib/markdown'; 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 { i18n, t } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types'; 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 Icon from './Icon.svelte';
import WaterLevel from './WaterLevel.svelte'; import WaterLevel from './WaterLevel.svelte';
let { let {
articles, sections,
vote, vote,
reordering = null,
all = false,
ontoggle, ontoggle,
fold, fold,
order,
onsection, onsection,
nameOf = (id) => id,
}: { }: {
/** Articles and modules (ADR 0009) with open proposals, in document order. */ /** The articles with open proposals, by section, each with its order when it closes here. */
articles: Article[]; sections: DeepSection[];
/** The version whose vote is unfolded. */ /** The version whose vote is unfolded. */
vote: string | null; 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; ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>; 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. */ /** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: string) => void; onsection?: (section: string) => void;
/** The title of an article, for the orders proposed. */
nameOf?: (unitId: string) => string;
} = $props(); } = $props();
/** What a line shows of a version: the start of its text, or the order it proposes. */ const keyOf = (s: DeepSection) => s.key ?? '';
function excerptOf(p: Proposal): string { // Unfolded by hand, or because the vote unfolded — or the reordering — is inside.
if (p.kind === 'text') return plain(p.body); let unfolded = $state<Record<string, boolean>>({});
if (p.kind === 'order') return (p.order?.ids ?? []).map(nameOf).join(' · '); let versionsOpen = $state<Record<string, boolean>>({});
return ''; 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. const isVersionsOpen = (a: Article) => versionsOpen[a.unit.id] ?? a.versions.some((p) => p.id === vote);
let unfolded = $state<Record<string, boolean>>({}); const toggleVersions = (a: Article) => (versionsOpen[a.unit.id] = !isVersionsOpen(a));
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */ /** The title of the article: its best text version, or what the best proposal is. */
function headline(a: Article): string { function headline(a: Article): string {
if (a.unit.kind) return moduleName(a.unit);
const text = a.versions.find((p) => p.kind === 'text'); const text = a.versions.find((p) => p.kind === 'text');
const best = a.versions[0]; const best = a.versions[0];
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code; 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 textual = a.versions.filter((p) => p.kind === 'text');
const told = excerpts(textual.map((p) => plain(p.body))); const told = excerpts(textual.map((p) => plain(p.body)));
return new Map([ 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)]), ...textual.map((p, k): [string, string] => [p.id, told[k] ?? plain(p.body)]),
]); ]);
} }
</script> </script>
{#snippet module(p: Proposal)} {#snippet need(p: Proposal)}
{#if p.kind === 'title'}<span class="faint">{t('module.title.name')}</span>{/if} {@const n = needOf(p)}
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each} {#if n}<span class="need" title={n.title}>{n.short}</span>{/if}
{/snippet} {/snippet}
{#snippet marks(p: Proposal)} {#snippet marks(p: Proposal)}
@@ -85,97 +100,219 @@
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if} {#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
{/snippet} {/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"> <ul class="deep">
{#each articles as a, i (a.unit.id)} {#each sections as s, i (keyOf(s))}
{@const unit = a.unit} {@const open = isOpen(s)}
{@const best = a.versions[0]} {@const label = sectionLabel(s.key) ?? (s.key ? s.key : t('deep.section.none'))}
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section} <li class="section" class:open>
<li class="section"> <div class="head">
<span class="eyebrow">{sectionLabel(unit.section)}</span> <button type="button" class="toggle" aria-expanded={open} aria-controls="deep-sec-{i}" onclick={() => toggle(s)}>
{#if onsection && unit.section} <span class="chevron" aria-hidden="true"><Icon name="chevron" size={16} /></span>
{@const key = unit.section} <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> <button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if} {/if}
</li> </div>
{/if} {#if open}
{#if best && a.versions.length === 1} <div class="body" id="deep-sec-{i}">
<li class="article" class:open={vote === best.id}> {#if s.order?.id !== reordering}
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}> <ul class="rows">
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} /> {#each s.articles as a (a.unit.id)}
<span class="main"> {@const unit = a.unit}
<span class="title">{titleOf(best, unit)}</span> {@const best = a.versions[0]}
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if} {#if best && a.versions.length === 1}
<span class="meta"> <li class="article" class:open={vote === best.id}>
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if} <WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if} <span class="main">
{@render module(best)} <span class="title">{titleOf(best, unit)}</span>
{@render marks(best)} {#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
</span> <span class="meta">
</span> <span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
</button> {@render need(best)}
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if} {#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
</li> {#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
{:else if best} {@render marks(best)}
{@const open = isOpen(a)} </span>
{@const title = headline(a)} </span>
{@const told = toldApart(a)} </button>
<li class="article" class:open> {#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}> </li>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} /> {:else if best}
<span class="main"> {@const vopen = isVersionsOpen(a)}
<span class="title">{title}</span> {@const title = headline(a)}
{#if excerptOf(best)}<span class="excerpt">{excerptOf(best)}</span>{/if} {@const told = toldApart(a)}
<span class="meta"> <li class="article" class:open={vopen}>
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span> <button type="button" class="row" aria-expanded={vopen} aria-controls="versions-{unit.id}" onclick={() => toggleVersions(a)}>
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if} <WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <span class="main">
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if} <span class="title">{title}</span>
{#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if} {#if told.get(best.id)}<span class="excerpt">{told.get(best.id)}</span>{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if} <span class="meta">
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if} <span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if} {#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
</span> <span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
</span> {@render need(best)}
<span class="chevron"><Icon name="chevron" size={18} /></span> {#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
</button> {#if unit.inertia !== 3}<Crans level={unit.inertia} short />{/if}
<ul class="versions" id="versions-{unit.id}" hidden={!open}> {#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#each a.versions as p (p.id)} {#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
<li> {#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}> </span>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} /> </span>
<span class="main"> <span class="chevron"><Icon name="chevron" size={18} /></span>
<span class="label">{labelOf(p, a)}{#if (p.kind === 'text' || p.kind === 'title') && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span> </button>
{#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if} <ul class="versions" id="versions-{unit.id}" hidden={!vopen}>
<span class="meta"> {#each a.versions as p (p.id)}
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span> <li>
{#each orderNote(p) as note (note)}<span class="faint">{note}</span>{/each} <button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
{@render marks(p)} <WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
</span> <span class="main">
</span> <span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
</button> {#if told.get(p.id)}<span class="excerpt">{told.get(p.id)}</span>{/if}
{#if vote === p.id}{@render fold(unit, p)}{/if} <span class="meta">
</li> <span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{/each} {@render need(p)}
</ul> {@render marks(p)}
</li> </span>
{/if} </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} {:else}
<li class="faint none">{t('deep.none')}</li> <li class="faint none">{t('deep.none')}</li>
{/each} {/each}
</ul> </ul>
<style> <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; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
} }
.article { .section {
margin: 0 -1rem; margin: 0 -1rem;
border-radius: var(--r-card); 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; transition: background-color 0.15s ease, box-shadow 0.15s ease;
} }
.article.open { .article.open {
@@ -225,17 +362,17 @@
color: var(--ink-soft); color: var(--ink-soft);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.count { .need {
color: var(--water); color: var(--ink);
} }
.chevron { .row .chevron {
flex: none; flex: none;
margin-left: auto; margin-left: auto;
display: inline-flex; display: inline-flex;
color: var(--ink-faint); color: var(--ink-faint);
transition: transform 0.2s var(--ease); transition: transform 0.2s var(--ease);
} }
.open .chevron { .article.open .row .chevron {
transform: rotate(90deg); transform: rotate(90deg);
} }
.versions { .versions {
@@ -272,40 +409,39 @@
font-weight: 600; font-weight: 600;
color: var(--ink-soft); 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 { .fold {
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
} }
.section:first-child {
margin-top: 0.4rem;
}
.none { .none {
padding: 0.8rem 0; padding: 0.8rem 0;
} }
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (max-width: 560px) { @media (max-width: 560px) {
.versions { .versions {
padding-left: 0.6rem; 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> </style>
+38 -124
View File
@@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
// The side panel: the basket of the vote, the versions of an article, a new // The side panel, one thing at a time (lot U, UX-lecteur.md): what is written, or works
// proposal, the details of a version, the settings of the formula. The vote of // over several modules, or is read beside the text — the basket of the vote, a new
// one version unfolds under its article, not here. // 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 type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import { answerPanel } from '$lib/objections'; import { answerPanel } from '$lib/objections';
import type { BasketState } from '$lib/picks.svelte'; import type { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.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 type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte'; import BallotPanel from './BallotPanel.svelte';
import FormulaSettings from './FormulaSettings.svelte'; import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import OrderEditor from './OrderEditor.svelte';
import ProposeView from './ProposeView.svelte'; import ProposeView from './ProposeView.svelte';
import TitlePropose from './TitlePropose.svelte'; import TitlePropose from './TitlePropose.svelte';
import VersionDetails from './VersionDetails.svelte'; import VersionDetails from './VersionDetails.svelte';
import WaterLevel from './WaterLevel.svelte';
let { let {
doc, doc,
panel, panel,
lang, lang,
basket, basket,
vote,
onchange, onchange,
onclose, onclose,
onvote, onvote,
@@ -34,61 +32,41 @@
panel: PanelState; panel: PanelState;
lang: Lang; lang: Lang;
basket: BasketState; basket: BasketState;
/** The version whose vote is unfolded under its article. */
vote: string | null;
onchange: (p: PanelState) => void; onchange: (p: PanelState) => void;
onclose: () => 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; onvote: (proposalId: string, close: boolean) => void;
onsign: (lines: Line[]) => void; onsign: (lines: Line[]) => void;
} = $props(); } = $props();
const unit = $derived(doc.unit(panel.unitId)); 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 voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
const tabs = $derived( /** One name per function, the same one that opened it. */
( const heading = $derived(
[ panel.tab === 'basket'
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'], ? t('basket.title')
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit], : panel.tab === 'settings'
['propose', t('panel.propose'), true], ? t('settings.title')
// A version: reached from its vote, or from the list of versions. : panel.tab === 'details'
['details', t('panel.details'), !!panel.proposalId], ? t('panel.details')
// Reached from the head of the document or from a vote; shown while it is open. : unit?.kind === 'title'
['settings', t('panel.settings'), panel.tab === 'settings'], ? t('title.propose')
] as const : panel.unitId
).filter(([, , show]) => show), ? t('doc.action.propose')
: t('deep.new'),
); );
let body: HTMLElement | undefined = $state(); let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => { $effect(() => {
void panel.tab; void panel.tab;
void panel.proposalId; void panel.proposalId;
body?.scrollTo({ top: 0 }); 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> </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="bar">
<div class="tabs" role="tablist" bind:this={tablist}> <h2 id="panel-title">{heading}</h2>
{#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>
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button> <button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
</div> </div>
@@ -108,24 +86,6 @@
if (next) onchange(next); 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'} {:else if panel.tab === 'propose' && unit?.kind === 'title'}
<TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} /> <TitlePropose {doc} {unit} ondone={(id) => onvote(id, true)} />
{:else if panel.tab === 'propose'} {:else if panel.tab === 'propose'}
@@ -139,7 +99,9 @@
ondone={(r) => ondone={(r) =>
r.proposalId && r.submitted r.proposalId && r.submitted
? onvote(r.proposalId, true) ? 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'} {:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} /> <FormulaSettings {doc} proposalId={voteId} />
@@ -165,38 +127,20 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.7rem 0.7rem 0.5rem 0.9rem; padding: 0.9rem 0.7rem 0.4rem 1.2rem;
} }
.tabs { h2 {
display: flex; font-family: var(--font-text);
gap: 0.2rem; font-weight: 500;
background: var(--surface-2); font-size: 1.35rem;
padding: 3px; line-height: 1.2;
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);
} }
.close { .close {
display: grid; display: grid;
place-items: center; place-items: center;
flex: none; flex: none;
width: 2.25rem; width: 2.75rem;
height: 2.25rem; height: 2.75rem;
border: 0; border: 0;
border-radius: var(--r-icon); border-radius: var(--r-icon);
background: transparent; background: transparent;
@@ -211,41 +155,6 @@
padding: 0.6rem 1.2rem 1.6rem; padding: 0.6rem 1.2rem 1.6rem;
overscroll-behavior: contain; 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 { @keyframes enter {
from { from {
transform: translateX(16px); transform: translateX(16px);
@@ -270,4 +179,9 @@
} }
} }
} }
@media (prefers-reduced-motion: reduce) {
.panel {
animation: none;
}
}
</style> </style>
@@ -45,8 +45,8 @@
</script> </script>
{#if view && p} {#if view && p}
<section class="settings" aria-labelledby="settings-title"> <!-- Named by the head of the panel (« Réglages de la formule »): one name, one place. -->
<h3 id="settings-title">{t('settings.title')}</h3> <section class="settings">
<p class="muted">{t('settings.intro')}</p> <p class="muted">{t('settings.intro')}</p>
<ul class="values"> <ul class="values">
@@ -113,11 +113,6 @@
gap: 0.8rem; gap: 0.8rem;
min-width: 0; min-width: 0;
} }
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
h4 { h4 {
margin: 0; margin: 0;
font-size: 0.92rem; font-size: 0.92rem;
+148
View File
@@ -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>
-89
View File
@@ -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>&nbsp;{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>
+113
View File
@@ -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>
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
// Another order for a section, with a list one clicks (Yvv, 2026-10-30: « une // Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
// simple liste clipable »): an article, then its new place — or its arrows. With // "poignées" aux modules de la section. Et permette de voter. »): the articles of the
// the keyboard: Enter chooses an article, the up and down arrows move it. Then // section fold to their titles, a handle before each. One drags a handle (mouse, pen or
// « Proposer cet ordre »: the order goes to the vote, like an article (ADR 0009). // 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 { tick, untrack } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { api } from '$lib/api'; import { api } from '$lib/api';
@@ -27,13 +31,14 @@
unit: Unit; unit: Unit;
/** The title of an article, in the language it is read in. */ /** The title of an article, in the language it is read in. */
nameOf: (unitId: string) => string; 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; ondone: (proposalId: string) => void;
oncancel?: () => void; oncancel: () => void;
} = $props(); } = $props();
let list = $state<string[]>([]); let list = $state<string[]>([]);
let chosen = $state<string | null>(null); let chosen = $state<string | null>(null);
let dragging = $state<string | null>(null);
let said = $state(''); let said = $state('');
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
@@ -41,6 +46,12 @@
const start = $derived(unit.members ?? []); const start = $derived(unit.members ?? []);
const changed = $derived(moved(start, list)); const changed = $derived(moved(start, list));
const unchanged = $derived(sameOrder(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; 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. // 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() { async function submit() {
if (unchanged) { if (unchanged) {
error = t('order.edit.unchanged'); error = t('order.edit.unchanged');
return; return;
} }
if (existing) {
ondone(existing.id);
return;
}
busy = true; busy = true;
error = ''; error = '';
try { try {
@@ -119,15 +164,19 @@
} }
</script> </script>
<div class="editor"> <div class="reorder">
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p> <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}> <ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
{#each list as id, i (id)} {#each list as id, i (id)}
{@const name = nameOf(id)} {@const name = nameOf(id)}
<li class:chosen={chosen === id} class:moved={changed.has(id)} animate:flip={{ duration: reduce ? 0 : 160 }}> <li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}> <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="n" aria-hidden="true">{i + 1}</span>
<span class="name">{name}</span> <span class="name">{name}</span>
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
</button> </button>
<span class="arrows"> <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> <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} {/each}
</ol> </ol>
<p class="visually-hidden" aria-live="polite">{said}</p> <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('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('module.order.rule')}</p>
<p class="faint small">{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} {#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions"> <div class="actions">
{#if oncancel}<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>{/if} <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('order.edit.submit')}</button> <button class="btn primary small" type="button" disabled={busy || unchanged} onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button>
</div> </div>
</div> </div>
<style> <style>
.editor { .reorder {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;
min-width: 0; min-width: 0;
animation: unfold 0.2s var(--ease);
} }
.list { .list {
list-style: none; list-style: none;
@@ -162,26 +212,52 @@
.list li { .list li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.2rem;
border-radius: var(--r-input); border-radius: var(--r-input);
background: var(--surface-2); background: var(--surface);
box-shadow: var(--shadow);
transition: background-color 0.15s var(--ease); transition: background-color 0.15s var(--ease);
} }
.list li.moved { .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); background: var(--water);
color: var(--on-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 { .item {
flex: 1; flex: 1;
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.7rem; gap: 0.3rem 0.6rem;
min-width: 0; min-width: 0;
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.5rem 0.4rem 0.5rem 0.8rem; padding: 0.45rem 0.3rem;
border: 0; border: 0;
border-radius: var(--r-input); border-radius: var(--r-input);
background: transparent; background: transparent;
@@ -191,18 +267,25 @@
} }
.n { .n {
flex: none; flex: none;
min-width: 1.4rem; min-width: 1.2rem;
font-weight: 800; font-weight: 800;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--water); color: var(--water);
} }
.chosen .n { .chosen .n,
.dragging .n {
color: inherit; color: inherit;
} }
.name { .name {
font-weight: 700; font-family: var(--font-text);
font-weight: 600;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.chosen .tag,
.dragging .tag {
background: color-mix(in srgb, var(--on-water) 22%, transparent);
color: inherit;
}
.arrows { .arrows {
display: flex; display: flex;
flex: none; flex: none;
@@ -234,6 +317,11 @@
.small { .small {
font-size: 0.84rem; font-size: 0.84rem;
} }
.note {
font-size: 0.88rem;
font-weight: 700;
color: var(--water);
}
.warn { .warn {
color: var(--ochre); color: var(--ochre);
font-weight: 700; font-weight: 700;
@@ -245,7 +333,23 @@
gap: 0.5rem; gap: 0.5rem;
justify-content: flex-end; 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) { @media (prefers-reduced-motion: reduce) {
.reorder {
animation: none;
}
.list li { .list li {
transition: none; transition: none;
} }
+45 -16
View File
@@ -1,49 +1,60 @@
<script lang="ts"> <script lang="ts">
// The title of the document is a module (ADR 0009, inertia 4): under the title, // The title of the document is a module (ADR 0009, inertia 4): one line under the title,
// the gestures of an article — the vote, the proposals, another 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 type { Snippet } from 'svelte';
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, moduleName } from '$lib/labels'; import { moduleName } from '$lib/labels';
import type { Unit } from '$lib/types'; import type { Proposal, Unit } from '$lib/types';
import Crans from './Crans.svelte';
let { let {
unit, unit,
stale = false, stale = false,
selected = false, selected = false,
voteOpen = false, vote,
fold, fold,
onaction, onvote,
onpropose,
}: { }: {
/** The title module of the document. */ /** The title module of the document. */
unit: Unit; unit: Unit;
/** The title read is a translation of an older title. */ /** The title read is a translation of an older title. */
stale?: boolean; stale?: boolean;
selected?: boolean; selected?: boolean;
voteOpen?: boolean; /** The version whose vote is unfolded, wherever it lives. */
fold?: Snippet; vote: string | null;
onaction: (action: 'vote' | 'proposals' | 'propose') => void; fold: Snippet<[Proposal]>;
onvote: (proposalId: string) => void;
onpropose: () => void;
} = $props(); } = $props();
const p = $derived(unit.inForce); const p = $derived(unit.inForce);
const votes = $derived((p?.tally.for ?? 0) + (p?.tally.against ?? 0)); 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> </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"> <p class="bar">
<span class="status faint"> <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> {#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} {:else if p?.enteredAt} · {t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
</span> </span>
<Crans level={unit.inertia} />
{#if stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{/if} {#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"> <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> {#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={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0}{` · ${rivals}`}{/if}</button> <button type="button" onclick={onpropose}>{t('title.propose')}</button>
<button type="button" onclick={() => onaction('propose')}>{t('title.propose')}</button>
</span> </span>
</p> </p>
{#if voteOpen && fold}<div class="fold">{@render fold()}</div>{/if} {#if shown}<div class="fold">{@render fold(shown)}</div>{/if}
</div> </div>
<style> <style>
@@ -65,11 +76,23 @@
align-items: center; align-items: center;
gap: 0.3rem 0.8rem; gap: 0.3rem 0.8rem;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
min-height: 2.25rem; min-height: 2.25rem;
} }
.status { .status {
font-weight: 700; 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 { .links {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -105,6 +128,12 @@
opacity: 1; opacity: 1;
} }
} }
@media (max-width: 899px) {
.links button,
.proposed {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.title-unit, .title-unit,
.links { .links {
+40 -32
View File
@@ -1,13 +1,22 @@
<script lang="ts"> <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 { Snippet } from 'svelte';
import type { Shown } from '$lib/doc.svelte'; import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.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 Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let { let {
text, text,
mark = 'unchanged', mark = 'unchanged',
seedMinimal = false, seedMinimal = false,
seed = null,
enteredAt = null, enteredAt = null,
rivals = 0, rivals = 0,
selected = false, selected = false,
@@ -15,17 +24,17 @@
voteFor = null, voteFor = null,
voteOpen = false, voteOpen = false,
votes = 0, votes = 0,
module = false,
proposeLabel, proposeLabel,
meta,
content,
fold, fold,
onaction, onaction,
}: { }: {
text: Shown; text: Shown;
mark?: 'unchanged' | 'added' | 'replaced' | 'removed'; mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
seedMinimal?: boolean; 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; enteredAt?: string | null;
/** Its other versions open to the vote: said in its state, read in « Le vote ». */
rivals?: number; rivals?: number;
selected?: boolean; selected?: boolean;
actions?: boolean; actions?: boolean;
@@ -34,51 +43,45 @@
voteOpen?: boolean; voteOpen?: boolean;
/** The votes on the version in force, shown next to the seed mark. */ /** The votes on the version in force, shown next to the seed mark. */
votes?: number; votes?: number;
/** A module (ADR 0009): quieter than an article, its own words to propose. */
module?: boolean;
proposeLabel?: string; 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. */ /** The vote, unfolded under the text. */
fold?: Snippet; fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void; onaction: (action: 'vote' | 'propose') => void;
} = $props(); } = $props();
</script> </script>
<!-- A module is not an article of the text: a group named by its title. --> <article
<svelte:element
this={module ? 'div' : 'article'}
class="unit {mark}" class="unit {mark}"
class:module
class:selected class:selected
class:seed={!!seed}
class:unfolded={voteOpen} class:unfolded={voteOpen}
role={module ? 'group' : undefined}
aria-label={module ? text.title : undefined}
> >
<header> <header>
{#if seed}<WaterLevel tally={seed.tally} evaluation={seed.evaluation} blocked={seed.blocked} />{/if}
<h2 class="eyebrow">{text.title}</h2> <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 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} {#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> </header>
{#if content}{@render content()}{:else}<Prose text={text.body} />{/if} <Prose text={text.body} />
{#if actions} {#if actions}
<footer class="actions"> <footer class="actions">
<span class="status faint"> <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} {:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
{#if rivals > 0} · {i18n.plural('doc.others', rivals)}{/if}
</span> </span>
<span class="links"> <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" 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> <button type="button" onclick={() => onaction('propose')}>{proposeLabel ?? t('doc.action.propose')}</button>
</span> </span>
</footer> </footer>
{/if} {/if}
{#if voteOpen && fold}{@render fold()}{/if} {#if voteOpen && fold}{@render fold()}{/if}
</svelte:element> </article>
<style> <style>
.unit { .unit {
@@ -98,18 +101,12 @@
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
/* A module closes its section: set apart, quieter than the articles. */ /* A seed proposal: the water of a vote under way, never the bare page of a text in force. */
.unit.module { .unit.seed {
margin-top: 0.9rem; background: color-mix(in srgb, var(--water-wash) 55%, transparent);
margin-bottom: 1.4rem;
padding-block: 0.85rem 0.6rem;
background: var(--water-wash);
} }
.unit.module:hover, .unit.seed :global(.prose) {
.unit.module:focus-within, color: var(--ink-soft);
.unit.module.selected,
.unit.module.unfolded {
background: var(--surface);
} }
header { header {
display: flex; display: flex;
@@ -168,4 +165,15 @@
opacity: 1; opacity: 1;
} }
} }
@media (max-width: 899px) {
.links button {
min-height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.unit,
.links {
transition: none;
}
}
</style> </style>
+106 -9
View File
@@ -5,22 +5,26 @@
// never who voted what —, how far the threshold is, your vote and where it // 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 // 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 // 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 // offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). Lot U: how
// rest of a version (typos, translations, objections, history) lives in the // many « oui » pass it, how many « non » block it (ADR 0013), its weight in notches,
// panel, tab « Détails ». // 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 { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte'; import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.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 { holding } from '$lib/objections';
import { moved } from '$lib/order'; import { moved } from '$lib/order';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte'; import { toasts } from '$lib/toast.svelte';
import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types'; import type { Entered, LevelInfo, Objection, Proposal, Unit } from '$lib/types';
import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote'; import { announce, errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Crans from './Crans.svelte';
import Formula from './Formula.svelte'; import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte'; import InertiaPicker from './InertiaPicker.svelte';
import QuorumSetting from './QuorumSetting.svelte';
import WaterLevel from './WaterLevel.svelte'; import WaterLevel from './WaterLevel.svelte';
let { let {
@@ -36,7 +40,7 @@
doc: DocState; doc: DocState;
proposal: Proposal; proposal: Proposal;
unit: Unit; 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; onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void; onsettings: (proposalId: string) => void;
ondetails: (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 shifted = $derived(order && p.status !== 'in_force' ? moved(unit.members ?? [], order.ids) : new Set<string>());
const source = $derived(view?.doc.sourceLang ?? 'fr'); const source = $derived(view?.doc.sourceLang ?? 'fr');
const nameOf = (id: string) => doc.articleTitle(id, doc.original ? source : i18n.lang, source); 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(() => { $effect(() => {
const id = p.id; const id = p.id;
@@ -119,8 +143,9 @@
} }
busy = true; busy = true;
try { try {
await voteDirect(doc, lines); const r = await voteDirect(doc, lines);
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted')); // A simulated vote says so, never « enregistré » (ADR 0012).
toasts.show(t(!choice ? 'vote.retracted' : r.simulated ? 'vote.saved.simulated' : 'vote.saved'));
} catch (e) { } catch (e) {
error = errorText(e); error = errorText(e);
} finally { } finally {
@@ -135,10 +160,10 @@
<div class="numbers"> <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> <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} {#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> <p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if} {/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p> <p class="faint small"><Crans level={p.votedAtInertia} /></p>
</div> </div>
</div> </div>
{#if p.blocked} {#if p.blocked}
@@ -223,7 +248,31 @@
{#if error}<p class="warn" role="alert">{error}</p>{/if} {#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="more"> <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 p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if !unit.kind && unit.canSetQuorum}<QuorumSetting {doc} {unit} />{/if}
{#if detail && view} {#if detail && view}
<Formula <Formula
electorate={detail.electorate} electorate={detail.electorate}
@@ -269,6 +318,54 @@
.state { .state {
font-weight: 700; 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 { .order {
margin: 0; margin: 0;
padding-left: 1.6rem; padding-left: 1.6rem;
+7 -1
View File
@@ -9,7 +9,7 @@
evaluation, evaluation,
blocked = false, blocked = false,
size = 'row', 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( const ratio = $derived(
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0, 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); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
overflow: hidden; 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 { .level.large {
width: 14px; width: 14px;
height: 5.5rem; height: 5.5rem;
+51 -17
View File
@@ -8,8 +8,13 @@ import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all'; 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 { export interface PanelState {
unitId: string | null; unitId: string | null;
@@ -25,9 +30,9 @@ export interface PanelState {
edit?: boolean; edit?: boolean;
} }
/** The tabs as the URL names them. */ /** The panel as the URL names it (`?onglet=`); an old `onglet=propositions` unfolds the vote instead. */
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; export const TABS: Record<string, Tab> = { panier: 'basket', proposer: 'propose', details: 'details', reglages: 'settings' };
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; 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. */ /** The query of a reader's URL: the unfolded vote and the panel. */
export function panelSearch(panel: PanelState | null, vote: string | null): string { 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 }; 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 { export class DocState {
view = $state<DocView | null>(null); view = $state<DocView | null>(null);
error = $state<string | 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 * The seed proposals (ADR 0011), read at the head of the text while no article is in
* where no article is in force yet, as long as it has something to decide. * force: one knows where to begin. Never once an article is in force.
*/ */
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] { get seedHead(): Entry[] {
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = []; if (this.inForce.length) return [];
for (const u of this.view?.units ?? []) { const out: Entry[] = [];
let last = out.at(-1); for (const unit of this.view?.units ?? []) {
if (!last || last.section !== u.section) { const proposal = unit.proposals.find((p) => p.status === 'open' && p.seedProposal);
last = { section: u.section, units: [], order: this.orderOf(u.section) }; if (proposal) out.push({ unit, proposal, seed: true });
out.push(last);
}
if (u.inForce) last.units.push(u);
} }
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. */ /** Open proposals, of articles and modules, in document order; drafts apart. */
+33
View File
@@ -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 }; 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
View File
@@ -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; const id = doc.view?.doc.id;
if (!id) return 0; if (!id) return { count: 0, simulated: false };
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines }); const r = await api.post<{ count: number; entered: Entered[]; simulated?: boolean }>(`/docs/${id}/votes`, { lines });
await doc.reload(); await doc.reload();
announce(doc, r.entered); announce(doc, r.entered);
return r.count; return { count: r.count, simulated: !!r.simulated };
} }
+180 -79
View File
@@ -1,4 +1,10 @@
<script lang="ts"> <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 { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte'; import ChainSign from '$lib/components/ChainSign.svelte';
@@ -6,20 +12,22 @@
import DocPanel from '$lib/components/DocPanel.svelte'; import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte'; import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.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 TitleBar from '$lib/components/TitleBar.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte'; import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte'; import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte'; import WaveRule from '$lib/components/WaveRule.svelte';
import { sections, votableIds } from '$lib/basket'; 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 { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { sectionLabel } from '$lib/labels';
import { answerPanel } from '$lib/objections'; import { answerPanel } from '$lib/objections';
import { BasketState } from '$lib/picks.svelte'; import { BasketState } from '$lib/picks.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import type { Lang, Proposal, Unit } from '$lib/types'; import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote'; 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 doc = new DocState();
const basket = new BasketState(); const basket = new BasketState();
@@ -53,10 +61,20 @@
edit: q.get('modifier') === '1', 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 vote = $derived.by(() => {
const q = page.url.searchParams; 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) { 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 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); 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. // On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null); let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines); const sign = (lines: Line[]) => (signing = lines);
@@ -92,34 +126,42 @@
const lang = $derived<Lang>(doc.original ? source : i18n.lang); const lang = $derived<Lang>(doc.original ? source : i18n.lang);
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : ''); const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.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); 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 titleUnit = $derived(view?.modules.title ?? null);
const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale); const titleStale = $derived(!!titleUnit?.inForce && shown(titleUnit.inForce, lang, source).stale);
const VIEWS: View[] = ['inforce', 'wellrated', 'all']; const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) { const reading = $derived(doc.mode === 'inforce' || !doc.preview);
if (action === 'vote') { /** The text as it reads, by section, each closed by its order. */
if (version) toggleVote(version); const textSections = $derived(reading ? doc.textSections : []);
return; /** The versions the text shows: their vote unfolds there, never in the list below. */
}
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 textIds = $derived( const textIds = $derived(
new Set([ new Set(
titleUnit?.inForce?.id, reading
...(doc.mode === 'inforce' || !doc.preview ? textSections.flatMap((s) => s.entries.map((e) => e.proposal.id))
? [...doc.inForce, ...(view?.modules.orders ?? [])].map((u) => u.inForce?.id) : (doc.preview ?? []).map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.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); 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> </script>
{#snippet foldOf(unit: Unit, p: Proposal)} {#snippet foldOf(unit: Unit, p: Proposal)}
@@ -135,6 +177,28 @@
/> />
{/snippet} {/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> <svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error} {#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> <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> </p>
<h1>{title}</h1> <h1>{title}</h1>
{#if titleUnit?.inForce} {#if titleUnit}
{@const tp = titleUnit.inForce}
<TitleBar <TitleBar
unit={titleUnit} unit={titleUnit}
stale={titleStale} stale={titleStale}
selected={panel?.unitId === titleUnit.id} selected={panel?.unitId === titleUnit.id}
voteOpen={inText && vote === tp.id} {vote}
onaction={(action) => onUnit(titleUnit, action)} fold={moduleFold}
> onvote={(id) => toggleVote(id)}
{#snippet fold()}{@render foldOf(titleUnit, tp)}{/snippet} onpropose={() => openPanel({ unitId: titleUnit.id, proposalId: null, tab: 'propose' })}
</TitleBar> />
{/if} {/if}
<p class="summary">{summary}</p> <p class="summary">{summary}</p>
<p class="facts faint"> <p class="facts faint">
@@ -170,10 +233,11 @@
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}> <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')} {basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
</button> </button>
{#if view.vote.simulated}<span class="tag" title={t('vote.simulated.note')}>{t('vote.simulated.mark')}</span>{/if}
</div> </div>
{/if} {/if}
<div class="controls"> <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)} {#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> <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} {/each}
@@ -191,44 +255,37 @@
</header> </header>
<section class="text" aria-label={t('doc.view.inforce')}> <section class="text" aria-label={t('doc.view.inforce')}>
{#if doc.mode === 'inforce' || !doc.preview} {#if reading}
<!-- No article in force (the seed proposals are not, ADR 0011): said once, above the orders. --> <!-- Nothing in force (ADR 0011): the seed proposals at the head, said once. -->
{#if !doc.inForce.length}<p class="muted">{t('doc.empty')}</p>{/if} {#if !doc.inForce.length}<p class="lead">{t(textSections.length ? 'doc.empty.seeds' : 'doc.empty')}</p>{/if}
{#each doc.inForceBySection as s, i (`${i}:${s.section}`)} {#each textSections as s, i (`${i}:${s.section}`)}
{#each s.units as u (u.id)} {@const label = sectionLabel(s.section)}
{#if u.inForce} <div class="text-section">
{@const p = u.inForce} {#if label}<p class="section-name">{label}</p>{/if}
<UnitBlock {#if !s.order || reordering !== s.order.id}
text={shown(p, lang, source)} {#each s.entries as e (e.proposal.id)}
seedMinimal={p.seedMinimal} {@const p = e.proposal}
enteredAt={p.enteredAt} <UnitBlock
votes={p.tally.for + p.tally.against} text={shown(p, lang, source)}
rivals={rivalsOf(u)} seed={e.seed ? p : null}
selected={panel?.unitId === u.id} seedMinimal={!e.seed && p.seedMinimal}
voteFor={p.id} enteredAt={e.seed ? null : p.enteredAt}
voteOpen={inText && vote === p.id} votes={p.tally.for + p.tally.against}
onaction={(action) => onUnit(u, action)} rivals={othersOf(e)}
> selected={panel?.unitId === e.unit.id}
{#snippet fold()}{@render foldOf(u, p)}{/snippet} voteFor={p.id}
</UnitBlock> voteOpen={vote === p.id}
onaction={(action) => onEntry(e, action)}
>
{#snippet fold()}{@render foldOf(e.unit, p)}{/snippet}
</UnitBlock>
{/each}
{/if} {/if}
{/each} {#if s.order && !s.order.moot}{@render orderLine(s.order)}{/if}
{#if s.order?.inForce} </div>
{@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}
{/each} {/each}
{:else} {:else}
{#each doc.preview as pu (pu.unitId)} {#each doc.preview ?? [] as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)} {@const u = doc.unit(pu.unitId)}
{@const id = pu.proposalId ?? u?.inForce?.id ?? null} {@const id = pu.proposalId ?? u?.inForce?.id ?? null}
{@const p = doc.proposal(id)} {@const p = doc.proposal(id)}
@@ -237,13 +294,17 @@
mark={pu.mark} mark={pu.mark}
selected={panel?.unitId === pu.unitId} selected={panel?.unitId === pu.unitId}
voteFor={id} voteFor={id}
voteOpen={inText && !!id && vote === id} voteOpen={!!id && vote === id}
onaction={(action) => u && onUnit(u, action, 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} {#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
</UnitBlock> </UnitBlock>
{/each} {/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} {/if}
</section> </section>
@@ -264,7 +325,7 @@
<div class="deep-head"> <div class="deep-head">
<h2 id="deep-title">{t('deep.title')}</h2> <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')}> <div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
{#each ['all', 'wellrated', 'mine'] as const as f (f)} {#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> <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>
</div> </div>
<DeepList <DeepList
articles={deep} sections={deepSecs}
vote={inText ? null : vote} vote={textIds.has(vote ?? '') ? null : vote}
{reordering}
all={filter !== 'all'}
ontoggle={(_, p) => toggleVote(p.id)} ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf} fold={foldOf}
{nameOf} order={orderLine}
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined} 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> <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> </div>
{#if panel} {#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}
{#if signing && view.vote.chain} {#if signing && view.vote.chain}
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} /> <ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
@@ -344,10 +407,14 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.6rem;
justify-content: space-between; justify-content: space-between;
margin-top: 1.6rem; margin-top: 1.4rem;
} }
.basket-row { .basket-row {
margin-top: 1.4rem; display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 1.2rem;
} }
.basket-row + .controls { .basket-row + .controls {
margin-top: 0.8rem; margin-top: 0.8rem;
@@ -372,6 +439,7 @@
font-weight: 750; font-weight: 750;
font-size: 0.86rem; font-size: 0.86rem;
color: var(--ink-soft); color: var(--ink-soft);
white-space: nowrap;
} }
.seg.small button { .seg.small button {
padding-inline: 0.65rem; padding-inline: 0.65rem;
@@ -402,6 +470,22 @@
.text { .text {
margin-top: 2.2rem; 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 { .deep {
padding-top: 2rem; padding-top: 2rem;
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem); background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
@@ -412,7 +496,7 @@
.deep-head { .deep-head {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
margin-bottom: 1rem; margin-bottom: 0.6rem;
} }
.deep-head h2 { .deep-head h2 {
font-family: var(--font-text); font-family: var(--font-text);
@@ -437,4 +521,21 @@
.new { .new {
margin-top: 1.4rem; 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> </style>
+30 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Proposal, Unit } from '../../src/lib/types'; import type { Proposal, Unit } from '../../src/lib/types';
import { articles, excerpts, level } from '../../src/lib/versions'; import { articles, deepSections, excerpts, level } from '../../src/lib/versions';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal { function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return { return {
@@ -96,3 +96,32 @@ describe('the versions of an article', () => {
expect(excerpts(['Seule.'])).toEqual(['Seule.']); expect(excerpts(['Seule.'])).toEqual(['Seule.']);
}); });
}); });
describe('the open proposals by section (lot U)', () => {
const inSection = (u: Unit, section: string | null): Unit => ({ ...u, section });
it('each section holds its articles in document order, and its order when it closes there', () => {
const t1 = inSection(unit('T1', [prop('t1', 'T1')]), 'technique');
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
const t2 = inSection(unit('T2', [prop('t2', 'T2')]), 'technique');
const order = { ...inSection(unit('O', []), 'technique'), kind: 'order' as const };
const list = deepSections(articles(items(t1, t2, c1)), [order], ['introduction', 'technique', 'conclusion']);
expect(list.map((s) => s.key)).toEqual(['technique', 'conclusion']);
expect(at(list, 0).articles.map((a) => a.unit.id)).toEqual(['T1', 'T2']);
expect(at(list, 0).order?.id).toBe('O');
expect(at(list, 1).order).toBeUndefined();
});
it('a section with only its order to show is kept, at its place in the document', () => {
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
const order = { ...inSection(unit('O', []), 'introduction'), kind: 'order' as const };
const list = deepSections(articles(items(c1)), [order], ['introduction', 'conclusion']);
expect(list.map((s) => [s.key, s.articles.length, s.order?.id ?? null])).toEqual([
['introduction', 0, 'O'],
['conclusion', 1, null],
]);
});
it('the articles without a section come after the sections the document lists', () => {
const loose = unit('N1', [prop('n1', 'N1')]);
const c1 = inSection(unit('C1', [prop('c1', 'C1')]), 'conclusion');
expect(deepSections(articles(items(loose, c1)), [], ['conclusion']).map((s) => s.key)).toEqual(['conclusion', null]);
});
});