v3 : interface Svelte — accueil, groupes, lecteur du texte en vigueur, panneau contextuel, éditeur à assistant, trustinWallet

- Accueil sobre, menu complet à gauche (Décisions, Docs de référence, Mandats,
  Protocoles), intro demandée, quatre sections, connexion Ğ1.
- Lecteur : texte en vigueur en Literata, actions contextuelles par article
  (le vote, propositions, proposer), propositions en cours sous la « surface »
  avec leur niveau d'eau, aperçus « si les bien cotées passent » / « si tout
  passe », langue du texte fr/en/es (traductions éditoriales).
- Panneau : vote pour/contre, seuil et formule (MathML), inertie et son
  changement (voté en inertie +), coquille sans vote, traduction, signalement
  de contradiction, historique des corrections.
- trustinWallet (/acces) : groupes et membres, invitations à usage unique,
  administrateurs, retours d'usage.
- Contrôles : vitest (markdown, catalogues, anti-lexique), Playwright C1–C10 + M1.
- Sections du seed portées jusqu'à l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:58:34 +02:00
co-authored by Claude Opus 5.5
parent 67ddee28b2
commit 7a022dd35e
66 changed files with 7771 additions and 15 deletions
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { plain } from '$lib/markdown';
import { relationOf, titleOf } from '$lib/labels';
import { t, type Key } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types';
import WaterLevel from './WaterLevel.svelte';
let {
items,
selected,
onopen,
}: { items: { unit: Unit; proposal: Proposal }[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
const variants = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
</script>
<ul class="deep">
{#each items as { unit, proposal: p }, i (p.id)}
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section}
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
{/if}
<li>
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="title">{titleOf(p, unit)}</span>
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if}
<span class="meta">
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if}
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
</span>
</span>
</button>
</li>
{:else}
<li class="faint none">{t('deep.none')}</li>
{/each}
</ul>
<style>
.deep {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.row {
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
padding: 0.85rem 1rem;
margin: 0 -1rem;
width: calc(100% + 2rem);
border: 0;
border-radius: var(--r-card);
background: transparent;
text-align: left;
transition: background-color 0.15s ease, transform 0.12s ease;
}
.row:hover,
.row.current {
background: var(--surface);
box-shadow: var(--shadow);
}
.main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.title {
font-weight: 780;
}
.excerpt {
font-family: var(--font-text);
color: var(--ink-soft);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.8rem;
align-items: center;
font-size: 0.8rem;
font-weight: 700;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.section {
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.section:first-child {
margin-top: 0.4rem;
}
.none {
padding: 0.8rem 0;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { DiffPart } from '$lib/types';
let { parts }: { parts: DiffPart[] } = $props();
</script>
<p class="diff prose">
{#each parts as part, i (i)}
{#if part.op === 'added'}<ins class="diff-added">{part.text}</ins>{:else if part.op === 'removed'}<del class="diff-removed">{part.text}</del>{:else}<span>{part.text}</span>{/if}
{/each}
</p>
<style>
.diff {
white-space: pre-wrap;
font-size: 1rem;
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import type { Lang } from '$lib/types';
import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
panel,
lang,
onchange,
onclose,
}: { doc: DocState; panel: PanelState; lang: Lang; onchange: (p: PanelState) => void; onclose: () => void } = $props();
const unit = $derived(doc.unit(panel.unitId));
const versions = $derived(
unit ? [unit.inForce, ...unit.proposals.filter((p) => p.status === 'open')].filter((p) => p !== null) : [],
);
const voteId = $derived(panel.proposalId ?? unit?.inForce?.id ?? unit?.proposals.find((p) => p.status === 'open')?.id ?? null);
const tabs = $derived(
(
[
['vote', t('panel.vote'), !!voteId],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true],
] as const
).filter(([, , show]) => show),
);
let body: HTMLElement | undefined = $state();
$effect(() => {
void panel.tab;
void panel.proposalId;
body?.scrollTo({ top: 0 });
});
</script>
<aside class="panel card" aria-label={t('panel.vote')}>
<div class="bar">
<div class="tabs" role="tablist">
{#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
{/each}
</div>
<button class="close" type="button" onclick={onclose} aria-label={t('panel.close')}><Icon name="close" /></button>
</div>
<div class="body" bind:this={body}>
{#if panel.tab === 'vote' && voteId}
<VoteView
{doc}
proposalId={voteId}
{lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
/>
{: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 === voteId} onclick={() => onchange({ ...panel, proposalId: v.id, tab: 'vote' })}>
<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">{titleOf(v, unit)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}</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} />{t('doc.action.propose')}</button>
{:else if panel.tab === 'propose'}
<ProposeView
{doc}
unitId={panel.unitId}
draftId={panel.draftId ?? null}
prefill={panel.prefill ?? null}
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
/>
{/if}
</div>
</aside>
<style>
.panel {
position: fixed;
top: 0.8rem;
right: 0.8rem;
bottom: 0.8rem;
width: min(var(--panel), calc(100vw - 1.6rem));
display: flex;
flex-direction: column;
z-index: 35;
box-shadow: var(--shadow-strong);
animation: enter 0.24s var(--ease);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.7rem 0.7rem 0.5rem 0.9rem;
}
.tabs {
display: flex;
gap: 0.2rem;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
overflow-x: auto;
}
.tabs button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.35rem 0.8rem;
min-height: 2.1rem;
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
white-space: nowrap;
}
.tabs button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
}
.close {
display: grid;
place-items: center;
flex: none;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: var(--ink-soft);
}
.close:hover {
background: var(--water-wash);
}
.body {
flex: 1;
overflow-y: auto;
padding: 0.6rem 1.2rem 1.6rem;
overscroll-behavior: contain;
}
.versions {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: grid;
gap: 0.4rem;
}
.version {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.7rem 0.8rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
text-align: left;
}
.version:hover,
.version.current {
background: var(--water-wash);
}
.text {
display: grid;
gap: 0.1rem;
}
.title {
font-weight: 750;
}
.small {
font-size: 0.82rem;
}
@keyframes enter {
from {
transform: translateX(16px);
opacity: 0;
}
}
@media (max-width: 899px) {
.panel {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100%;
max-height: 86dvh;
border-radius: var(--r-card) var(--r-card) 0 0;
animation-name: sheet;
}
@keyframes sheet {
from {
transform: translateY(24px);
opacity: 0;
}
}
}
</style>
@@ -0,0 +1,89 @@
<script lang="ts">
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { i18n, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { toasts } from '$lib/toast.svelte';
let dialog: HTMLDialogElement | undefined = $state();
let body = $state('');
let busy = $state(false);
let error = $state('');
export function open() {
error = '';
dialog?.showModal();
}
async function send(e: SubmitEvent) {
e.preventDefault();
if (!body.trim()) return;
busy = true;
try {
await api.post('/feedback', {
route: page.url.pathname + page.url.search,
body,
context: {
lang: i18n.lang,
mood: mood.current,
viewport: `${innerWidth}x${innerHeight}`,
build: __LD_BUILD__,
},
});
body = '';
dialog?.close();
toasts.show(t('feedback.sent'));
} catch (err) {
error = t(`error.${err instanceof ApiError ? err.code : 'internal'}` as never);
} finally {
busy = false;
}
}
</script>
<dialog bind:this={dialog} class="dialog card" aria-labelledby="fb-title">
<form onsubmit={send}>
<h2 id="fb-title">{t('feedback.title')}</h2>
<label class="field">
<span>{t('feedback.prompt')}</span>
<textarea class="textarea" bind:value={body} rows="6" maxlength="4000"></textarea>
</label>
<p class="faint small">{t('feedback.context')}</p>
{#if error}<p class="err" role="alert">{error}</p>{/if}
<div class="actions">
<button class="btn quiet" type="button" onclick={() => dialog?.close()}>{t('common.cancel')}</button>
<button class="btn primary" type="submit" disabled={busy || !body.trim()}>{t('feedback.send')}</button>
</div>
</form>
</dialog>
<style>
.dialog {
border: 0;
padding: 1.5rem;
width: min(92vw, 32rem);
color: var(--ink);
}
.dialog::backdrop {
background: var(--veil);
}
form {
display: grid;
gap: 1rem;
}
h2 {
font-size: 1.2rem;
}
.small {
font-size: 0.82rem;
}
.err {
color: var(--ochre);
font-weight: 650;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
// The threshold formula, typeset with native MathML — no KaTeX needed.
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Evaluation, Inertia, Tally } from '$lib/types';
let {
electorate,
source,
tally,
evaluation,
inertia,
presets,
floor,
base,
}: {
electorate: number;
source: 'wot' | 'group';
tally: Tally;
evaluation: Evaluation | null;
inertia: Inertia;
presets: { key: Inertia; majorityPct: number; gradient: number }[];
floor: number;
base: number;
} = $props();
const p = $derived(presets.find((x) => x.key === inertia));
</script>
<details class="formula">
<summary>{t('formula.title')}</summary>
<p class="muted">{t('formula.intro')}</p>
<math display="block" class="math">
<mrow>
<mi>R</mi><mo>=</mo><mi>C</mi><mo>+</mo><msup><mi>B</mi><mi>W</mi></msup><mo>+</mo>
<mrow><mo>(</mo><mi>M</mi><mo>+</mo><mo>(</mo><mn>1</mn><mo>−</mo><mi>M</mi><mo>)</mo><mo>(</mo><mn>1</mn><mo>−</mo>
<msup><mrow><mo>(</mo><mfrac><mi>T</mi><mi>W</mi></mfrac><mo>)</mo></mrow><mi>G</mi></msup><mo>)</mo><mo>)</mo></mrow>
<mo>·</mo><mi>max</mi><mo>(</mo><mn>0</mn><mo>,</mo><mi>T</mi><mo>−</mo><mi>C</mi><mo>)</mo>
</mrow>
</math>
<ul class="values">
<li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.T', { n: tally.for + tally.against })}</li>
{#if p}
<li>{t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}</li>
<li>{t('formula.G', { n: p.gradient })}</li>
{/if}
<li>{t('formula.B', { n: base })}</li>
<li>{t('formula.C', { n: floor })}</li>
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
</ul>
<p class="faint">{t('formula.reading')}</p>
</details>
<style>
.formula {
border-radius: var(--r-input);
background: var(--surface-2);
padding: 0.7rem 0.9rem;
}
summary {
cursor: pointer;
font-weight: 750;
color: var(--ink-soft);
}
.formula[open] summary {
margin-bottom: 0.7rem;
}
.formula > :not(summary) + :not(summary) {
margin-top: 0.6rem;
}
.math {
font-size: 1.05rem;
overflow-x: auto;
padding: 0.4rem 0;
}
.values {
margin: 0;
padding-left: 1.1rem;
font-size: 0.9rem;
display: grid;
gap: 0.15rem;
}
.r {
font-weight: 800;
}
.faint {
font-size: 0.85rem;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
// Shown instead of a page that needs a session, or that failed to load.
import { page } from '$app/state';
import { t, type Key } from '$lib/i18n/index.svelte';
let { code, onretry }: { code: string; onretry?: () => void } = $props();
</script>
<div class="guard">
<p>{t(`error.${code}` as Key)}</p>
{#if code === 'unauthenticated'}
<a class="btn primary" href="/connexion?return={encodeURIComponent(page.url.pathname)}">{t('nav.signin')}</a>
{:else if onretry && (code === 'network' || code === 'internal')}
<button class="btn" type="button" onclick={onretry}>{t('error.retry')}</button>
{/if}
</div>
<style>
.guard {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1.2rem;
justify-items: start;
color: var(--ink-soft);
font-size: 1.05rem;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
// A few line icons, drawn here rather than pulled from a library.
let { name, size = 18 }: { name: 'lock' | 'close' | 'menu' | 'moon' | 'sun' | 'chevron' | 'message' | 'key' | 'plus'; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{#if name === 'lock'}
<rect x="5" y="11" width="14" height="9" rx="2.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
{:else if name === 'close'}
<path d="M6 6l12 12M18 6L6 18" />
{:else if name === 'menu'}
<path d="M4 7h16M4 12h16M4 17h10" />
{:else if name === 'moon'}
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />
{:else if name === 'sun'}
<circle cx="12" cy="12" r="4" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" />
{:else if name === 'chevron'}
<path d="M9 6l6 6-6 6" />
{:else if name === 'message'}
<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H10l-4 3v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z" />
{:else if name === 'key'}
<circle cx="8" cy="15" r="4" /><path d="M11 12l8-8M16 7l2 2M14 9l2 2" />
{:else if name === 'plus'}
<path d="M12 5v14M5 12h14" />
{/if}
</svg>
+265
View File
@@ -0,0 +1,265 @@
<script lang="ts">
import { untrack } from 'svelte';
import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { toasts } from '$lib/toast.svelte';
type Mode = 'replace' | 'remove' | 'new';
type Hint =
| { kind: 'split'; parts: string[] }
| { kind: 'similar'; proposalId: string; unitId: string | null; title: string | null; inForce: boolean; score: number }
| { kind: 'long'; chars: number };
let {
doc,
unitId,
draftId = null,
prefill = null,
ondone,
}: {
doc: DocState;
unitId: string | null;
draftId?: string | null;
prefill?: { title: string; body: string } | null;
ondone: (r: { proposalId: string | null; submitted: boolean }) => void;
} = $props();
const draft = $derived(doc.proposal(draftId));
let target = $state<string | null>(null);
let mode = $state<Mode>('replace');
let after = $state<string>('');
let title = $state('');
let body = $state('');
let hints = $state<Hint[]>([]);
let busy = $state(false);
let error = $state('');
let savedId = $state<string | null>(null);
const unit = $derived(doc.unit(target));
const units = $derived(doc.view?.units ?? []);
// (Re)initialise only when the panel is pointed elsewhere — never on a reload,
// which would wipe what the person is typing.
$effect(() => {
const [u0, d0, pf] = [unitId, draftId, prefill];
untrack(() => {
const u = doc.unit(u0);
const d = doc.proposal(d0);
target = u0;
savedId = d0;
mode = u0 ? 'replace' : 'new';
after = doc.inForce.at(-1)?.id ?? '';
title = pf?.title ?? d?.title ?? u?.inForce?.title ?? u?.proposals[0]?.title ?? '';
body = pf?.body ?? d?.body ?? u?.inForce?.body ?? '';
error = '';
});
});
let timer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const text = `${title}\n${body}`;
clearTimeout(timer);
if (mode === 'remove' || body.trim().length < 20 || !doc.view) {
hints = [];
return;
}
const id = doc.view.doc.id;
timer = setTimeout(async () => {
try {
const r = await api.post<{ hints: Hint[] }>(`/docs/${id}/assist`, { text, proposalId: savedId });
hints = r.hints.filter((h) => h.kind !== 'similar' || h.unitId !== target);
} catch {
hints = [];
}
}, 600);
});
function message(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
/** Create the draft, or update it; returns its id. */
async function save(): Promise<string> {
if (!doc.view) throw new ApiError(0, 'internal');
if (savedId) {
await api.patch(`/proposals/${savedId}`, { title, body });
return savedId;
}
const r = await api.post<{ id: string }>(`/docs/${doc.view.doc.id}/proposals`, {
kind: mode === 'remove' ? 'remove' : 'text',
unitId: mode === 'new' ? null : target,
afterUnitId: mode === 'new' ? after || null : null,
title: mode === 'remove' ? (unit?.inForce?.title ?? title) : title,
body: mode === 'remove' ? '' : body,
submit: false,
});
savedId = r.id;
return r.id;
}
async function keep() {
busy = true;
try {
const id = await save();
await doc.reload();
toasts.show(t('propose.drafted'));
ondone({ proposalId: id, submitted: false });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function submit() {
busy = true;
try {
const id = await save();
await api.post(`/proposals/${id}/submit`);
await doc.reload();
toasts.show(t('propose.submitted'));
ondone({ proposalId: id, submitted: true });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function split(parts: string[]) {
busy = true;
try {
const id = await save();
await api.post(`/proposals/${id}/split`, { parts });
await doc.reload();
toasts.show(t('assist.split.done', { n: parts.length }));
ondone({ proposalId: null, submitted: false });
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
function variantOf(h: Extract<Hint, { kind: 'similar' }>) {
if (!h.unitId || savedId) return;
target = h.unitId;
mode = 'replace';
}
const canSubmit = $derived(mode === 'remove' || (title.trim().length > 0 && body.trim().length > 0));
</script>
<div class="propose">
{#if !draft}
<div class="modes" role="radiogroup" aria-label={t('panel.propose')}>
{#if unit}
<label class:on={mode === 'replace'}><input type="radio" bind:group={mode} value="replace" />{t('propose.mode.replace')}</label>
{#if unit.inForce}<label class:on={mode === 'remove'}><input type="radio" bind:group={mode} value="remove" />{t('propose.mode.remove')}</label>{/if}
{/if}
<label class:on={mode === 'new'}><input type="radio" bind:group={mode} value="new" />{t('propose.mode.new')}</label>
</div>
{/if}
{#if mode === 'new'}
<label class="field">
<span>{t('propose.after')}</span>
<select class="select" bind:value={after}>
{#each units.filter((u) => u.inForce) as u (u.id)}<option value={u.id}>{u.inForce?.title}</option>{/each}
<option value="">{t('propose.after.end')}</option>
</select>
</label>
{/if}
{#if mode === 'remove'}
<p class="muted">{t('propose.remove.note')}</p>
{:else}
<label class="field"><span>{t('propose.title')}</span><input class="input" bind:value={title} maxlength="120" /></label>
<label class="field"><span>{t('propose.body')}</span><textarea class="textarea" bind:value={body} rows="9" maxlength="4000"></textarea></label>
<section class="assist" aria-live="polite">
<h4 class="eyebrow">{t('assist.title')}</h4>
{#each hints as h, i (i)}
{#if h.kind === 'split'}
<p>{t('assist.split', { n: h.parts.length })}</p>
<button class="btn small" type="button" disabled={busy} onclick={() => split(h.parts)}>{t('assist.split.do', { n: h.parts.length })}</button>
{:else if h.kind === 'similar'}
<p>{t('assist.similar', { title: h.title ?? '', where: h.inForce ? t('assist.similar.inForce') : '' })}</p>
{#if h.unitId && !savedId}<button class="btn small" type="button" onclick={() => variantOf(h)}>{t('assist.similar.variant')}</button>{/if}
{:else if h.kind === 'long'}
<p>{t('assist.long', { n: h.chars })}</p>
{/if}
{:else}
<p class="faint">{t('assist.quiet')}</p>
{/each}
</section>
{/if}
<p class="faint small">{t('propose.rule')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="actions">
<button class="btn" type="button" disabled={busy || !canSubmit} onclick={keep}>{t('propose.draft')}</button>
<button class="btn primary" type="button" disabled={busy || !canSubmit} onclick={submit}>{t('propose.submit')}</button>
</div>
</div>
<style>
.propose {
display: grid;
gap: 1rem;
}
.modes {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.modes label {
display: inline-flex;
align-items: center;
min-height: 2.25rem;
padding: 0.3rem 0.9rem;
border-radius: var(--r-pill);
background: var(--surface-2);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
}
.modes label.on {
background: var(--water);
color: var(--on-water);
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.modes label:has(input:focus-visible) {
outline: 2px solid var(--water);
outline-offset: 2px;
}
.assist {
display: grid;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
font-size: 0.92rem;
}
.assist .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import { render } from '$lib/markdown';
let { text, class: cls = '' }: { text: string; class?: string } = $props();
</script>
<!-- render() escapes every character of the source before adding markup. -->
<div class="prose {cls}">{@html render(text)}</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
// Hexagram 48 — 井 Tsing, The Well (Wilhelm). Water ☵ over wind/wood ☴.
// True six-line figure, line 1 at the bottom: yin, yang, yang, yin, yang, yin.
const lines: boolean[] = [false, true, true, false, true, false];
const seg = 42;
</script>
<svg class="seal" viewBox="0 0 112 105" role="img" aria-label="Hexagramme 48 — Le Puits">
{#each lines as yang, i (i)}
{@const y = 91 - i * 17}
{#if yang}
<rect x="6" {y} width="100" height="8" />
{:else}
<rect x="6" {y} width={seg} height="8" />
<rect x={6 + seg + 16} {y} width={seg} height="8" />
{/if}
{/each}
</svg>
<style>
.seal {
position: fixed;
right: 14px;
bottom: 12px;
width: 44px;
height: auto;
opacity: 0.26;
fill: var(--water);
filter: drop-shadow(1px 1px 0.5px rgb(0 0 0 / 0.25));
pointer-events: none;
z-index: 1;
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
import Icon from './Icon.svelte';
import Stamp from './Stamp.svelte';
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
const group = $derived(page.params.group ?? null);
const docsHref = $derived(
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
);
const path = $derived(page.url.pathname);
const tools = $derived([
{ key: 'decisions', label: t('nav.decisions'), href: null },
{ key: 'docs', label: t('nav.docs'), href: docsHref },
{ key: 'mandats', label: t('nav.mandates'), href: null },
{ key: 'protocoles', label: t('nav.protocols'), href: null },
]);
async function signOut() {
await session.logout();
onnavigate?.();
await goto('/');
}
</script>
<nav class="sidebar" aria-label={t('nav.label')}>
<a class="brand" href="/" onclick={() => onnavigate?.()}>
<Stamp />
<span class="word">libre<b>Decision</b></span>
</a>
{#if session.me}
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
<span class="eyebrow">{t('nav.groups')}</span>
</a>
{/if}
<ul class="tools">
{#each tools as tool (tool.key)}
<li>
{#if tool.href}
<a
href={tool.href}
class="tool"
class:current={path.includes('/docs')}
aria-current={path.includes('/docs') ? 'page' : undefined}
onclick={() => onnavigate?.()}>{tool.label}</a
>
{:else}
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
{tool.label}<span class="soon">{t('nav.soon')}</span>
</span>
{/if}
</li>
{/each}
</ul>
<div class="foot">
{#if session.me?.admin}
<a class="line" href="/acces" class:current={path === '/acces'} onclick={() => onnavigate?.()}>
<Icon name="key" size={16} />{t('nav.access')}<span class="faint hint">{t('nav.access.hint')}</span>
</a>
{/if}
{#if session.me}
<button class="line" type="button" onclick={onfeedback}><Icon name="message" size={16} />{t('nav.feedback')}</button>
{/if}
<div class="row">
<div class="langs" role="group" aria-label={t('nav.lang')}>
{#each LANGS as l (l)}
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each}
</div>
<button
class="icon-btn"
type="button"
onclick={() => mood.toggle()}
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
>
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
</button>
</div>
{#if session.me}
<div class="who">
<span class="name">{session.me.displayName}</span>
<button class="btn quiet small" type="button" onclick={signOut}>{t('nav.signout')}</button>
</div>
{:else if session.ready}
<a class="btn primary small signin" href="/connexion" onclick={() => onnavigate?.()}>{t('nav.signin')}</a>
{/if}
</div>
</nav>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.6rem;
height: 100%;
padding: 1.4rem 1rem 1.2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
color: var(--ink);
text-decoration: none;
padding: 0.2rem 0.4rem;
}
.word {
font-size: 1.08rem;
letter-spacing: 0.01em;
}
.word b {
font-weight: 800;
}
.groups {
padding: 0 0.6rem;
text-decoration: none;
}
.groups.current .eyebrow {
color: var(--ink);
}
.tools {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.15rem;
}
.tool {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
border-radius: var(--r-input);
color: var(--ink);
font-weight: 700;
text-decoration: none;
transition: background-color 0.15s ease;
}
a.tool:hover {
background: var(--water-wash);
}
a.tool.current {
background: var(--water-wash);
color: var(--water);
}
.tool.disabled {
color: var(--ink-faint);
font-weight: 600;
cursor: default;
}
.soon {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--ink-faint);
}
.foot {
margin-top: auto;
display: grid;
gap: 0.5rem;
}
.line {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.7rem;
border: 0;
border-radius: var(--r-input);
background: transparent;
color: var(--ink-soft);
font-weight: 650;
font-size: 0.92rem;
text-decoration: none;
text-align: left;
}
.line:hover,
.line.current {
background: var(--water-wash);
color: var(--ink);
}
.hint {
margin-left: auto;
font-size: 0.72rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.2rem 0.3rem;
}
.langs {
display: inline-flex;
background: var(--surface-2);
border-radius: var(--r-pill);
padding: 3px;
}
.langs button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
min-width: 2.25rem;
min-height: 2rem;
font-size: 0.78rem;
font-weight: 800;
color: var(--ink-soft);
}
.langs button.on {
background: var(--surface);
color: var(--water);
box-shadow: var(--shadow);
}
.icon-btn {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: var(--ink-soft);
}
.icon-btn:hover {
background: var(--water-wash);
color: var(--ink);
}
.who {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.3rem 0.2rem 0 0.7rem;
}
.name {
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signin {
justify-self: stretch;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
let { size = 2.2 }: { size?: number } = $props();
const lines: boolean[] = [false, true, true, false, true, false];
</script>
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
<svg viewBox="0 0 112 105">
{#each lines as yang, i (i)}
{@const y = 91 - i * 17}
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
{/each}
</svg>
</span>
<style>
.stamp {
display: grid;
place-items: center;
flex: none;
width: var(--s);
height: var(--s);
border-radius: var(--r-icon);
background: var(--water-wash);
transform: rotate(-10deg);
transition: transform 0.2s var(--ease);
}
svg {
width: 56%;
fill: var(--water);
}
:global(a:hover) > .stamp {
transform: rotate(-2deg) scale(1.06);
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { toasts } from '$lib/toast.svelte';
</script>
<div class="toasts" role="status" aria-live="polite">
{#each toasts.list as toast (toast.id)}
<button class="toast {toast.tone}" type="button" onclick={() => toasts.dismiss(toast.id)}>{toast.text}</button>
{/each}
</div>
<style>
.toasts {
position: fixed;
left: 50%;
bottom: 1.4rem;
transform: translateX(-50%);
display: grid;
gap: 0.5rem;
z-index: 60;
width: min(92vw, 30rem);
}
.toast {
border: 0;
border-radius: var(--r-card);
padding: 0.8rem 1.1rem;
background: var(--ink);
color: var(--bg);
font-weight: 650;
text-align: left;
box-shadow: var(--shadow-strong);
animation: rise 0.35s var(--ease);
}
.toast.rise {
background: var(--moss);
color: var(--on-water);
}
.toast.warn {
background: var(--ochre);
color: var(--on-water);
}
@keyframes rise {
from {
transform: translateY(12px);
opacity: 0;
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import Prose from './Prose.svelte';
let {
text,
mark = 'unchanged',
seedMinimal = false,
enteredAt = null,
rivals = 0,
selected = false,
actions = true,
onaction,
}: {
text: Shown;
mark?: 'unchanged' | 'added' | 'replaced' | 'removed';
seedMinimal?: boolean;
enteredAt?: string | null;
rivals?: number;
selected?: boolean;
actions?: boolean;
onaction: (tab: 'vote' | 'proposals' | 'propose') => void;
} = $props();
</script>
<article class="unit {mark}" class:selected>
<header>
<h2 class="eyebrow">{text.title}</h2>
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
{#if text.stale}<span class="tag ochre">{t('doc.lang.stale')}</span>{:else if text.missing}<span class="tag">{t('doc.lang.missing')}</span>{/if}
</header>
<Prose text={text.body} />
{#if actions}
<footer class="actions">
<span class="status faint">
{#if seedMinimal}<span title={t('doc.seed.long')}>{t('doc.seed')} · 0 vote</span>
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
</span>
<span class="links">
<button type="button" onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
</span>
</footer>
{/if}
</article>
<style>
.unit {
position: relative;
padding: 1.1rem 1.3rem 0.9rem;
margin: 0 -1.3rem;
border-radius: var(--r-card);
transition: background-color 0.18s ease;
}
.unit + :global(.unit) {
margin-top: 0.5rem;
}
.unit:hover,
.unit:focus-within,
.unit.selected {
background: var(--surface);
box-shadow: var(--shadow);
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.removed :global(.prose) {
text-decoration: line-through;
text-decoration-thickness: 1px;
color: var(--ink-faint);
}
.added,
.replaced {
background: var(--moss-wash);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 1rem;
margin-top: 0.7rem;
min-height: 2.25rem;
font-size: 0.84rem;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.unit:hover .links,
.unit:focus-within .links,
.unit.selected .links {
opacity: 1;
}
.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 {
background: var(--water-wash);
}
@media (hover: none) {
.links {
opacity: 1;
}
}
</style>
+520
View File
@@ -0,0 +1,520 @@
<script lang="ts">
import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposalId,
lang,
onopen,
onpropose,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
onopen: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
} = $props();
interface Detail {
proposal: Proposal & { author: string | null };
unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | null;
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[];
signals: {
id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
voteFloor: number;
}
let detail = $state<Detail | null>(null);
let busy = $state(false);
let showDiff = $state(false);
let error = $state('');
let inertiaTarget = $state<Inertia | ''>('');
let typoOpen = $state(false);
let typoTitle = $state('');
let typoBody = $state('');
let typoSubstantive = $state(false);
let trTitle = $state('');
let trBody = $state('');
let signalTarget = $state('');
let signalReason = $state('');
const view = $derived(doc.view);
const unit = $derived(doc.unit(detail?.unit.id ?? null));
const p = $derived(detail?.proposal);
const source = $derived(view?.doc.sourceLang ?? 'fr');
const text = $derived(p ? shown(p, lang, source) : null);
const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open'));
const signalTargets = $derived(
(view?.units ?? []).flatMap((u) =>
[u.inForce, ...u.proposals]
.filter((x): x is Proposal => !!x && x.id !== p?.id && x.status !== 'draft' && x.kind === 'text')
.filter((x) => !(u.id === unit?.id))
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
),
);
async function load() {
try {
detail = await api.get<Detail>(`/proposals/${proposalId}`);
error = '';
const d = detail;
typoTitle = d.proposal.title;
typoBody = d.proposal.body;
typoSubstantive = false;
const tr = d.proposal.translations[lang];
trTitle = tr?.title ?? d.proposal.title;
trBody = tr?.body ?? d.proposal.body;
} catch (e) {
error = message(e);
}
}
$effect(() => {
void proposalId;
void lang;
void load();
});
function message(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
async function refresh() {
await doc.reload();
await load();
}
function announce(entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
async function vote(choice: 'for' | 'against' | null) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
await refresh();
announce(r.entered);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function proposeInertia() {
if (!unit || !inertiaTarget) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget });
inertiaTarget = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) {
e.preventDefault();
busy = true;
typoSubstantive = false;
try {
const r = await api.patch<{ edit: string }>(`/proposals/${proposalId}`, { title: typoTitle, body: typoBody });
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
typoOpen = false;
await refresh();
} catch (err) {
if (err instanceof ApiError && (err.code === 'substantive_in_force' || err.code === 'substantive_not_author')) {
typoSubstantive = true;
} else {
error = message(err);
}
} finally {
busy = false;
}
}
async function saveTranslation(e: SubmitEvent) {
e.preventDefault();
busy = true;
try {
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
toasts.show(t('translate.saved'));
await refresh();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function sendSignal(e: SubmitEvent) {
e.preventDefault();
busy = true;
try {
await api.post(`/proposals/${proposalId}/signals`, { targetId: signalTarget, reason: signalReason });
signalTarget = '';
signalReason = '';
toasts.show(t('signal.sent'));
await refresh();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function lift(id: string) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
await refresh();
announce(r.entered);
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
async function withdraw() {
busy = true;
try {
await api.post(`/proposals/${proposalId}/withdraw`);
toasts.show(t('propose.withdrawn'));
await doc.reload();
} catch (err) {
error = message(err);
} finally {
busy = false;
}
}
</script>
{#if detail && p && text && view}
<article class="vote">
<header>
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
<p class="meta faint">
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
{#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
</p>
</header>
{#if p.kind === 'text'}
{#if detail.diff && showDiff}
<DiffText parts={detail.diff} />
{:else}
<Prose text={text.body} />
{/if}
{#if detail.diff}
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
{/if}
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
{:else if p.kind === 'remove' && detail.inForce}
<del class="removed"><Prose text={detail.inForce.body} /></del>
{/if}
<section class="box">
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if votable}
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<span class="label">{t('vote.yours')}</span>
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
</div>
<p class="faint small">{t('vote.private')}</p>
{:else}
<p class="muted">{t('vote.closed')}</p>
{/if}
</section>
{#if p.kind !== 'inertia' && unit}
<section class="sub">
<h4>{t('inertia.title')} · <span class="muted">{t(`inertia.level.${unit.inertia}`)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each inertiaOpen as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>{t('inertia.change.open', { level: t(`inertia.level.${ip.inertiaTarget ?? 'standard'}`) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="row">
<select class="select" bind:value={inertiaTarget} aria-label={t('inertia.change.to')}>
<option value="" disabled>{t('inertia.change.to')}</option>
{#each ['low', 'standard', 'high', 'max'] as const as level (level)}
{#if level !== unit.inertia}<option value={level}>{t(`inertia.level.${level}`)}</option>{/if}
{/each}
</select>
<button class="btn small" type="button" disabled={!inertiaTarget || busy} onclick={proposeInertia}>{t('inertia.change.submit')}</button>
</div>
<p class="faint small">{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}</p>
</details>
</section>
{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} presets={view.inertia} floor={detail.voteFloor} base={view.doc.formula.base} />
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub">
<details bind:open={typoOpen}>
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
<form class="form" onsubmit={saveTypo}>
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>
{#if typoSubstantive}
<p class="warn">{t('typo.substantive')}</p>
<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>
{/if}
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
</form>
</details>
</section>
{/if}
{#if lang !== source && p.kind === 'text'}
<section class="sub">
<details>
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
<form class="form" onsubmit={saveTranslation}>
<p class="faint small">{t('translate.note')}</p>
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
</form>
</details>
</section>
{/if}
{#if detail.history.length > 1}
<section class="sub">
<h4>{t('history.title')}</h4>
<ol class="history">
{#each detail.history.slice(1) as h (h.at + h.revision)}
<li>
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}</p>
{#if h.diff}<DiffText parts={h.diff} />{/if}
</li>
{/each}
</ol>
</section>
{/if}
{#if p.kind === 'text' && votable}
<section class="sub">
<h4>{t('signal.title')}</h4>
{#each detail.signals as s (s.id)}
<div class="signal" class:lifted={s.liftedAt}>
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
<p class="faint small">
{t('signal.by', { name: s.author })} · {i18n.date(s.at)}
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
</p>
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
</div>
{:else}
<p class="faint small">{t('signal.none')}</p>
{/each}
<details>
<summary>{t('signal.add')}</summary>
<form class="form" onsubmit={sendSignal}>
<p class="faint small">{t('signal.note')}</p>
<select class="select" bind:value={signalTarget} required aria-label={t('signal.target')}>
<option value="" disabled>{t('signal.target')}</option>
{#each signalTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
</select>
<textarea class="textarea short" bind:value={signalReason} rows="3" minlength="10" maxlength="600" placeholder={t('signal.reason')} aria-label={t('signal.reason')}></textarea>
<button class="btn small" type="submit" disabled={busy || !signalTarget || signalReason.trim().length < 10}>{t('signal.send')}</button>
</form>
</details>
</section>
{/if}
{#if p.mine && p.status === 'open'}
<button class="btn quiet small" type="button" disabled={busy} onclick={withdraw}>{t('propose.withdraw')}</button>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</article>
{:else if error}
<p class="warn" role="alert">{error}</p>
{:else}
<p class="faint">{t('common.loading')}</p>
{/if}
<style>
.vote {
display: grid;
gap: 1.1rem;
}
header {
display: grid;
gap: 0.35rem;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.4rem;
}
h4 {
font-size: 0.92rem;
font-weight: 800;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.85rem;
}
.note,
.small {
font-size: 0.84rem;
}
.removed {
opacity: 0.6;
}
.box {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
margin-right: 0.3rem;
}
.btn.on {
background: var(--moss);
color: var(--on-water);
}
.btn.on-against {
background: var(--ochre);
color: var(--on-water);
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
.sub {
display: grid;
gap: 0.5rem;
}
details > summary {
cursor: pointer;
font-weight: 750;
color: var(--water);
font-size: 0.92rem;
}
.row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form {
display: grid;
gap: 0.6rem;
margin-top: 0.6rem;
}
.form .btn {
justify-self: start;
}
.textarea.short {
min-height: 5rem;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.history {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.6rem;
}
.signal {
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
background: var(--ochre-wash);
display: grid;
gap: 0.2rem;
}
.signal.lifted {
background: var(--surface-2);
opacity: 0.75;
}
.signal .btn {
justify-self: start;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
// The signature of this tool: a proposal rises toward the surface as its
// « for » votes approach the threshold; reached, it stands full, in moss.
import { t } from '$lib/i18n/index.svelte';
import type { Evaluation, Tally } from '$lib/types';
let {
tally,
evaluation,
blocked = false,
size = 'row',
}: { tally: Tally; evaluation: Evaluation | null; blocked?: boolean; size?: 'row' | 'large' } = $props();
const ratio = $derived(
evaluation && evaluation.required > 0 ? Math.min(1, tally.for / evaluation.required) : 0,
);
const pct = $derived(Math.round(ratio * 100));
</script>
<span
class="level {size}"
class:reached={evaluation?.reached}
class:blocked
role="img"
aria-label={t('tally.level', { pct })}
title={t('tally.level', { pct })}
>
<span class="water" style:height="{Math.max(pct, tally.for > 0 ? 6 : 0)}%"></span>
</span>
<style>
.level {
position: relative;
display: inline-block;
flex: none;
width: 8px;
height: 2.4rem;
border-radius: 6px;
background: var(--surface-2);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
overflow: hidden;
}
.level.large {
width: 14px;
height: 5.5rem;
border-radius: 8px;
}
.water {
position: absolute;
inset: auto 0 0 0;
background: linear-gradient(to top, var(--water), color-mix(in srgb, var(--water) 55%, var(--water-light)));
transition: height 0.7s var(--ease);
}
.reached .water {
height: 100% !important;
background: var(--moss);
}
.blocked {
box-shadow: inset 0 0 0 2px var(--ochre);
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
// The surface of the well: above, the text in force; below, what may rise.
</script>
<div class="surface" aria-hidden="true">
<svg viewBox="0 0 600 14" preserveAspectRatio="none">
<path d="M0 7 C 25 1, 50 1, 75 7 S 125 13, 150 7 S 200 1, 225 7 S 275 13, 300 7 S 350 1, 375 7 S 425 13, 450 7 S 500 1, 525 7 S 575 13, 600 7" />
</svg>
</div>
<style>
.surface {
margin: 3.5rem 0 0;
height: 14px;
}
svg {
display: block;
width: 100%;
height: 14px;
fill: none;
stroke: var(--water-light);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
</style>