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
+1
View File
@@ -52,3 +52,4 @@ web/test-results/
web/playwright-report/
*.db-wal
*.db-shm
web/node_modules/
@@ -65,6 +65,7 @@ CREATE TABLE units (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id),
code TEXT NOT NULL,
section TEXT, -- grouping key of the seed (introduction, technique…)
position REAL NOT NULL,
inertia TEXT NOT NULL, -- low | standard | high | max
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
+3 -2
View File
@@ -185,6 +185,7 @@ pub struct DocRow {
pub struct UnitRow {
pub id: String,
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
pub in_force_id: Option<String>,
@@ -274,7 +275,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
let doc = load_doc(state, doc_id).await?;
let group = load_group(state, &doc.group_slug).await?;
let units = sqlx::query_as::<_, UnitRow>(
"SELECT id, code, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
)
.bind(doc_id)
.fetch_all(&state.db)
@@ -515,7 +516,7 @@ pub async fn document(
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "position": u.position, "inertia": u.inertia,
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
"inForce": in_force, "proposals": proposals,
})
})
+15 -9
View File
@@ -171,14 +171,20 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
.bind(&st.doc.id)
.fetch_one(&state.db)
.await?;
sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, 'standard', NULL, ?)")
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(position_after(&st.units, after))
.bind(util::now())
.execute(&state.db)
.await?;
// A new article belongs to the section of the one it follows.
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position_after(&st.units, after))
.bind(util::now())
.execute(&state.db)
.await?;
Ok(id)
}
@@ -783,7 +789,7 @@ mod tests {
use super::*;
fn unit(id: &str, position: f64) -> UnitRow {
UnitRow { id: id.into(), code: id.into(), position, inertia: "standard".into(), in_force_id: None }
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "standard".into(), in_force_id: None }
}
#[test]
+9 -4
View File
@@ -32,6 +32,7 @@ pub struct SeedInstance {
#[derive(Deserialize)]
pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
pub versions: Vec<SeedVersion>,
@@ -136,10 +137,14 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.await?;
for unit in &seed.units {
let unit_id = util::id();
sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
.bind(&unit_id)
.bind(&doc_id)
.bind(&unit.code)
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
)
.bind(&unit_id)
.bind(&doc_id)
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(&unit.inertia)
.bind(&now)
+19
View File
@@ -0,0 +1,19 @@
import type { Browser, Page } from '@playwright/test';
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
export async function signIn(page: Page, name: string, admin = false) {
const r = await page.request.post('/api/auth/dev', {
data: { name, admin },
headers: { 'x-requested-with': 'libredecision' },
});
if (!r.ok()) throw new Error(`dev login ${r.status()}`);
}
export async function person(browser: Browser, name: string, admin = false): Promise<Page> {
const ctx = await browser.newContext({ locale: 'fr-FR' });
const page = await ctx.newPage();
await signIn(page, name, admin);
return page;
}
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers';
test('M1 — mobile : menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
await page.getByRole('button', { name: 'Menu' }).click();
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
});
+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers';
test('C1 — l’accueil : l’intro, les quatre sections, le menu complet', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
await expect(page.getByText('en fonction du contexte, processus et modalités, boîte à outils')).toBeVisible();
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
}
await expect(nav.locator('.tool.disabled')).toHaveCount(3);
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
});
test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/groupes');
for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) {
await expect(page.getByText(g, { exact: true })).toBeVisible();
}
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
});
test('C3 — le texte en vigueur : préambule et deux engagements, seed a minima', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
const units = page.locator('section.text article.unit');
await expect(units).toHaveCount(3);
await expect(units.nth(0).locator('h2')).toHaveText('Préambule');
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
await expect(page.locator('ul.deep button.row')).toHaveCount(32);
});
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible();
// The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
const yvv = await person(browser, 'Yvv');
const chiara = await person(browser, 'Chiara');
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await chiara.keyboard.press('Escape');
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
const preamble = page.locator('section.text article.unit').first();
await preamble.hover();
await preamble.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true });
await body.fill((await body.inputValue()).replace('latransmission', 'la transmission'));
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible();
await expect(preamble).toContainText('la transmission de ce document');
// A change of meaning is turned away.
await panel.getByText('Corriger une coquille').click();
await body.fill('Tout autre chose, qui change le sens du préambule.');
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible();
});
test('C7 — l’assistant propose de découper deux décisions', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
await page.getByRole('button', { name: 'Proposer un nouvel article' }).click();
const panel = page.locator('aside.panel');
await panel.getByLabel('Titre').fill('Joignabilité et vérification');
await panel.getByLabel('Texte').fill(
"Je m'engage à rester joignable par mes certifieurs.\n\nJe m'engage à vérifier la clé publique avec la personne.",
);
await expect(panel.getByText('Ce texte semble porter 2 décisions.', { exact: false })).toBeVisible();
await panel.getByRole('button', { name: 'Découper en 2 brouillons' }).click();
await expect(page.getByText('Découpé en 2 brouillons.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Tes brouillons' })).toBeVisible();
});
test('C8 — multilingue : le texte et l’interface en anglais et en espagnol', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
await expect(page.getByText('Open proposals', { exact: false }).first()).toBeVisible();
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Acta de compromiso de Certificación');
});
test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
await page.getByRole('radio', { name: 'Si tout passe' }).click();
await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible();
await expect(page.locator('section.text article.unit')).toHaveCount(33);
await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click();
await expect(page.locator('section.text article.unit')).toHaveCount(3);
});
test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => {
const chiara = await person(browser, 'Chiara');
await chiara.goto('/acces');
await expect(chiara.getByText('La gestion des accès est réservée aux administrateurs de trustinWallet.')).toBeVisible();
const yvv = await person(browser, 'Yvv', true);
await yvv.goto('/acces');
await expect(yvv.getByRole('heading', { name: 'trustinWallet — accès' })).toBeVisible();
await yvv.getByLabel('Pour qui').fill('Invitée');
await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click();
await expect(yvv.locator('.link code')).toContainText('/invitation#');
});
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface.
set -eu
here=$(cd "$(dirname "$0")/../.." && pwd)
data=/tmp/ld-e2e
rm -rf "$data"
mkdir -p "$data"
cd "$here"
LD_DATA_DIR="$data" LD_SEED_DIR=seeds LD_WEB_DIR=web/build LD_BIND=127.0.0.1:8092 LD_DEV_LOGIN=1 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS=http://127.0.0.1:9 \
exec ./target/debug/ld-server
+2195
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "libredecision-web",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 3002 --strictPort",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"test": "vitest run",
"e2e": "playwright test"
},
"devDependencies": {
"@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
"@playwright/test": "1.63.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.70.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^22.20.5",
"svelte": "^5.57.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^7.0.0",
"vitest": "^3.2.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, devices } from '@playwright/test';
// End-to-end controls — the usage tests of the binôme, automated once agreed.
// The server is the real Rust binary on a throwaway database (e2e/serve.sh),
// with the dev login; build it first: `cargo build -p ld-server` and `npm run build`.
export default defineConfig({
testDir: 'e2e',
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: { baseURL: 'http://127.0.0.1:8092', trace: 'retain-on-failure' },
webServer: {
command: 'sh e2e/serve.sh',
url: 'http://127.0.0.1:8092/api/health',
reuseExistingServer: false,
timeout: 60_000,
},
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1360, height: 900 }, locale: 'fr-FR' }, testIgnore: /mobile\.spec\.ts/ },
{ name: 'mobile', use: { ...devices['Pixel 7'], locale: 'fr-FR' }, testMatch: /mobile\.spec\.ts/ },
],
});
+322
View File
@@ -0,0 +1,322 @@
/* libreDecision v3 — tokens and base.
Two moods: « pierre » (cool stone, light) and « nuit » (well at night, dark).
Borderless: depth comes from background and shadow, never from strokes.
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
@import '@fontsource-variable/nunito';
@import '@fontsource-variable/literata/opsz.css';
@import '@fontsource-variable/literata/opsz-italic.css';
:root,
[data-mood='pierre'] {
--bg: #e9eeec;
--surface: #f6f8f7;
--surface-2: #dfe6e3;
--ink: #18282a;
--ink-soft: #4e615f;
--ink-faint: #7f908d;
--water: #1f6f78;
--water-light: #8cc3c4;
--water-wash: rgb(31 111 120 / 0.08);
--moss: #4c7a4b;
--moss-wash: rgb(76 122 75 / 0.12);
--ochre: #a8661e;
--ochre-wash: rgb(168 102 30 / 0.12);
--on-water: #f6f8f7;
--shadow: 0 1px 2px rgb(20 40 40 / 0.06), 0 8px 28px rgb(20 40 40 / 0.07);
--shadow-strong: 0 2px 6px rgb(20 40 40 / 0.1), 0 18px 48px rgb(20 40 40 / 0.16);
--veil: rgb(16 30 31 / 0.28);
color-scheme: light;
}
[data-mood='nuit'] {
--bg: #0e1819;
--surface: #142224;
--surface-2: #1b2d2f;
--ink: #e2ebe8;
--ink-soft: #a2b4b0;
--ink-faint: #6e827e;
--water: #6ec1c6;
--water-light: #2d6a70;
--water-wash: rgb(110 193 198 / 0.08);
--moss: #8dbf84;
--moss-wash: rgb(141 191 132 / 0.12);
--ochre: #e0a458;
--ochre-wash: rgb(224 164 88 / 0.12);
--on-water: #0e1819;
--shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 28px rgb(0 0 0 / 0.28);
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.4), 0 18px 48px rgb(0 0 0 / 0.5);
--veil: rgb(0 0 0 / 0.5);
color-scheme: dark;
}
:root {
--font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif;
--r-card: 16px;
--r-input: 12px;
--r-pill: 20px;
--r-icon: 14px;
--sidebar: 15.5rem;
--panel: 27rem;
--measure: 36rem;
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font-ui);
font-size: 1rem;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background-color 0.2s ease, color 0.2s ease;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
line-height: 1.2;
}
p {
margin: 0;
}
a {
color: var(--water);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--water);
outline-offset: 3px;
border-radius: 6px;
}
::selection {
background: var(--water-light);
color: var(--ink);
}
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Buttons — native, custom. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 2.4rem;
padding: 0.5rem 1.1rem;
border: 0;
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink);
font-weight: 650;
font-size: 0.95rem;
text-decoration: none;
transition: transform 0.1s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn.primary {
background: var(--water);
color: var(--on-water);
box-shadow: 0 6px 18px rgb(31 111 120 / 0.22);
}
.btn.quiet {
background: transparent;
color: var(--ink-soft);
padding-inline: 0.6rem;
}
.btn.quiet:hover {
background: var(--water-wash);
color: var(--ink);
}
.btn.small {
min-height: 2.25rem;
padding: 0.3rem 0.8rem;
font-size: 0.88rem;
}
/* Fields — native, custom. */
.field {
display: grid;
gap: 0.35rem;
}
.field > span {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
}
.input,
.textarea,
.select {
width: 100%;
border: 0;
border-radius: var(--r-input);
background: var(--surface-2);
padding: 0.6rem 0.8rem;
min-height: 2.4rem;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.06);
}
.textarea {
resize: vertical;
font-family: var(--font-text);
font-size: 1.02rem;
line-height: 1.65;
min-height: 9rem;
}
.input:focus-visible,
.textarea:focus-visible,
.select:focus-visible {
outline-offset: 1px;
}
.card {
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--shadow);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.55rem;
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink-soft);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.tag.water {
background: var(--water-wash);
color: var(--water);
}
.tag.moss {
background: var(--moss-wash);
color: var(--moss);
}
.tag.ochre {
background: var(--ochre-wash);
color: var(--ochre);
}
.eyebrow {
font-family: var(--font-ui);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--water);
}
.muted {
color: var(--ink-soft);
}
.faint {
color: var(--ink-faint);
}
/* The texts of reference. */
.prose {
font-family: var(--font-text);
font-size: clamp(1.04rem, 0.98rem + 0.25vw, 1.14rem);
line-height: 1.72;
font-variation-settings: 'opsz' 14;
hyphens: auto;
}
.prose p + p,
.prose p + ul,
.prose ul + p {
margin-top: 0.75em;
}
.prose ul,
.prose ol {
margin: 0.5em 0 0;
padding-left: 1.2em;
}
.prose li + li {
margin-top: 0.25em;
}
.prose strong {
font-weight: 650;
}
.prose code {
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
font-size: 0.86em;
background: var(--surface-2);
padding: 0.05em 0.35em;
border-radius: 6px;
}
.diff-added {
background: var(--moss-wash);
color: var(--ink);
text-decoration: none;
border-radius: 4px;
}
.diff-removed {
background: var(--ochre-wash);
color: var(--ink-soft);
text-decoration: line-through;
text-decoration-thickness: 1px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+7
View File
@@ -0,0 +1,7 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
const __LD_BUILD__: string;
namespace App {}
}
export {};
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<title>libreDecision</title>
<script>
// Mood before first paint: no flash.
try {
const m = localStorage.getItem('ld:mood');
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre';
} catch { document.documentElement.dataset.mood = 'pierre'; }
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
// The one door to the server. Mutations carry the anti-CSRF header the server
// requires; errors come back as a stable code the interface translates.
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
) {
super(code);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'x-requested-with': 'libredecision' };
if (body !== undefined) headers['content-type'] = 'application/json';
let res: Response;
try {
res = await fetch(`/api${path}`, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(0, 'network');
}
const data = await res.json().catch(() => null);
if (!res.ok) throw new ApiError(res.status, (data && typeof data.error === 'string' && data.error) || 'internal');
return data as T;
}
export const api = {
get: <T>(path: string) => call<T>('GET', path),
post: <T>(path: string, body: unknown = {}) => call<T>('POST', path, body),
patch: <T>(path: string, body: unknown) => call<T>('PATCH', path, body),
put: <T>(path: string, body: unknown) => call<T>('PUT', path, body),
del: <T>(path: string) => call<T>('DELETE', path),
};
+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>
+116
View File
@@ -0,0 +1,116 @@
// State of the reader: the document, the view (in force / previews), the
// language the text is read in, and the panel (which unit, which version,
// which tab) — the panel lives in the URL so a link reopens it.
import { api, ApiError } from './api';
import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all';
export type Tab = 'vote' | 'proposals' | 'propose';
export interface PanelState {
unitId: string | null;
proposalId: string | null;
tab: Tab;
draftId?: string | null;
prefill?: { title: string; body: string } | null;
}
export interface Shown {
title: string;
body: string;
/** The text is a translation (editorial). */
translated: boolean;
stale: boolean;
/** Asked for a translation that does not exist yet: the source is shown. */
missing: boolean;
}
export function shown(
v: { title: string; body: string; translations: Partial<Record<Lang, Translation>> },
lang: Lang,
source: Lang,
): Shown {
if (lang === source) return { title: v.title, body: v.body, translated: false, stale: false, missing: false };
const tr = v.translations[lang];
if (!tr) return { title: v.title, body: v.body, translated: false, stale: false, missing: true };
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
}
export class DocState {
view = $state<DocView | null>(null);
error = $state<string | null>(null);
mode = $state<View>('inforce');
preview = $state<PreviewUnit[] | null>(null);
original = $state(false);
async load(group: string, slug: string) {
try {
const g = await api.get<GroupView>(`/groups/${group}`);
const d = g.docs.find((x) => x.slug === slug);
if (!d) {
this.error = g.group.open ? 'not_found' : 'forbidden';
return;
}
this.view = await api.get<DocView>(`/docs/${d.id}`);
this.error = null;
if (this.mode !== 'inforce') await this.loadPreview();
} catch (e) {
this.error = e instanceof ApiError ? e.code : 'internal';
}
}
async reload() {
if (!this.view) return;
this.view = await api.get<DocView>(`/docs/${this.view.doc.id}`);
if (this.mode !== 'inforce') await this.loadPreview();
}
async setMode(mode: View) {
this.mode = mode;
if (mode === 'inforce') this.preview = null;
else await this.loadPreview();
}
async loadPreview() {
if (!this.view) return;
const r = await api.get<{ units: PreviewUnit[] }>(`/docs/${this.view.doc.id}/preview?mode=${this.mode}`);
this.preview = r.units;
}
unit(id: string | null): Unit | undefined {
return this.view?.units.find((u) => u.id === id);
}
proposal(id: string | null): Proposal | undefined {
if (!id || !this.view) return undefined;
for (const u of this.view.units) {
if (u.inForce?.id === id) return u.inForce;
const p = u.proposals.find((x) => x.id === id);
if (p) return p;
}
return undefined;
}
get inForce(): Unit[] {
return (this.view?.units ?? []).filter((u) => u.inForce);
}
/** Open proposals, in document order; drafts apart. */
get open(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.view?.units ?? []) {
for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal });
}
return out;
}
get drafts(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.view?.units ?? []) {
for (const proposal of unit.proposals) if (proposal.status === 'draft') out.push({ unit, proposal });
}
return out;
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { LangMap } from './types';
export interface GroupView {
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
tools: { key: string; active: boolean }[];
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
}
+315
View File
@@ -0,0 +1,315 @@
import type { Messages } from './fr';
export const en: Messages = {
'app.name': 'libreDecision',
'app.skip': 'Skip to content',
'nav.label': 'Toolbox',
'nav.home': 'Home',
'nav.decisions': 'Decisions',
'nav.docs': 'Reference documents',
'nav.mandates': 'Mandates',
'nav.protocols': 'Protocols',
'nav.soon': 'coming soon',
'nav.groups': 'My groups',
'nav.access': 'Access',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Give feedback',
'nav.signin': 'Sign in',
'nav.signout': 'Sign out',
'nav.mood.toNight': 'Night mood',
'nav.mood.toStone': 'Stone mood',
'nav.lang': 'Language',
'nav.menu': 'Menu',
'nav.close': 'Close',
'home.title': 'Making collective decisions possible',
'home.subtitle': 'depending on the context, processes and methods, a toolbox',
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
'home.mandates': 'Create and revoke any kind of mandate.',
'home.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
'home.open': 'open',
'home.soon': 'coming soon',
'home.signin': 'Sign in with your Ğ1 key',
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
'home.continue': 'Open my groups',
'signin.title': 'Sign in',
'signin.g1': 'Sign in with Ğ1',
'signin.g1.how': "You'll be sent to connect.monnaie-libre.fr, the sign-in service of the Monnaie libre forum. There you approve the sign-in in Ğecko (link or QR code), then come back here. No password and no key is ever passed to us.",
'signin.g1.members': 'For members of the Ğ1 web of trust only.',
'signin.g1.pending': "Ğ1 sign-in is on its way: libreDecision's registration with connect.monnaie-libre.fr is in progress. Meanwhile, you get in with an invitation link.",
'signin.invite': 'Got an invitation link? Just open it: it signs you in.',
'signin.dev': 'Development sign-in',
'signin.dev.name': 'Name',
'signin.dev.admin': 'access administrator',
'signin.dev.go': 'Enter',
'signin.error.refus': 'The sign-in was declined on connect.monnaie-libre.fr.',
'signin.error.etat': 'The sign-in expired or came from another browser. Start again from this browser.',
'signin.error.incomplet': 'The response from connect.monnaie-libre.fr was incomplete. Start again.',
'signin.error.echange': "connect.monnaie-libre.fr isn't responding. Try again in a moment.",
'signin.error.interne': 'Sign-in failed on our side. Try again in a moment.',
'invite.title': 'Invitation',
'invite.working': 'Signing in…',
'invite.invalid': 'This invitation link is no longer valid: it has already been used or it has expired. Ask the person who sent it to you for a new link.',
'invite.missing': 'This link is incomplete. Open the whole invitation link, exactly as you received it.',
'groups.title': 'My groups',
'groups.same.key': 'Groups that use the same key',
'groups.locked': 'Members only',
'groups.locked.long': 'This group opens to its members. Ask a trustinWallet administrator for access.',
'groups.test': 'For testing',
'groups.open': 'Open',
'groups.empty': 'No group is visible yet.',
'group.tools': 'Tools',
'group.tool.soon': 'This tool is coming later.',
'group.docs.empty': 'No reference documents in this group yet.',
'docs.title': 'Reference documents',
'docs.inForce.one': '{n} article in force',
'docs.inForce.other': '{n} articles in force',
'docs.open.one': '{n} open proposal',
'docs.open.other': '{n} open proposals',
'doc.view.label': 'Read',
'doc.view.inforce': 'In force',
'doc.view.wellrated': 'If well-rated ones pass',
'doc.view.all': 'If everything passes',
'doc.view.wellrated.note': 'Preview: the text as it would be if the well-rated proposals came into force. Nothing is adopted.',
'doc.view.all.note': 'Preview: the text as it would be if all proposals came into force, even those with little support. Nothing is adopted.',
'doc.view.empty': 'No proposal changes the text in this preview.',
'doc.mark.added': 'added',
'doc.mark.replaced': 'changed',
'doc.mark.removed': 'removed',
'doc.lang.label': 'Text language',
'doc.lang.translation': 'Editorial translation, not voted on. The authoritative text is in French.',
'doc.lang.original': 'Read the original',
'doc.lang.stale': 'translation needs review',
'doc.lang.missing': 'not translated yet',
'doc.seed': 'seed a minima',
'doc.seed.long': 'Came into force without a vote, so as not to start from a blank page. It remains open for voting: 0 votes so far.',
'doc.confirmed': 'confirmed by vote',
'doc.adopted': 'adopted on {date}',
'doc.action.vote': 'Voting',
'doc.action.proposals': 'Proposals',
'doc.action.propose': 'Propose a change',
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
'doc.published': 'Published to the text repository',
'doc.publish.pending': 'Publication pending',
'deep.title': 'Open proposals',
'deep.note': 'Not in force yet. Each one moves up as soon as it reaches its threshold: at least {floor} votes in favor, then the majority its inertia requires.',
'deep.new': 'Propose a new article',
'deep.drafts': 'Your drafts',
'deep.drafts.note': 'Not submitted to vote. Only you can see them.',
'deep.rival': 'would replace “{title}”',
'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia: {level}',
'deep.blocked': 'blocked',
'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming',
'deep.wellrated': 'well-rated',
'deep.filter.all': 'All',
'deep.filter.wellrated': 'Well-rated',
'deep.filter.mine': 'Mine',
'deep.none': 'No proposals in this filter.',
'deep.variant': 'variant {label}',
'section.introduction': 'Introduction',
'section.fondamental': 'Fundamental commitments',
'section.technique': 'Technical commitments',
'section.conclusion': 'Conclusion',
'section.annexe': 'Annexes',
'section.formule': 'Voting formula',
'section.inertie': 'Inertia',
'section.ordonnancement': 'Ordering',
'tally.short': '{for} for · {against} against',
'tally.required': 'Threshold: {n} votes in favor',
'tally.missing.one': '{n} more vote in favor needed.',
'tally.missing.other': '{n} more votes in favor needed.',
'tally.reached': 'Threshold reached.',
'tally.level': 'Level: {pct}% of the threshold',
'inertia.low': 'low inertia',
'inertia.standard': 'standard inertia',
'inertia.high': 'high inertia',
'inertia.max': 'maximum inertia',
'inertia.level.low': 'low',
'inertia.level.standard': 'standard',
'inertia.level.high': 'high',
'inertia.level.max': 'maximum',
'panel.vote': 'Voting',
'panel.proposals': 'Proposals',
'panel.propose': 'Propose',
'panel.close': 'Close panel',
'panel.inforce': 'In force',
'panel.proposal': 'Proposal',
'panel.by': 'proposed by {name}',
'panel.seed.origin': 'text from the seed',
'panel.changes': 'View changes',
'panel.text': 'View text',
'vote.yours': 'Your vote',
'vote.for': 'For',
'vote.against': 'Against',
'vote.retract': 'Withdraw my vote',
'vote.saved': 'Vote saved.',
'vote.retracted': 'Vote withdrawn.',
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
'vote.reconfirm': 'Reconfirm: {choice}',
'vote.private': 'Nobody sees your vote: only the totals are visible.',
'vote.blocked': "A contradiction flag is holding it back: it can't come into force while the flag stands.",
'vote.entered': '“{title}” has come into force.',
'vote.entered.remove': '“{title}” has been removed from the text in force.',
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
'vote.closed': 'This version is no longer open for voting.',
'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
'inertia.change': 'Propose an inertia change',
'inertia.change.to': 'New inertia',
'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.',
'inertia.change.submit': 'Submit to vote',
'inertia.change.open': 'Inertia change in progress: to {level}',
'formula.title': 'The threshold formula',
'formula.intro': 'The number of votes in favor required, R, depends on turnout:',
'formula.W': 'W — electorate: {n} ({source})',
'formula.W.wot': 'members of the Ğ1 web of trust',
'formula.W.group': 'group members',
'formula.T': 'T — votes cast: {n}',
'formula.M': 'M — majority at full turnout: {n}%',
'formula.G': 'G — gradient: {n}',
'formula.B': 'B — base: {n}',
'formula.C': 'C — minimum votes in favor: {n}',
'formula.R': 'R — current threshold: {n} votes in favor',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.',
'history.title': 'Corrections',
'history.initial': 'initial text',
'history.typo': 'typo fixed',
'history.substantive': 'substantive change (votes to reconfirm)',
'history.by': 'by {name}',
'typo.title': 'Fix a typo',
'typo.note': 'Accents, punctuation, typos: the fix applies without a vote, with its author and its diff. A change in meaning goes through a proposal.',
'typo.save': 'Save the fix',
'typo.done': 'Typo fixed, votes untouched.',
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
'typo.asProposal': 'Make it a proposal',
'signal.title': 'Contradictions',
'signal.none': 'No flags.',
'signal.add': 'Flag a contradiction',
'signal.target': 'Contradicts',
'signal.reason': 'How these two texts contradict each other',
'signal.send': 'Flag',
'signal.sent': 'Flag saved.',
'signal.note': "While a flag stands, these two texts can't be in force together. Its author lifts it; a substantive change makes it lapse.",
'signal.lift': 'Lift',
'signal.lifted': 'lifted',
'signal.lifted.revision': 'lapsed: the text changed',
'signal.by': 'by {name}',
'propose.mode.replace': 'Change this text',
'propose.mode.remove': 'Remove this article',
'propose.mode.new': 'New article',
'propose.after': 'Place after',
'propose.after.end': 'at the end',
'propose.title': 'Title',
'propose.body': 'Text',
'propose.remove.note': 'A removal proposal is voted on like any other; if it passes, the article leaves the text in force.',
'propose.draft': 'Save draft',
'propose.submit': 'Submit to vote',
'propose.drafted': 'Draft saved.',
'propose.submitted': 'Proposal submitted to vote.',
'propose.rule': "While it's a draft, you edit it freely. Once it's submitted, a typo can be fixed without touching the votes; a substantive change asks voters to reconfirm.",
'propose.edit': 'Edit',
'propose.withdraw': 'Withdraw my proposal',
'propose.withdrawn': 'Proposal withdrawn.',
'propose.saved.typo': 'Fix saved, the votes stand.',
'propose.saved.substantive': 'New version saved: voters will have to reconfirm.',
'assist.title': 'Assistant',
'assist.quiet': 'Nothing to report: one proposal, one decision.',
'assist.split': 'This text seems to carry {n} decisions. One proposal = one decision: split it?',
'assist.split.do': 'Split into {n} drafts',
'assist.split.done': 'Split into {n} drafts.',
'assist.similar': 'Close to “{title}”{where}.',
'assist.similar.inForce': ' (in force)',
'assist.similar.variant': 'Make it a variant of this article',
'assist.long': 'Long text ({n} characters): a short article is easier to vote on.',
'translate.title': 'Translate ({lang})',
'translate.save': 'Save translation',
'translate.saved': 'Translation saved.',
'translate.note': 'Translations are not voted on. They switch to “needs review” when the French text changes in substance.',
'feedback.title': 'Give feedback',
'feedback.prompt': 'What you did, what you expected, what happened.',
'feedback.send': 'Send feedback',
'feedback.sent': 'Feedback sent. Thank you.',
'feedback.context': 'The page address, language and screen size are attached.',
'access.title': 'trustinWallet — access',
'access.intro': 'Who opens which group. Monnaie libre opens by itself to members of the Ğ1 web of trust; other groups open one by one.',
'access.groups': 'Groups',
'access.members.none': 'No members granted access.',
'access.members.wot': '+ all members of the Ğ1 web of trust',
'access.grant': 'Grant',
'access.revoke': 'Remove',
'access.choose': 'Choose an identity',
'access.invitations': 'Invitations',
'access.invite.label': "Who it's for",
'access.invite.groups': 'Groups opened by the link',
'access.invite.admin': 'access administrator',
'access.invite.create': 'Create invitation link',
'access.invite.link': "Invitation link, single use, valid for 14 days. It won't be shown again: copy it now.",
'access.invite.copy': 'Copy link',
'access.invite.copied': 'Link copied.',
'access.invite.cancel': 'Cancel',
'access.invite.pending': 'Pending',
'access.identities': 'Identities',
'access.admin': 'administrator',
'access.admin.give': 'Make administrator',
'access.admin.take': 'Remove role',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
'access.member': 'web of trust member',
'access.feedback': 'Feedback',
'access.feedback.none': 'No feedback.',
'access.feedback.seen': 'Seen',
'access.feedback.done': 'Handled',
'access.denied': 'Access management is for trustinWallet administrators only.',
'soon.title': 'Coming soon',
'soon.body': 'This tool is coming later. Tools open one at a time: reference documents first.',
'error.unauthenticated': 'Sign in to continue.',
'error.forbidden': "You don't have access to this page.",
'error.not_found': "This page doesn't exist, or isn't visible to you.",
'error.network': "The server isn't responding. Check your connection and try again.",
'error.internal': 'Something failed on our side. Try again in a moment.',
'error.title': 'The title is empty or too long (120 characters max).',
'error.body': 'The text is empty or too long (4,000 characters max).',
'error.reason': 'Say in a few words how these texts contradict each other (10 to 600 characters).',
'error.substantive_not_author': "This edit affects the meaning: only the proposal's author can make it. Propose your own version.",
'error.substantive_in_force': 'This edit affects the meaning: propose it as a change.',
'error.same_unit': "These two texts target the same article: they can't be in force together anyway.",
'error.last_admin': 'At least one administrator must remain.',
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
'error.invitation_invalid': 'This invitation link is no longer valid.',
'error.retry': 'Try again',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.save': 'Save',
'common.loading': 'Loading…',
'common.back': 'Back',
};
+315
View File
@@ -0,0 +1,315 @@
import type { Messages } from './fr';
export const es: Messages = {
'app.name': 'libreDecision',
'app.skip': 'Ir al contenido',
'nav.label': 'Caja de herramientas',
'nav.home': 'Inicio',
'nav.decisions': 'Decisiones',
'nav.docs': 'Documentos de referencia',
'nav.mandates': 'Mandatos',
'nav.protocols': 'Protocolos',
'nav.soon': 'próximamente',
'nav.groups': 'Mis grupos',
'nav.access': 'Accesos',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Dejar un comentario',
'nav.signin': 'Iniciar sesión',
'nav.signout': 'Cerrar sesión',
'nav.mood.toNight': 'Ambiente noche',
'nav.mood.toStone': 'Ambiente piedra',
'nav.lang': 'Idioma',
'nav.menu': 'Menú',
'nav.close': 'Cerrar',
'home.title': 'Hacer posible la decisión colectiva',
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas',
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
'home.mandates': 'Crear y revocar todo tipo de mandato.',
'home.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
'home.open': 'abierto',
'home.soon': 'próximamente',
'home.signin': 'Iniciar sesión con tu clave Ğ1',
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
'home.continue': 'Abrir mis grupos',
'signin.title': 'Iniciar sesión',
'signin.g1': 'Iniciar sesión con Ğ1',
'signin.g1.how': 'Se te redirige a connect.monnaie-libre.fr, el servicio de conexión del foro Monnaie libre. Allí apruebas la conexión en Ğecko (enlace o código QR) y luego vuelves aquí. No se nos transmite ninguna contraseña ni ninguna clave.',
'signin.g1.members': 'Reservado a los miembros de la red de confianza Ğ1.',
'signin.g1.pending': 'La conexión Ğ1 llega pronto: el registro de libreDecision en connect.monnaie-libre.fr está en curso. Mientras tanto, se entra con un enlace de invitación.',
'signin.invite': '¿Has recibido un enlace de invitación? Ábrelo sin más: te conecta.',
'signin.dev': 'Conexión de desarrollo',
'signin.dev.name': 'Nombre',
'signin.dev.admin': 'administrador de accesos',
'signin.dev.go': 'Entrar',
'signin.error.refus': 'La conexión fue rechazada en connect.monnaie-libre.fr.',
'signin.error.etat': 'La conexión ha caducado o viene de otro navegador. Vuelve a empezar desde este navegador.',
'signin.error.incomplet': 'La respuesta de connect.monnaie-libre.fr llegó incompleta. Vuelve a empezar.',
'signin.error.echange': 'connect.monnaie-libre.fr no responde. Inténtalo de nuevo en un momento.',
'signin.error.interne': 'La conexión ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
'invite.title': 'Invitación',
'invite.working': 'Conectando…',
'invite.invalid': 'Este enlace de invitación ya no es válido: ya se ha usado o ha caducado. Pide un enlace nuevo a la persona que te lo envió.',
'invite.missing': 'Este enlace está incompleto. Abre el enlace de invitación completo, tal como lo recibiste.',
'groups.title': 'Mis grupos',
'groups.same.key': 'Grupos que usan la misma clave',
'groups.locked': 'Reservado a sus miembros',
'groups.locked.long': 'Este grupo se abre a sus miembros. El acceso se pide a un administrador de trustinWallet.',
'groups.test': 'Para pruebas',
'groups.open': 'Abrir',
'groups.empty': 'Por ahora no hay ningún grupo visible.',
'group.tools': 'Herramientas',
'group.tool.soon': 'Esta herramienta llegará más adelante.',
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.',
'docs.title': 'Documentos de referencia',
'docs.inForce.one': '{n} artículo en vigor',
'docs.inForce.other': '{n} artículos en vigor',
'docs.open.one': '{n} propuesta en curso',
'docs.open.other': '{n} propuestas en curso',
'doc.view.label': 'Leer',
'doc.view.inforce': 'En vigor',
'doc.view.wellrated': 'Si pasan las bien valoradas',
'doc.view.all': 'Si todo pasa',
'doc.view.wellrated.note': 'Vista previa: el texto tal como quedaría si las propuestas bien valoradas entraran en vigor. No se adopta nada.',
'doc.view.all.note': 'Vista previa: el texto tal como quedaría si todas las propuestas entraran en vigor, incluso las poco apoyadas. No se adopta nada.',
'doc.view.empty': 'Ninguna propuesta cambia el texto en esta vista previa.',
'doc.mark.added': 'añadido',
'doc.mark.replaced': 'modificado',
'doc.mark.removed': 'retirado',
'doc.lang.label': 'Idioma del texto',
'doc.lang.translation': 'Traducción editorial, sin votación. El texto que hace fe está en francés.',
'doc.lang.original': 'Leer el original',
'doc.lang.stale': 'traducción por revisar',
'doc.lang.missing': 'aún sin traducir',
'doc.seed': 'seed a minima',
'doc.seed.long': 'Entró en vigor sin votación, para no partir de una página en blanco. Sigue en votación: 0 votos por ahora.',
'doc.confirmed': 'confirmado por la votación',
'doc.adopted': 'adoptado el {date}',
'doc.action.vote': 'Votación',
'doc.action.proposals': 'Propuestas',
'doc.action.propose': 'Proponer una modificación',
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
'doc.published': 'Publicado en el repositorio de textos',
'doc.publish.pending': 'Publicación pendiente',
'deep.title': 'Propuestas en curso',
'deep.note': 'Aún no están en vigor. Cada una sube en cuanto alcanza su umbral: al menos {floor} votos a favor y, después, la mayoría que exige su inercia.',
'deep.new': 'Proponer un artículo nuevo',
'deep.drafts': 'Tus borradores',
'deep.drafts.note': 'No sometidos a votación. Solo tú los ves.',
'deep.rival': 'reemplazaría «{title}»',
'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia: {level}',
'deep.blocked': 'bloqueada',
'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar',
'deep.wellrated': 'bien valorada',
'deep.filter.all': 'Todas',
'deep.filter.wellrated': 'Bien valoradas',
'deep.filter.mine': 'Las mías',
'deep.none': 'Ninguna propuesta en este filtro.',
'deep.variant': 'variante {label}',
'section.introduction': 'Introducción',
'section.fondamental': 'Compromisos fundamentales',
'section.technique': 'Compromisos técnicos',
'section.conclusion': 'Conclusión',
'section.annexe': 'Anexos',
'section.formule': 'Fórmula de votación',
'section.inertie': 'Inercia',
'section.ordonnancement': 'Ordenación',
'tally.short': '{for} a favor · {against} en contra',
'tally.required': 'Umbral: {n} votos a favor',
'tally.missing.one': 'Falta {n} voto a favor.',
'tally.missing.other': 'Faltan {n} votos a favor.',
'tally.reached': 'Umbral alcanzado.',
'tally.level': 'Nivel: {pct} % del umbral',
'inertia.low': 'inercia baja',
'inertia.standard': 'inercia estándar',
'inertia.high': 'inercia alta',
'inertia.max': 'inercia máxima',
'inertia.level.low': 'baja',
'inertia.level.standard': 'estándar',
'inertia.level.high': 'alta',
'inertia.level.max': 'máxima',
'panel.vote': 'Votación',
'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer',
'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta',
'panel.by': 'propuesto por {name}',
'panel.seed.origin': 'texto procedente del seed',
'panel.changes': 'Ver los cambios',
'panel.text': 'Ver el texto',
'vote.yours': 'Tu voto',
'vote.for': 'A favor',
'vote.against': 'En contra',
'vote.retract': 'Retirar mi voto',
'vote.saved': 'Voto registrado.',
'vote.retracted': 'Voto retirado.',
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
'vote.reconfirm': 'Reconfirmar: {choice}',
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
'vote.blocked': 'Un aviso de contradicción la retiene: no puede entrar en vigor mientras se mantenga.',
'vote.entered': '«{title}» ha entrado en vigor.',
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
'vote.closed': 'Esta versión ya no está en votación.',
'inertia.title': 'Inercia',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
'inertia.change': 'Proponer un cambio de inercia',
'inertia.change.to': 'Nueva inercia',
'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.',
'inertia.change.submit': 'Someter a votación',
'inertia.change.open': 'Cambio de inercia en curso: a {level}',
'formula.title': 'La fórmula del umbral',
'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:',
'formula.W': 'W — electorado: {n} ({source})',
'formula.W.wot': 'miembros de la red de confianza Ğ1',
'formula.W.group': 'miembros del grupo',
'formula.T': 'T — votos emitidos: {n}',
'formula.M': 'M — mayoría con participación plena: {n} %',
'formula.G': 'G — gradiente: {n}',
'formula.B': 'B — base: {n}',
'formula.C': 'C — mínimo de votos a favor: {n}',
'formula.R': 'R — umbral actual: {n} votos a favor',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.',
'history.title': 'Correcciones',
'history.initial': 'texto inicial',
'history.typo': 'errata corregida',
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
'history.by': 'por {name}',
'typo.title': 'Corregir una errata',
'typo.note': 'Acentos, puntuación, erratas: la corrección se aplica sin votación, con su autor y su diff. Un cambio de sentido pasa por una propuesta.',
'typo.save': 'Guardar la corrección',
'typo.done': 'Errata corregida, sin tocar los votos.',
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
'typo.asProposal': 'Convertirlo en propuesta',
'signal.title': 'Contradicciones',
'signal.none': 'Ningún aviso.',
'signal.add': 'Señalar una contradicción',
'signal.target': 'Contradice',
'signal.reason': 'En qué se contradicen estos dos textos',
'signal.send': 'Señalar',
'signal.sent': 'Aviso registrado.',
'signal.note': 'Mientras un aviso se mantenga, estos dos textos no pueden estar en vigor a la vez. Su autor lo levanta; un cambio de fondo lo deja sin efecto.',
'signal.lift': 'Levantar',
'signal.lifted': 'levantado',
'signal.lifted.revision': 'sin efecto: el texto ha cambiado',
'signal.by': 'por {name}',
'propose.mode.replace': 'Modificar este texto',
'propose.mode.remove': 'Retirar este artículo',
'propose.mode.new': 'Artículo nuevo',
'propose.after': 'Colocar después de',
'propose.after.end': 'al final',
'propose.title': 'Título',
'propose.body': 'Texto',
'propose.remove.note': 'La propuesta de retirada se vota como cualquier otra; si se aprueba, el artículo sale del texto en vigor.',
'propose.draft': 'Guardar el borrador',
'propose.submit': 'Someter a votación',
'propose.drafted': 'Borrador guardado.',
'propose.submitted': 'Propuesta sometida a votación.',
'propose.rule': 'Mientras sea un borrador, lo modificas libremente. Una vez sometido, una errata se corrige sin tocar los votos; un cambio de fondo pide a los votantes que reconfirmen.',
'propose.edit': 'Modificar',
'propose.withdraw': 'Retirar mi propuesta',
'propose.withdrawn': 'Propuesta retirada.',
'propose.saved.typo': 'Corrección guardada, los votos se mantienen.',
'propose.saved.substantive': 'Nueva versión guardada: los votantes tendrán que reconfirmar.',
'assist.title': 'Asistente',
'assist.quiet': 'Todo en orden: una propuesta, una decisión.',
'assist.split': 'Este texto parece contener {n} decisiones. Una propuesta = una decisión: ¿dividirlo?',
'assist.split.do': 'Dividir en {n} borradores',
'assist.split.done': 'Dividido en {n} borradores.',
'assist.similar': 'Parecido a «{title}»{where}.',
'assist.similar.inForce': ' (en vigor)',
'assist.similar.variant': 'Convertirlo en una variante de este artículo',
'assist.long': 'Texto largo ({n} caracteres): un artículo corto se vota mejor.',
'translate.title': 'Traducir ({lang})',
'translate.save': 'Guardar la traducción',
'translate.saved': 'Traducción guardada.',
'translate.note': 'Las traducciones no se votan. Pasan a «por revisar» cuando el texto francés cambia de fondo.',
'feedback.title': 'Dejar un comentario',
'feedback.prompt': 'Lo que hiciste, lo que esperabas, lo que pasó.',
'feedback.send': 'Enviar el comentario',
'feedback.sent': 'Comentario enviado. Gracias.',
'feedback.context': 'Se adjuntan la dirección de la página, el idioma y el tamaño de la pantalla.',
'access.title': 'trustinWallet — accesos',
'access.intro': 'Quién abre qué grupo. Monnaie libre se abre por sí solo a los miembros de la red de confianza Ğ1; los demás grupos se abren uno a uno.',
'access.groups': 'Grupos',
'access.members.none': 'Ningún miembro tiene acceso.',
'access.members.wot': '+ todos los miembros de la red de confianza Ğ1',
'access.grant': 'Conceder',
'access.revoke': 'Retirar',
'access.choose': 'Elegir una identidad',
'access.invitations': 'Invitaciones',
'access.invite.label': 'Para quién',
'access.invite.groups': 'Grupos que abre el enlace',
'access.invite.admin': 'administrador de accesos',
'access.invite.create': 'Crear el enlace de invitación',
'access.invite.link': 'Enlace de invitación, de un solo uso, válido 14 días. No volverá a mostrarse: cópialo ahora.',
'access.invite.copy': 'Copiar el enlace',
'access.invite.copied': 'Enlace copiado.',
'access.invite.cancel': 'Cancelar',
'access.invite.pending': 'Pendiente',
'access.identities': 'Identidades',
'access.admin': 'administrador',
'access.admin.give': 'Nombrar administrador',
'access.admin.take': 'Retirar el rol',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitación',
'access.kind.dev': 'dev',
'access.member': 'miembro de la red de confianza',
'access.feedback': 'Comentarios',
'access.feedback.none': 'Ningún comentario.',
'access.feedback.seen': 'Visto',
'access.feedback.done': 'Atendido',
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
'soon.title': 'Próximamente',
'soon.body': 'Esta herramienta llegará más adelante. Las herramientas se abren una a una: primero, los documentos de referencia.',
'error.unauthenticated': 'Inicia sesión para continuar.',
'error.forbidden': 'No tienes acceso a esta página.',
'error.not_found': 'Esta página no existe, o no es visible para ti.',
'error.network': 'El servidor no responde. Comprueba tu conexión e inténtalo de nuevo.',
'error.internal': 'Algo ha fallado por nuestra parte. Inténtalo de nuevo en un momento.',
'error.title': 'El título está vacío o es demasiado largo (120 caracteres como máximo).',
'error.body': 'El texto está vacío o es demasiado largo (4000 caracteres como máximo).',
'error.reason': 'Di en pocas palabras en qué se contradicen estos textos (de 10 a 600 caracteres).',
'error.substantive_not_author': 'Este cambio afecta al sentido: solo el autor de la propuesta puede hacerlo. Propón tu propia versión.',
'error.substantive_in_force': 'Este cambio afecta al sentido: proponlo como modificación.',
'error.same_unit': 'Estos dos textos se refieren al mismo artículo: de todos modos no pueden estar en vigor a la vez.',
'error.last_admin': 'Debe quedar al menos un administrador.',
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
'error.retry': 'Reintentar',
'common.cancel': 'Cancelar',
'common.close': 'Cerrar',
'common.save': 'Guardar',
'common.loading': 'Cargando…',
'common.back': 'Volver',
};
+321
View File
@@ -0,0 +1,321 @@
// Source unique des libellés de l'interface — en français d'abord.
// Ton : on tutoie la personne ; le sujet est la personne ou le collectif,
// jamais la formule ni le système. Les clés en/es doivent être identiques
// (vérifié à la compilation). Termes interdits : tests/unit/lexicon.test.ts.
export const fr = {
'app.name': 'libreDecision',
'app.skip': 'Aller au contenu',
'nav.label': 'Boîte à outils',
'nav.home': 'Accueil',
'nav.decisions': 'Décisions',
'nav.docs': 'Docs de référence',
'nav.mandates': 'Mandats',
'nav.protocols': 'Protocoles',
'nav.soon': 'à venir',
'nav.groups': 'Mes groupes',
'nav.access': 'Accès',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Faire un retour',
'nav.signin': 'Se connecter',
'nav.signout': 'Se déconnecter',
'nav.mood.toNight': 'Ambiance nuit',
'nav.mood.toStone': 'Ambiance pierre',
'nav.lang': 'Langue',
'nav.menu': 'Menu',
'nav.close': 'Fermer',
'home.title': 'Rendre possible la décision collective',
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils',
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
'home.mandates': 'Créer et révoquer tout type de mandat.',
'home.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
'home.open': 'ouvert',
'home.soon': 'à venir',
'home.signin': 'Se connecter avec sa clé Ğ1',
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
'home.continue': 'Ouvrir mes groupes',
'signin.title': 'Se connecter',
'signin.g1': 'Se connecter avec Ğ1',
'signin.g1.how': "Tu es envoyé sur connect.monnaie-libre.fr, le service de connexion du forum Monnaie libre. Tu y approuves la connexion dans Ğecko (lien ou QR code), puis tu reviens ici. Aucun mot de passe, aucune clé ne nous est transmise.",
'signin.g1.members': 'Réservé aux membres de la toile de confiance Ğ1.',
'signin.g1.pending': "La connexion Ğ1 arrive : l'inscription de libreDecision auprès de connect.monnaie-libre.fr est en cours. En attendant, on entre avec un lien d'invitation.",
'signin.invite': "Tu as reçu un lien d'invitation ? Ouvre-le simplement : il te connecte.",
'signin.dev': 'Connexion de développement',
'signin.dev.name': 'Nom',
'signin.dev.admin': 'administrateur des accès',
'signin.dev.go': 'Entrer',
'signin.error.refus': 'La connexion a été refusée sur connect.monnaie-libre.fr.',
'signin.error.etat': 'La connexion a expiré ou vient d’un autre navigateur. Recommence depuis ce navigateur.',
'signin.error.incomplet': 'Le retour de connect.monnaie-libre.fr était incomplet. Recommence.',
'signin.error.echange': 'connect.monnaie-libre.fr ne répond pas. Réessaie dans un moment.',
'signin.error.interne': 'La connexion a échoué de notre côté. Réessaie dans un moment.',
'invite.title': 'Invitation',
'invite.working': 'Connexion en cours…',
'invite.invalid': "Ce lien d'invitation n'est plus valable : il a déjà servi ou il a expiré. Demande un nouveau lien à la personne qui te l'a envoyé.",
'invite.missing': "Ce lien est incomplet. Ouvre le lien d'invitation entier, tel que tu l'as reçu.",
'groups.title': 'Mes groupes',
'groups.same.key': 'Groupes qui utilisent la même clé',
'groups.locked': 'Réservé à ses membres',
'groups.locked.long': "Ce groupe s'ouvre à ses membres. L'accès se demande à un administrateur de trustinWallet.",
'groups.test': 'Pour les essais',
'groups.open': 'Ouvrir',
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
'group.tools': 'Outils',
'group.tool.soon': 'Cet outil arrive plus tard.',
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.",
'docs.title': 'Docs de référence',
'docs.inForce.one': '{n} article en vigueur',
'docs.inForce.other': '{n} articles en vigueur',
'docs.open.one': '{n} proposition en cours',
'docs.open.other': '{n} propositions en cours',
'doc.view.label': 'Lire',
'doc.view.inforce': 'En vigueur',
'doc.view.wellrated': 'Si les bien cotées passent',
'doc.view.all': 'Si tout passe',
'doc.view.wellrated.note': "Aperçu : le texte tel qu'il serait si les propositions bien cotées entraient en vigueur. Rien n'est adopté.",
'doc.view.all.note': "Aperçu : le texte tel qu'il serait si toutes les propositions entraient en vigueur, même peu soutenues. Rien n'est adopté.",
'doc.view.empty': 'Aucune proposition ne change le texte dans cet aperçu.',
'doc.mark.added': 'ajouté',
'doc.mark.replaced': 'modifié',
'doc.mark.removed': 'retiré',
'doc.lang.label': 'Langue du texte',
'doc.lang.translation': 'Traduction éditoriale, sans vote. Le texte qui fait foi est en français.',
'doc.lang.original': "Lire l'original",
'doc.lang.stale': 'traduction à revoir',
'doc.lang.missing': 'pas encore traduit',
'doc.seed': 'seed a minima',
'doc.seed.long': "Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote : 0 vote pour l'instant.",
'doc.confirmed': 'confirmé par le vote',
'doc.adopted': 'adopté le {date}',
'doc.action.vote': 'Le vote',
'doc.action.proposals': 'Propositions',
'doc.action.propose': 'Proposer une modification',
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
'doc.published': 'Publié sur le dépôt des textes',
'doc.publish.pending': 'Publication en attente',
'deep.title': 'Propositions en cours',
'deep.note': "Pas encore en vigueur. Chacune remonte dès qu'elle atteint son seuil : au moins {floor} voix pour, puis la majorité que demande son inertie.",
'deep.new': 'Proposer un nouvel article',
'deep.drafts': 'Tes brouillons',
'deep.drafts.note': 'Non soumis au vote. Toi seul les vois.',
'deep.rival': 'remplacerait « {title} »',
'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie : {level}',
'deep.blocked': 'bloquée',
'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer',
'deep.wellrated': 'bien cotée',
'deep.filter.all': 'Toutes',
'deep.filter.wellrated': 'Bien cotées',
'deep.filter.mine': 'Les miennes',
'deep.none': 'Aucune proposition dans ce filtre.',
'deep.variant': 'variante {label}',
'section.introduction': 'Introduction',
'section.fondamental': 'Engagements fondamentaux',
'section.technique': 'Engagements techniques',
'section.conclusion': 'Conclusion',
'section.annexe': 'Annexes',
'section.formule': 'Formule de vote',
'section.inertie': 'Inertie',
'section.ordonnancement': 'Ordonnancement',
'tally.short': '{for} pour · {against} contre',
'tally.required': 'Seuil : {n} voix pour',
'tally.missing.one': 'Il manque {n} voix pour.',
'tally.missing.other': 'Il manque {n} voix pour.',
'tally.reached': 'Seuil atteint.',
'tally.level': 'Niveau : {pct} % du seuil',
'inertia.low': 'inertie faible',
'inertia.standard': 'inertie standard',
'inertia.high': 'inertie haute',
'inertia.max': 'inertie maximale',
'inertia.level.low': 'faible',
'inertia.level.standard': 'standard',
'inertia.level.high': 'haute',
'inertia.level.max': 'maximale',
'panel.vote': 'Le vote',
'panel.proposals': 'Propositions',
'panel.propose': 'Proposer',
'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition',
'panel.by': 'proposé par {name}',
'panel.seed.origin': 'texte issu du seed',
'panel.changes': 'Voir les changements',
'panel.text': 'Voir le texte',
'vote.yours': 'Ton vote',
'vote.for': 'Pour',
'vote.against': 'Contre',
'vote.retract': 'Retirer mon vote',
'vote.saved': 'Vote enregistré.',
'vote.retracted': 'Vote retiré.',
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
'vote.reconfirm': 'Reconfirmer : {choice}',
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
'vote.blocked': "Un signalement de contradiction la retient : elle ne peut pas entrer en vigueur tant qu'il tient.",
'vote.entered': '« {title} » est entré en vigueur.',
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
'vote.closed': 'Cette version ne se vote plus.',
'inertia.title': 'Inertie',
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
'inertia.change': "Proposer un changement d'inertie",
'inertia.change.to': 'Nouvelle inertie',
'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.",
'inertia.change.submit': 'Soumettre au vote',
'inertia.change.open': "Changement d'inertie en cours : vers {level}",
'formula.title': 'La formule du seuil',
'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :',
'formula.W': "W — électorat : {n} ({source})",
'formula.W.wot': 'membres de la toile de confiance Ğ1',
'formula.W.group': 'membres du groupe',
'formula.T': 'T — voix exprimées : {n}',
'formula.M': 'M — majorité à pleine participation : {n} %',
'formula.G': 'G — gradient : {n}',
'formula.B': 'B — base : {n}',
'formula.C': 'C — minimum de voix pour : {n}',
'formula.R': 'R — seuil actuel : {n} voix pour',
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.",
'history.title': 'Corrections',
'history.initial': 'texte initial',
'history.typo': 'coquille corrigée',
'history.substantive': 'changement de fond (votes à reconfirmer)',
'history.by': 'par {name}',
'typo.title': 'Corriger une coquille',
'typo.note': "Accents, ponctuation, faute de frappe : la correction s'applique sans vote, avec son auteur et son diff. Un changement de sens passe par une proposition.",
'typo.save': 'Enregistrer la correction',
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
'typo.asProposal': 'En faire une proposition',
'signal.title': 'Contradictions',
'signal.none': 'Aucun signalement.',
'signal.add': 'Signaler une contradiction',
'signal.target': 'Contredit',
'signal.reason': 'En quoi ces deux textes se contredisent',
'signal.send': 'Signaler',
'signal.sent': 'Signalement enregistré.',
'signal.note': "Tant qu'un signalement tient, ces deux textes ne peuvent pas être en vigueur ensemble. Son auteur le lève ; un changement de fond le fait tomber.",
'signal.lift': 'Lever',
'signal.lifted': 'levé',
'signal.lifted.revision': 'tombé : le texte a changé',
'signal.by': 'par {name}',
'propose.mode.replace': 'Modifier ce texte',
'propose.mode.remove': 'Retirer cet article',
'propose.mode.new': 'Nouvel article',
'propose.after': 'Placer après',
'propose.after.end': 'à la fin',
'propose.title': 'Titre',
'propose.body': 'Texte',
'propose.remove.note': 'La proposition de retrait se vote comme une autre ; si elle passe, l’article sort du texte en vigueur.',
'propose.draft': 'Enregistrer le brouillon',
'propose.submit': 'Soumettre au vote',
'propose.drafted': 'Brouillon enregistré.',
'propose.submitted': 'Proposition soumise au vote.',
'propose.rule': "Tant que c'est un brouillon, tu le modifies librement. Une fois soumis, une coquille se corrige sans toucher aux votes ; un changement de fond demande aux votants de reconfirmer.",
'propose.edit': 'Modifier',
'propose.withdraw': 'Retirer ma proposition',
'propose.withdrawn': 'Proposition retirée.',
'propose.saved.typo': 'Correction enregistrée, les votes restent.',
'propose.saved.substantive': 'Nouvelle version enregistrée : les votants devront reconfirmer.',
'assist.title': 'Assistant',
'assist.quiet': 'Rien à signaler : une proposition, une décision.',
'assist.split': 'Ce texte semble porter {n} décisions. Une proposition = une décision : les découper ?',
'assist.split.do': 'Découper en {n} brouillons',
'assist.split.done': 'Découpé en {n} brouillons.',
'assist.similar': 'Proche de « {title} »{where}.',
'assist.similar.inForce': ' (en vigueur)',
'assist.similar.variant': 'En faire une variante de cet article',
'assist.long': 'Texte long ({n} signes) : un article court se vote mieux.',
'translate.title': 'Traduire ({lang})',
'translate.save': 'Enregistrer la traduction',
'translate.saved': 'Traduction enregistrée.',
'translate.note': 'Les traductions ne se votent pas. Elles passent « à revoir » quand le texte français change de fond.',
'feedback.title': 'Faire un retour',
'feedback.prompt': "Ce que tu as fait, ce que tu attendais, ce qui s'est passé.",
'feedback.send': 'Envoyer le retour',
'feedback.sent': 'Retour envoyé. Merci.',
'feedback.context': "L'adresse de la page, la langue et la taille de l'écran sont jointes.",
'access.title': 'trustinWallet — accès',
'access.intro': "Qui ouvre quel groupe. Monnaie libre s'ouvre de lui-même aux membres de la toile de confiance Ğ1 ; les autres groupes s'ouvrent un par un.",
'access.groups': 'Groupes',
'access.members.none': 'Aucun membre accordé.',
'access.members.wot': '+ tous les membres de la toile Ğ1',
'access.grant': 'Accorder',
'access.revoke': 'Retirer',
'access.choose': 'Choisir une identité',
'access.invitations': 'Invitations',
'access.invite.label': 'Pour qui',
'access.invite.groups': 'Groupes ouverts par le lien',
'access.invite.admin': 'administrateur des accès',
'access.invite.create': "Créer le lien d'invitation",
'access.invite.link': "Lien d'invitation, à usage unique, valable 14 jours. Il ne s'affichera plus : copie-le maintenant.",
'access.invite.copy': 'Copier le lien',
'access.invite.copied': 'Lien copié.',
'access.invite.cancel': 'Annuler',
'access.invite.pending': 'En attente',
'access.identities': 'Identités',
'access.admin': 'administrateur',
'access.admin.give': 'Rendre administrateur',
'access.admin.take': 'Retirer le rôle',
'access.kind.g1': 'Ğ1',
'access.kind.invite': 'invitation',
'access.kind.dev': 'dev',
'access.member': 'membre de la toile',
'access.feedback': 'Retours',
'access.feedback.none': 'Aucun retour.',
'access.feedback.seen': 'Vu',
'access.feedback.done': 'Traité',
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
'soon.title': 'À venir',
'soon.body': "Cet outil arrive plus tard. Les chantiers s'ouvrent un par un : d'abord les docs de référence.",
'error.unauthenticated': 'Connecte-toi pour continuer.',
'error.forbidden': "Tu n'as pas accès à cette page.",
'error.not_found': "Cette page n'existe pas, ou elle ne t'est pas visible.",
'error.network': 'Le serveur ne répond pas. Vérifie ta connexion et réessaie.',
'error.internal': 'Quelque chose a échoué de notre côté. Réessaie dans un moment.',
'error.title': 'Le titre est vide ou trop long (120 signes au plus).',
'error.body': 'Le texte est vide ou trop long (4 000 signes au plus).',
'error.reason': 'Dis en quelques mots en quoi ces textes se contredisent (10 à 600 signes).',
'error.substantive_not_author': "Ce changement touche au sens : seul l'auteur de la proposition peut le faire. Propose ta propre version.",
'error.substantive_in_force': 'Ce changement touche au sens : propose-le comme modification.',
'error.same_unit': "Ces deux textes visent le même article : ils ne peuvent de toute façon pas être en vigueur ensemble.",
'error.last_admin': 'Il faut garder au moins un administrateur.',
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
'error.retry': 'Réessayer',
'common.cancel': 'Annuler',
'common.close': 'Fermer',
'common.save': 'Enregistrer',
'common.loading': 'Chargement…',
'common.back': 'Retour',
} as const;
export type Key = keyof typeof fr;
export type Messages = Record<Key, string>;
+60
View File
@@ -0,0 +1,60 @@
// Interface language: fr, en, es (Yvv, 2026-10-21). French is the source.
import { en } from './en';
import { es } from './es';
import { fr, type Key, type Messages } from './fr';
import type { Lang } from '../types';
export const LANGS: Lang[] = ['fr', 'en', 'es'];
const CATALOGS: Record<Lang, Messages> = { fr, en, es };
const LOCALES: Record<Lang, string> = { fr: 'fr-FR', en: 'en-GB', es: 'es-ES' };
function initial(): Lang {
try {
const saved = localStorage.getItem('ld:lang');
if (saved === 'fr' || saved === 'en' || saved === 'es') return saved;
} catch {
/* no storage */
}
const nav = (typeof navigator !== 'undefined' ? navigator.language : 'fr').slice(0, 2);
return nav === 'en' || nav === 'es' ? nav : 'fr';
}
class I18n {
lang = $state<Lang>('fr');
init() {
this.set(initial());
}
set(lang: Lang) {
this.lang = lang;
document.documentElement.lang = lang;
try {
localStorage.setItem('ld:lang', lang);
} catch {
/* no storage */
}
}
t = (key: Key, params: Record<string, string | number> = {}): string => {
const raw = CATALOGS[this.lang][key] ?? fr[key] ?? key;
return raw.replace(/\{(\w+)\}/g, (_, k: string) => (k in params ? String(params[k]) : `{${k}}`));
};
/** `<key>.one` / `<key>.other` — French and Spanish use « one » for 0 and 1. */
plural = (key: string, n: number, params: Record<string, string | number> = {}): string => {
const one = this.lang === 'en' ? n === 1 : n <= 1;
return this.t(`${key}.${one ? 'one' : 'other'}` as Key, { n, ...params });
};
date = (iso: string | null | undefined): string => {
if (!iso) return '';
return new Date(iso).toLocaleDateString(LOCALES[this.lang], { day: 'numeric', month: 'long', year: 'numeric' });
};
number = (n: number): string => n.toLocaleString(LOCALES[this.lang]);
}
export const i18n = new I18n();
export const t = i18n.t;
export type { Key };
+18
View File
@@ -0,0 +1,18 @@
import { t } from './i18n/index.svelte';
import type { Proposal, Unit } from './types';
/** What a proposal is, in words, whatever its kind. */
export function titleOf(p: Proposal, unit: Unit | undefined): string {
if (p.kind === 'inertia' && p.inertiaTarget) {
return t('deep.inertia', { level: t(`inertia.level.${p.inertiaTarget}`) });
}
if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title });
return p.title;
}
/** Its relation to the text in force. */
export function relationOf(p: Proposal, unit: Unit | undefined): string {
if (p.kind !== 'text') return '';
if (unit?.inForce && unit.inForce.id !== p.id) return t('deep.rival', { title: unit.inForce.title });
return '';
}
+22
View File
@@ -0,0 +1,22 @@
import { ApiError } from './api';
/** A page's remote data: value, error code, reload. */
export class Remote<T> {
value = $state<T | null>(null);
error = $state<string | null>(null);
loading = $state(true);
constructor(private fetcher: () => Promise<T>) {}
async load() {
this.loading = true;
try {
this.value = await this.fetcher();
this.error = null;
} catch (e) {
this.error = e instanceof ApiError ? e.code : 'internal';
} finally {
this.loading = false;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// A deliberately tiny Markdown subset for the texts of reference: paragraphs,
// line breaks, lists, **bold**, *italic*, `code`. HTML is always escaped first,
// so a proposal can never inject markup.
const ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
}
export function inline(s: string): string {
return escapeHtml(s)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>');
}
const BULLET = /^\s*[-*]\s+/;
const NUMBERED = /^\s*\d+[.)]\s+/;
export function render(md: string): string {
const blocks = md.replace(/\r\n?/g, '\n').trim().split(/\n\s*\n/);
return blocks
.map((block) => {
const lines = block.split('\n').filter((l) => l.trim().length > 0);
if (lines.length > 0 && lines.every((l) => BULLET.test(l))) {
return `<ul>${lines.map((l) => `<li>${inline(l.replace(BULLET, ''))}</li>`).join('')}</ul>`;
}
if (lines.length > 0 && lines.every((l) => NUMBERED.test(l))) {
return `<ol>${lines.map((l) => `<li>${inline(l.replace(NUMBERED, ''))}</li>`).join('')}</ol>`;
}
return `<p>${lines.map(inline).join('<br>')}</p>`;
})
.join('');
}
/** Plain text, for one-line excerpts. */
export function plain(md: string): string {
return md.replace(/[*`]/g, '').replace(/^\s*[-*]\s+/gm, '').replace(/\s+/g, ' ').trim();
}
+23
View File
@@ -0,0 +1,23 @@
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
export type Mood = 'pierre' | 'nuit';
class MoodState {
current = $state<Mood>('pierre');
init() {
const m = document.documentElement.dataset.mood;
this.current = m === 'nuit' ? 'nuit' : 'pierre';
}
toggle() {
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
document.documentElement.dataset.mood = this.current;
try {
localStorage.setItem('ld:mood', this.current);
} catch {
/* private mode: the mood just won't persist */
}
}
}
export const mood = new MoodState();
+27
View File
@@ -0,0 +1,27 @@
import { api } from './api';
import type { Me } from './types';
class Session {
me = $state<Me | null>(null);
ready = $state(false);
g1 = $state(false);
dev = $state(false);
async load() {
const [me, cfg] = await Promise.all([
api.get<{ me: Me | null }>('/me').catch(() => ({ me: null })),
api.get<{ g1: boolean; dev: boolean }>('/auth/config').catch(() => ({ g1: false, dev: false })),
]);
this.me = me.me;
this.g1 = cfg.g1;
this.dev = cfg.dev;
this.ready = true;
}
async logout() {
await api.post('/auth/logout');
this.me = null;
}
}
export const session = new Session();
+22
View File
@@ -0,0 +1,22 @@
export interface Toast {
id: number;
text: string;
tone: 'info' | 'rise' | 'warn';
}
class Toasts {
list = $state<Toast[]>([]);
#n = 0;
show(text: string, tone: Toast['tone'] = 'info', ms = 4200) {
const id = ++this.#n;
this.list = [...this.list, { id, text, tone }];
setTimeout(() => this.dismiss(id), ms);
}
dismiss(id: number) {
this.list = this.list.filter((t) => t.id !== id);
}
}
export const toasts = new Toasts();
+127
View File
@@ -0,0 +1,127 @@
export type Lang = 'fr' | 'en' | 'es';
export type LangMap = Partial<Record<Lang, string>>;
export type Inertia = 'low' | 'standard' | 'high' | 'max';
export interface Me {
id: string;
kind: 'g1' | 'invite' | 'dev';
displayName: string;
address: string | null;
g1Member: boolean;
admin: boolean;
}
export interface GroupSummary {
slug: string;
name: string;
parent: string | null;
policy: 'g1-members' | 'managed';
open: boolean;
}
export interface Tally {
for: number;
against: number;
}
export interface Evaluation {
cast: number;
required: number;
reached: boolean;
missing: number;
requiredRatio: number;
wellRated: boolean;
}
export interface Translation {
title: string;
body: string;
stale: boolean;
origin: 'seed' | 'member';
}
export interface Proposal {
id: string;
unitId: string;
kind: 'text' | 'remove' | 'inertia';
inertiaTarget: Inertia | null;
title: string;
body: string;
status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn';
origin: 'seed' | 'member';
label: string | null;
author: { id: string; name: string | null } | null;
mine: boolean;
revision: number;
createdAt: string;
updatedAt: string;
submittedAt: string | null;
enteredAt: string | null;
confirmedAt: string | null;
seedMinimal: boolean;
tally: Tally;
evaluation: Evaluation | null;
votedAtInertia: Inertia;
blocked: boolean;
signals: number;
myVote: 'for' | 'against' | null;
myVoteStale: boolean;
translations: Partial<Record<Lang, Translation>>;
}
export interface Unit {
id: string;
code: string;
section: string | null;
position: number;
inertia: Inertia;
inForce: Proposal | null;
proposals: Proposal[];
}
export interface Publication {
state: 'none' | 'pending' | 'done';
configured: boolean;
since?: string;
doneAt?: string | null;
commit?: string | null;
attempts?: number;
}
export interface DocView {
doc: {
id: string;
slug: string;
groupSlug: string;
groupName: string;
title: LangMap;
summary: LangMap;
sourceLang: Lang;
voteFloor: number;
electorate: number;
electorateSource: 'wot' | 'group';
formula: { base: number };
publication: Publication;
};
units: Unit[];
inertia: { key: Inertia; majorityPct: number; gradient: number }[];
}
export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string };
export interface PreviewUnit {
unitId: string;
title: string;
body: string;
mark: 'unchanged' | 'added' | 'replaced' | 'removed';
proposalId: string | null;
versionId: string | null;
translations: Partial<Record<Lang, Translation>>;
}
export interface Entered {
proposalId: string;
unitId: string;
code: string;
kind: 'text' | 'remove' | 'inertia';
}
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import '../app.css';
import { onMount, type Snippet } from 'svelte';
import { afterNavigate } from '$app/navigation';
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
import Icon from '$lib/components/Icon.svelte';
import Seal from '$lib/components/Seal.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import Stamp from '$lib/components/Stamp.svelte';
import Toasts from '$lib/components/Toasts.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
let { children }: { children: Snippet } = $props();
let feedback: FeedbackDialog | undefined = $state();
let drawer = $state(false);
onMount(() => {
i18n.init();
mood.init();
void session.load();
});
afterNavigate(() => {
drawer = false;
});
</script>
<svelte:head>
<title>{t('app.name')}</title>
</svelte:head>
<a class="skip" href="#main">{t('app.skip')}</a>
<div class="shell">
<aside class="side">
<Sidebar onfeedback={() => feedback?.open()} />
</aside>
<header class="topbar">
<a class="brand" href="/"><Stamp size={2} /><span>libre<b>Decision</b></span></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
</button>
</header>
{#if drawer}
<div class="veil" role="presentation" onclick={() => (drawer = false)}></div>
<div class="drawer card" id="drawer" role="dialog" aria-modal="true" aria-label={t('nav.label')}>
<button class="close" type="button" onclick={() => (drawer = false)} aria-label={t('nav.close')}><Icon name="close" /></button>
<Sidebar onfeedback={() => { drawer = false; feedback?.open(); }} onnavigate={() => (drawer = false)} />
</div>
{/if}
<main id="main" tabindex="-1">
{#if session.ready}
{@render children()}
{/if}
</main>
</div>
<FeedbackDialog bind:this={feedback} />
<Toasts />
<Seal />
<svelte:window onkeydown={(e) => e.key === 'Escape' && (drawer = false)} />
<style>
.skip {
position: absolute;
left: -999px;
top: 0.5rem;
z-index: 100;
background: var(--surface);
padding: 0.5rem 1rem;
border-radius: var(--r-pill);
}
.skip:focus {
left: 0.5rem;
}
.shell {
min-height: 100dvh;
}
.side {
position: fixed;
inset: 0 auto 0 0;
width: var(--sidebar);
background: var(--surface);
box-shadow: var(--shadow);
z-index: 20;
overflow-y: auto;
}
main {
margin-left: var(--sidebar);
min-height: 100dvh;
outline: none;
}
.topbar {
display: none;
}
.veil {
position: fixed;
inset: 0;
background: var(--veil);
z-index: 40;
}
.drawer {
position: fixed;
inset: 0.6rem auto 0.6rem 0.6rem;
width: min(84vw, 18rem);
z-index: 41;
overflow-y: auto;
animation: slide 0.22s var(--ease);
}
.close {
position: absolute;
top: 1.2rem;
right: 0.8rem;
border: 0;
background: transparent;
color: var(--ink-soft);
width: 2.25rem;
height: 2.25rem;
border-radius: var(--r-icon);
}
@keyframes slide {
from {
transform: translateX(-12px);
opacity: 0;
}
}
@media (max-width: 899px) {
.side {
display: none;
}
main {
margin-left: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.9rem;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--ink);
text-decoration: none;
font-size: 1.02rem;
}
.brand b {
font-weight: 800;
}
.menu {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: var(--r-icon);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--ink);
}
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// SPA: the Rust server answers /api, the interface renders in the browser.
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'never';
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
const sections = $derived([
{ name: t('nav.decisions'), text: t('home.decisions'), open: false },
{ name: t('nav.docs'), text: t('home.docs'), open: true },
{ name: t('nav.mandates'), text: t('home.mandates'), open: false },
{ name: t('nav.protocols'), text: t('home.protocols'), open: false },
]);
</script>
<div class="home">
<header class="intro">
<h1>{t('home.title')}</h1>
<p class="sub">{t('home.subtitle')}</p>
</header>
<ul class="sections">
{#each sections as s (s.name)}
<li class="section" class:open={s.open}>
<div class="head">
<h2>{s.name}</h2>
<span class="tag" class:water={s.open}>{s.open ? t('home.open') : t('home.soon')}</span>
</div>
<p>{s.text}</p>
</li>
{/each}
</ul>
<div class="enter">
{#if session.me}
<a class="btn primary" href="/groupes">{t('home.continue')}</a>
{:else}
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
<p class="faint">{t('home.signin.note')}</p>
{/if}
</div>
</div>
<style>
.home {
max-width: 50rem;
margin: 0 auto;
padding: clamp(3rem, 10vh, 7rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
}
.intro h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2.1rem, 1.4rem + 3vw, 3.4rem);
letter-spacing: -0.015em;
line-height: 1.08;
font-variation-settings: 'opsz' 72;
max-width: 15ch;
text-wrap: balance;
}
.sub {
margin-top: 1.1rem;
font-family: var(--font-text);
font-style: italic;
font-size: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
color: var(--ink-soft);
max-width: 30ch;
}
.sections {
list-style: none;
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--surface-2);
border-radius: var(--r-card);
overflow: hidden;
box-shadow: var(--shadow);
}
.section {
background: var(--surface);
padding: 1.4rem 1.5rem 1.5rem;
display: grid;
gap: 0.55rem;
align-content: start;
}
.section:not(.open) {
color: var(--ink-soft);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
h2 {
font-size: 1.08rem;
font-weight: 800;
color: var(--ink);
}
.section:not(.open) h2 {
color: var(--ink-soft);
}
.section p {
font-size: 0.96rem;
line-height: 1.55;
}
.enter {
margin-top: 2.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.2rem;
}
.enter p {
font-size: 0.9rem;
max-width: 34ch;
}
@media (max-width: 640px) {
.sections {
grid-template-columns: 1fr;
}
}
</style>
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
interface Overview {
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
}
interface Feedback {
feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[];
}
const data = new Remote(() => api.get<Overview>('/access'));
const fb = new Remote(() => api.get<Feedback>('/access/feedback'));
onMount(() => {
void data.load();
void fb.load();
});
let choose = $state<Record<string, string>>({});
let label = $state('');
let groups = $state<string[]>([]);
let admin = $state(false);
let link = $state('');
let error = $state('');
const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id;
async function act(fn: () => Promise<unknown>) {
error = '';
try {
await fn();
await data.load();
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
}
async function invite(e: SubmitEvent) {
e.preventDefault();
await act(async () => {
const r = await api.post<{ link: string }>('/access/invitations', { label, groups, admin });
link = r.link;
label = '';
groups = [];
admin = false;
});
}
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
}
async function status(id: number, s: 'seen' | 'done') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
}
</script>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if !session.me.admin}
<div class="page"><p class="muted">{t('access.denied')}</p></div>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('access.title')}</h1>
<p class="muted">{t('access.intro')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<section>
<h2 class="eyebrow">{t('access.groups')}</h2>
{#each data.value.groups as g (g.slug)}
<div class="card block">
<h3>{g.name}</h3>
{#if g.policy === 'g1-members'}<p class="faint small">{t('access.members.wot')}</p>{/if}
<ul class="members">
{#each g.members as m (m.identityId)}
<li>
<span>{nameOf(m.identityId)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/groups/${g.slug}/members/${encodeURIComponent(m.identityId)}`))}>{t('access.revoke')}</button>
</li>
{:else}
<li class="faint small">{t('access.members.none')}</li>
{/each}
</ul>
<div class="row">
<select class="select" bind:value={choose[g.slug]} aria-label={t('access.choose')}>
<option value={undefined}>{t('access.choose')}</option>
{#each data.value.identities.filter((i) => !g.members.some((m) => m.identityId === i.id)) as i (i.id)}
<option value={i.id}>{i.displayName} · {t(`access.kind.${i.kind}`)}</option>
{/each}
</select>
<button class="btn small" type="button" disabled={!choose[g.slug]} onclick={() => act(() => api.post(`/access/groups/${g.slug}/members`, { identityId: choose[g.slug] }))}>{t('access.grant')}</button>
</div>
</div>
{/each}
</section>
<section>
<h2 class="eyebrow">{t('access.invitations')}</h2>
<form class="card block" onsubmit={invite}>
<label class="field"><span>{t('access.invite.label')}</span><input class="input" bind:value={label} maxlength="60" required /></label>
<fieldset>
<legend>{t('access.invite.groups')}</legend>
{#each data.value.groups as g (g.slug)}
<label class="check"><input type="checkbox" value={g.slug} bind:group={groups} /> {g.name}</label>
{/each}
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
</fieldset>
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
{#if link}
<div class="link">
<p class="small">{t('access.invite.link')}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
{#each data.value.invitations as inv (inv.id)}
<li>
<span><b>{inv.label}</b> · {t('access.invite.pending')} · {i18n.date(inv.expiresAt)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/invitations/${inv.id}`))}>{t('access.invite.cancel')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
{#each data.value.identities as i (i.id)}
<li>
<span>
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
{#if i.g1Member} · {t('access.member')}{/if}
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</li>
{/each}
</ul>
</section>
<section>
<h2 class="eyebrow">{t('access.feedback')}</h2>
{#each fb.value?.feedback ?? [] as f (f.id)}
<div class="card block fb" class:done={f.status === 'done'}>
<p class="small faint">{f.author ?? '—'} · {i18n.date(f.createdAt)} · <code>{f.route}</code></p>
<p class="body">{f.body}</p>
<div class="row">
{#if f.status === 'new'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'seen')}>{t('access.feedback.seen')}</button>{/if}
{#if f.status !== 'done'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'done')}>{t('access.feedback.done')}</button>{/if}
</div>
</div>
{:else}
<p class="faint">{t('access.feedback.none')}</p>
{/each}
</section>
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
h3 {
font-size: 1.05rem;
font-weight: 800;
}
section {
display: grid;
gap: 0.7rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.6rem;
}
.members,
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.members li,
.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.row {
display: flex;
gap: 0.5rem;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
legend {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
margin-bottom: 0.3rem;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
}
.block > .btn {
justify-self: start;
}
.link {
display: grid;
gap: 0.5rem;
padding: 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.link code {
word-break: break-all;
font-size: 0.85rem;
}
.link .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.fb .body {
white-space: pre-wrap;
}
.fb.done {
opacity: 0.6;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { t, type Key } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
const back = $derived(page.url.searchParams.get('return') ?? '/groupes');
const error = $derived(page.url.searchParams.get('erreur'));
let devName = $state('');
let devAdmin = $state(false);
let devError = $state('');
$effect(() => {
if (session.me) void goto(back, { replaceState: true });
});
async function dev(e: SubmitEvent) {
e.preventDefault();
try {
const r = await api.post<{ me: Me }>('/auth/dev', { name: devName, admin: devAdmin });
session.me = r.me;
} catch (err) {
devError = t(`error.${err instanceof ApiError ? err.code : 'internal'}` as Key);
}
}
</script>
<div class="page">
<h1>{t('signin.title')}</h1>
{#if error}
<p class="alert" role="alert">{t(`signin.error.${error}` as Key)}</p>
{/if}
<section class="card block">
{#if session.g1}
<a class="btn primary big" href="/api/auth/g1/start?return={encodeURIComponent(back)}">{t('signin.g1')}</a>
<p>{t('signin.g1.how')}</p>
<p class="faint">{t('signin.g1.members')}</p>
{:else}
<button class="btn primary big" type="button" disabled>{t('signin.g1')}</button>
<p>{t('signin.g1.pending')}</p>
{/if}
</section>
<p class="muted invite">{t('signin.invite')}</p>
{#if session.dev}
<form class="card block dev" onsubmit={dev}>
<h2 class="eyebrow">{t('signin.dev')}</h2>
<label class="field"><span>{t('signin.dev.name')}</span><input class="input" bind:value={devName} required maxlength="40" /></label>
<label class="check"><input type="checkbox" bind:checked={devAdmin} /> {t('signin.dev.admin')}</label>
{#if devError}<p class="alert">{devError}</p>{/if}
<button class="btn" type="submit">{t('signin.dev.go')}</button>
</form>
{/if}
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem 4rem;
display: grid;
gap: 1.2rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
.block {
padding: 1.5rem;
display: grid;
gap: 0.9rem;
}
.big {
min-height: 3rem;
font-size: 1.02rem;
justify-self: start;
}
.invite {
padding: 0 0.3rem;
}
.alert {
color: var(--ochre);
font-weight: 700;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
color: var(--ink-soft);
}
.dev .btn {
justify-self: start;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
$effect(() => {
void slug;
void data.load();
});
const labels: Record<string, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const texts: Record<string, Key> = { decisions: 'home.decisions', docs: 'home.docs', mandats: 'home.mandates', protocoles: 'home.protocols' };
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/groupes">{t('nav.groups')}</a>
<h1>{g.name}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<h2 class="eyebrow sub">{t('group.tools')}</h2>
<ul class="tools">
{#each data.value.tools as tool (tool.key)}
<li>
{#if tool.active}
<a class="tool card" href="/g/{g.slug}/docs">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}</span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</a>
{:else}
<div class="tool off" aria-disabled="true">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}<span class="tag">{t('home.soon')}</span></span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
}
.sub {
margin-top: 1.6rem;
color: var(--ink-soft);
}
.tools {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.tool {
display: grid;
gap: 0.45rem;
padding: 1.3rem 1.4rem;
height: 100%;
color: var(--ink);
text-decoration: none;
}
a.tool {
transition: transform 0.12s ease;
}
a.tool:hover {
transform: translateY(-3px);
}
.tool.off {
border-radius: var(--r-card);
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.name {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-weight: 800;
font-size: 1.05rem;
}
.text {
font-size: 0.93rem;
line-height: 1.5;
color: var(--ink-soft);
}
.off .text {
color: var(--ink-faint);
}
@media (max-width: 640px) {
.tools {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
import type { LangMap } from '$lib/types';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
$effect(() => {
void slug;
void data.load();
});
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
<h1>{t('docs.title')}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else if !data.value.docs.length}
<p class="muted">{t('group.docs.empty')}</p>
{:else}
<ul class="docs">
{#each data.value.docs as d (d.id)}
<li>
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
<span class="summary">{pick(d.summary)}</span>
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1rem;
}
.docs {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.doc {
display: grid;
gap: 0.5rem;
padding: 1.5rem 1.6rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.doc:hover {
transform: translateY(-3px);
}
.title {
font-family: var(--font-text);
font-size: 1.45rem;
font-weight: 500;
}
.summary {
color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,302 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import DeepList from '$lib/components/DeepList.svelte';
import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
const doc = new DocState();
const group = $derived(page.params.group ?? '');
const slug = $derived(page.params.doc ?? '');
$effect(() => {
void doc.load(group, slug);
});
// The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer' };
let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams;
const tab = TABS[q.get('onglet') ?? ''];
if (!tab) return null;
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), prefill };
});
function openPanel(next: PanelState | null) {
prefill = next?.prefill ?? null;
const q = new URLSearchParams();
if (next) {
q.set('onglet', TAB_NAMES[next.tab]);
if (next.unitId) q.set('article', next.unitId);
if (next.proposalId) q.set('version', next.proposalId);
if (next.draftId) q.set('brouillon', next.draftId);
}
const search = q.toString();
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
}
let filter = $state<'all' | 'wellrated' | 'mine'>('all');
const view = $derived(doc.view);
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
const deep = $derived(
doc.open.filter(({ proposal: p }) =>
filter === 'wellrated' ? p.evaluation?.wellRated : filter === 'mine' ? p.mine : true,
),
);
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, tab: Tab) {
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
}
function onProposal(u: Unit, p: Proposal) {
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
}
</script>
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
<Guard code={doc.error} onretry={() => doc.load(group, slug)} />
{:else if view}
<div class="reader" class:with-panel={!!panel}>
<header class="head">
<p class="crumbs eyebrow">
<a href="/g/{view.doc.groupSlug}">{view.doc.groupName}</a><span class="sep" aria-hidden="true">·</span><a href="/g/{view.doc.groupSlug}/docs">{t('nav.docs')}</a>
</p>
<h1>{title}</h1>
<p class="summary">{summary}</p>
<p class="facts faint">
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
</p>
<div class="controls">
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
<button type="button" role="radio" aria-checked={doc.mode === v} class:on={doc.mode === v} onclick={() => doc.setMode(v)}>{t(`doc.view.${v}`)}</button>
{/each}
</div>
<div class="seg small" role="radiogroup" aria-label={t('doc.lang.label')}>
{#each LANGS as l (l)}
<button type="button" role="radio" aria-checked={lang === l} class:on={lang === l} onclick={() => { doc.original = l === source && i18n.lang !== source; if (l !== source) i18n.set(l); }}>{l.toUpperCase()}</button>
{/each}
</div>
</div>
{#if lang !== source}
<p class="note faint">{t('doc.lang.translation')} <button class="linkish" type="button" onclick={() => (doc.original = true)}>{t('doc.lang.original')}</button></p>
{/if}
{#if doc.mode !== 'inforce'}<p class="note preview">{t(`doc.view.${doc.mode}.note`)}</p>{/if}
</header>
<section class="text" aria-label={t('doc.view.inforce')}>
{#if doc.mode === 'inforce' || !doc.preview}
{#each doc.inForce as u (u.id)}
{#if u.inForce}
<UnitBlock
text={shown(u.inForce, lang, source)}
seedMinimal={u.inForce.seedMinimal}
enteredAt={u.inForce.enteredAt}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
onaction={(tab) => onUnit(u, tab)}
/>
{/if}
{:else}
<p class="muted">{t('doc.empty')}</p>
{/each}
{:else}
{#each doc.preview as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
<UnitBlock
text={shown(pu, lang, source)}
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
/>
{/each}
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{/if}
</section>
<WaveRule />
<section class="deep" aria-labelledby="deep-title">
{#if doc.drafts.length}
<div class="drafts">
<h2 class="eyebrow">{t('deep.drafts')}</h2>
<p class="faint small">{t('deep.drafts.note')}</p>
<ul>
{#each doc.drafts as { unit, proposal } (proposal.id)}
<li><button type="button" class="linkish" onclick={() => openPanel({ unitId: unit.inForce ? unit.id : null, proposalId: null, tab: 'propose', draftId: proposal.id })}>{proposal.title}</button></li>
{/each}
</ul>
</div>
{/if}
<div class="deep-head">
<h2 id="deep-title">{t('deep.title')}</h2>
<p class="muted">{t('deep.note', { floor: view.doc.voteFloor })}</p>
<div class="seg small" role="radiogroup" aria-label={t('deep.title')}>
{#each ['all', 'wellrated', 'mine'] as const as f (f)}
<button type="button" role="radio" aria-checked={filter === f} class:on={filter === f} onclick={() => (filter = f)}>{t(`deep.filter.${f}`)}</button>
{/each}
</div>
</div>
<DeepList items={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
</section>
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
{/if}
{/if}
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
<style>
.reader {
max-width: calc(var(--measure) + 2.6rem);
margin: 0 auto;
padding: clamp(2.2rem, 7vh, 4.5rem) 1.3rem 6rem;
transition: padding 0.2s var(--ease);
}
@media (min-width: 1180px) {
.reader.with-panel {
margin-right: calc(var(--panel) + 2.5rem);
}
}
.crumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.crumbs a {
color: inherit;
text-decoration: none;
}
.crumbs a:hover {
text-decoration: underline;
}
h1 {
margin-top: 0.8rem;
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.4rem + 2.4vw, 2.9rem);
line-height: 1.1;
letter-spacing: -0.012em;
font-variation-settings: 'opsz' 60;
}
.summary {
margin-top: 0.9rem;
font-family: var(--font-text);
font-style: italic;
color: var(--ink-soft);
font-size: 1.06rem;
line-height: 1.6;
}
.facts {
margin-top: 0.8rem;
font-size: 0.85rem;
font-weight: 700;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: space-between;
margin-top: 1.6rem;
}
.seg {
display: inline-flex;
flex-wrap: wrap;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
}
.seg button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.35rem 0.85rem;
min-height: 2.25rem;
font-weight: 750;
font-size: 0.86rem;
color: var(--ink-soft);
}
.seg.small button {
padding-inline: 0.65rem;
font-size: 0.8rem;
}
.seg button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow);
}
.note {
margin-top: 0.8rem;
font-size: 0.86rem;
}
.note.preview {
color: var(--moss);
font-weight: 700;
}
.linkish {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
text-decoration: underline;
text-underline-offset: 3px;
}
.text {
margin-top: 2.2rem;
}
.deep {
padding-top: 2rem;
background: linear-gradient(to bottom, var(--water-wash), transparent 22rem);
margin: 0 -1.3rem;
padding-inline: 1.3rem;
border-radius: 0 0 var(--r-card) var(--r-card);
}
.deep-head {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
}
.deep-head h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.55rem;
}
.deep-head .seg {
justify-self: start;
}
.drafts {
display: grid;
gap: 0.3rem;
margin-bottom: 2rem;
}
.drafts ul {
margin: 0.3rem 0 0;
padding-left: 1.1rem;
}
.small {
font-size: 0.84rem;
}
.new {
margin-top: 1.4rem;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupSummary } from '$lib/types';
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
onMount(() => void data.load());
const groups = $derived(data.value?.groups ?? []);
const root = $derived(groups.find((g) => g.slug === 'monnaie-libre'));
const sameKey = $derived(groups.filter((g) => g.parent === 'monnaie-libre'));
const others = $derived(groups.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('groups.title')}</h1>
{#if root}
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
<span class="eyebrow">Ğ1</span>
<span class="name">{root.name}</span>
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
</a>
{/if}
{#if sameKey.length}
<h2 class="eyebrow sub">{t('groups.same.key')}</h2>
<ul class="list">
{#each sameKey as g (g.slug)}
<li>
{#if g.open}
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
{:else}
<div class="row card locked" title={t('groups.locked.long')}>
<span class="name">{g.name}</span>
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
{/if}
{#if others.length}
<h2 class="eyebrow sub">{t('groups.test')}</h2>
<ul class="list">
{#each others as g (g.slug)}
<li>
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
</a>
</li>
{/each}
</ul>
{/if}
{#if !groups.length}<p class="muted">{t('groups.empty')}</p>{/if}
</div>
{/if}
<style>
.page {
max-width: 42rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.9rem, 1.5rem + 1.5vw, 2.5rem);
margin-bottom: 0.6rem;
}
.root {
display: grid;
gap: 0.3rem;
padding: 1.6rem 1.6rem 1.7rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
a.root:hover,
a.row:hover {
transform: translateY(-3px);
}
.root .name {
font-family: var(--font-text);
font-size: 1.7rem;
font-weight: 500;
}
.sub {
margin-top: 1.4rem;
color: var(--ink-soft);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.row .name {
font-weight: 750;
}
.locked {
background: transparent;
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.lock {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.82rem;
font-weight: 700;
}
.note {
font-size: 0.88rem;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
let state = $state<'working' | 'invalid' | 'missing'>('working');
onMount(async () => {
// The token travels in the fragment: it never reaches a server log.
const token = location.hash.slice(1).trim();
history.replaceState(null, '', '/invitation');
if (!/^[0-9a-f]{64}$/.test(token)) {
state = 'missing';
return;
}
try {
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
session.me = r.me;
await goto('/groupes', { replaceState: true });
} catch {
state = 'invalid';
}
});
</script>
<div class="page">
<h1>{t('invite.title')}</h1>
<p class="muted" role="status">{t(`invite.${state}`)}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 105" fill="#1f6f78">
<!-- Hexagram 48 — 井 Tsing — Le Puits -->
<rect x="6" y="6" width="42" height="8"/><rect x="64" y="6" width="42" height="8"/>
<rect x="6" y="23" width="100" height="8"/>
<rect x="6" y="40" width="42" height="8"/><rect x="64" y="40" width="42" height="8"/>
<rect x="6" y="57" width="100" height="8"/>
<rect x="6" y="74" width="100" height="8"/>
<rect x="6" y="91" width="42" height="8"/><rect x="64" y="91" width="42" height="8"/>
</svg>

After

Width:  |  Height:  |  Size: 535 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 105" fill="currentColor">
<!-- Hexagram 48 — 井 Tsing — Le Puits (flat, traits épais, coins nets) -->
<rect x="6" y="6" width="42" height="8"/>
<rect x="64" y="6" width="42" height="8"/>
<rect x="6" y="23" width="100" height="8"/>
<rect x="6" y="40" width="42" height="8"/>
<rect x="64" y="40" width="42" height="8"/>
<rect x="6" y="57" width="100" height="8"/>
<rect x="6" y="74" width="100" height="8"/>
<rect x="6" y="91" width="42" height="8"/>
<rect x="64" y="91" width="42" height="8"/>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
export default {
preprocess: vitePreprocess(),
kit: {
// SPA served by the Rust server: every unknown path falls back to index.html.
adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', precompress: true, strict: true }),
version: { name: process.env.LD_BUILD ?? 'dev' },
},
};
+55
View File
@@ -0,0 +1,55 @@
// The words of the screen: every catalogue has the same keys and the same
// placeholders, and no visible text uses a forbidden term.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { en } from '../../src/lib/i18n/en';
import { es } from '../../src/lib/i18n/es';
import { fr } from '../../src/lib/i18n/fr';
/** Never shown to a person (case- and accent-insensitive). They may live on as code identifiers. */
export const FORBIDDEN_UI_TERMS = [
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
'verdict', 'triage', 'oligarchie', 'peuple adulte',
];
const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const marks = (s: string) => (s.match(/\{\w+\}/g) ?? []).sort().join(',');
function svelteFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
return statSync(p).isDirectory() ? svelteFiles(p) : p.endsWith('.svelte') ? [p] : [];
});
}
describe('catalogues', () => {
it('en and es have exactly the keys of fr, in order', () => {
expect(Object.keys(en)).toEqual(Object.keys(fr));
expect(Object.keys(es)).toEqual(Object.keys(fr));
});
it('placeholders are kept', () => {
for (const k of Object.keys(fr) as (keyof typeof fr)[]) {
expect(marks(en[k]), `en ${k}`).toBe(marks(fr[k]));
expect(marks(es[k]), `es ${k}`).toBe(marks(fr[k]));
}
});
});
describe('anti-lexicon', () => {
it('no forbidden term in the French catalogue', () => {
for (const [k, v] of Object.entries(fr)) {
for (const term of FORBIDDEN_UI_TERMS) expect(fold(v).includes(fold(term)), `${k}: ${term}`).toBe(false);
}
});
it('no forbidden term in template text of components', () => {
for (const file of svelteFiles(join(__dirname, '../../src'))) {
const markup = readFileSync(file, 'utf8')
.replace(/<script[\s\S]*?<\/script>/g, '')
.replace(/<style[\s\S]*?<\/style>/g, '')
.replace(/\{[^}]*\}/g, ' ');
const text = fold(markup.replace(/<[^>]+>/g, ' '));
for (const term of FORBIDDEN_UI_TERMS) expect(text.includes(fold(term)), `${file}: ${term}`).toBe(false);
}
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { plain, render } from '../../src/lib/markdown';
describe('markdown', () => {
it('escapes html before anything', () => {
expect(render('<script>alert(1)</script>')).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
expect(render('**<b>x</b>**')).toBe('<p><strong>&lt;b&gt;x&lt;/b&gt;</strong></p>');
});
it('paragraphs, breaks, emphasis, code', () => {
expect(render('un **être humain** vivant\nligne\n\n*note*')).toBe(
'<p>un <strong>être humain</strong> vivant<br>ligne</p><p><em>note</em></p>',
);
expect(render('`DU(n+1) = DU(n)`')).toBe('<p><code>DU(n+1) = DU(n)</code></p>');
});
it('lists', () => {
expect(render('- Stock de **100**\n- 5 jours')).toBe('<ul><li>Stock de <strong>100</strong></li><li>5 jours</li></ul>');
expect(render('1. un\n2. deux')).toBe('<ol><li>un</li><li>deux</li></ol>');
});
it('numbered paragraphs that are not lists stay paragraphs', () => {
expect(render('**1.** Derrière une clé')).toBe('<p><strong>1.</strong> Derrière une clé</p>');
});
it('plain excerpts', () => {
expect(plain('**1.** Derrière\n- une clé')).toBe('1. Derrière une clé');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
// In development the Rust server runs on :8002 (`cargo run -p ld-server`);
// Vite serves the interface on :3002 and forwards /api to it.
export default defineConfig({
plugins: [sveltekit()],
define: { __LD_BUILD__: JSON.stringify(process.env.LD_BUILD ?? 'dev') },
server: {
host: '0.0.0.0',
port: 3002,
strictPort: true,
proxy: { '/api': { target: 'http://127.0.0.1:8002', changeOrigin: false } },
},
test: { include: ['tests/unit/**/*.test.ts'], environment: 'node' },
});