diff --git a/.gitignore b/.gitignore index 2ce7da6..ceacdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ web/test-results/ web/playwright-report/ *.db-wal *.db-shm +web/node_modules/ diff --git a/crates/ld-server/migrations/0001_init.sql b/crates/ld-server/migrations/0001_init.sql index 96357fe..9fa33a8 100644 --- a/crates/ld-server/migrations/0001_init.sql +++ b/crates/ld-server/migrations/0001_init.sql @@ -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 diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 80e6d37..934a14f 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -185,6 +185,7 @@ pub struct DocRow { pub struct UnitRow { pub id: String, pub code: String, + pub section: Option, pub position: f64, pub inertia: String, pub in_force_id: Option, @@ -274,7 +275,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { 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, }) }) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index c5e0f9a..f02f9bb 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -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] diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index d2f06b1..5f333c1 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -32,6 +32,7 @@ pub struct SeedInstance { #[derive(Deserialize)] pub struct SeedUnit { pub code: String, + pub section: Option, pub position: f64, pub inertia: String, pub versions: Vec, @@ -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) diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts new file mode 100644 index 0000000..3b041b2 --- /dev/null +++ b/web/e2e/helpers.ts @@ -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 { + const ctx = await browser.newContext({ locale: 'fr-FR' }); + const page = await ctx.newPage(); + await signIn(page, name, admin); + return page; +} diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts new file mode 100644 index 0000000..4400d0e --- /dev/null +++ b/web/e2e/mobile.spec.ts @@ -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(); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts new file mode 100644 index 0000000..db0ca41 --- /dev/null +++ b/web/e2e/reader.spec.ts @@ -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#'); +}); diff --git a/web/e2e/serve.sh b/web/e2e/serve.sh new file mode 100755 index 0000000..3cf3501 --- /dev/null +++ b/web/e2e/serve.sh @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..9f32c28 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2195 @@ +{ + "name": "libredecision-web", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libredecision-web", + "version": "0.3.0", + "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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/literata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/literata/-/literata-5.3.0.tgz", + "integrity": "sha512-1Jw7xvOcoGyB83lxB2nG4bfNbqtX2/TTbdh9sHAOmU064SvmSdulDtA5RBu5JYWf64fdaKX7xU+z3GO1T2bCHA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", + "integrity": "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", + "integrity": "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", + "integrity": "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", + "integrity": "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", + "integrity": "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", + "integrity": "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", + "integrity": "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", + "integrity": "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", + "integrity": "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", + "integrity": "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", + "integrity": "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", + "integrity": "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", + "integrity": "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", + "integrity": "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", + "integrity": "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", + "integrity": "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", + "integrity": "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", + "integrity": "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", + "integrity": "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", + "integrity": "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", + "integrity": "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", + "integrity": "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", + "integrity": "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", + "integrity": "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", + "integrity": "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", + "integrity": "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.12", + "@rollup/rollup-android-arm64": "4.64.12", + "@rollup/rollup-darwin-arm64": "4.64.12", + "@rollup/rollup-darwin-x64": "4.64.12", + "@rollup/rollup-freebsd-arm64": "4.64.12", + "@rollup/rollup-freebsd-x64": "4.64.12", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", + "@rollup/rollup-linux-arm-musleabihf": "4.64.12", + "@rollup/rollup-linux-arm64-gnu": "4.64.12", + "@rollup/rollup-linux-arm64-musl": "4.64.12", + "@rollup/rollup-linux-loong64-gnu": "4.64.12", + "@rollup/rollup-linux-loong64-musl": "4.64.12", + "@rollup/rollup-linux-ppc64-gnu": "4.64.12", + "@rollup/rollup-linux-ppc64-musl": "4.64.12", + "@rollup/rollup-linux-riscv64-gnu": "4.64.12", + "@rollup/rollup-linux-riscv64-musl": "4.64.12", + "@rollup/rollup-linux-s390x-gnu": "4.64.12", + "@rollup/rollup-linux-x64-gnu": "4.64.12", + "@rollup/rollup-linux-x64-musl": "4.64.12", + "@rollup/rollup-openbsd-x64": "4.64.12", + "@rollup/rollup-openharmony-arm64": "4.64.12", + "@rollup/rollup-win32-arm64-msvc": "4.64.12", + "@rollup/rollup-win32-ia32-msvc": "4.64.12", + "@rollup/rollup-win32-x64-gnu": "4.64.12", + "@rollup/rollup-win32-x64-msvc": "4.64.12", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.4.tgz", + "integrity": "sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz", + "integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..81f494e --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..3577d0f --- /dev/null +++ b/web/playwright.config.ts @@ -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/ }, + ], +}); diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..429b187 --- /dev/null +++ b/web/src/app.css @@ -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; + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..612cd23 --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,7 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + const __LD_BUILD__: string; + namespace App {} +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..011dfbb --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,22 @@ + + + + + + + + libreDecision + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..0fd6531 --- /dev/null +++ b/web/src/lib/api.ts @@ -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(method: string, path: string, body?: unknown): Promise { + const headers: Record = { '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: (path: string) => call('GET', path), + post: (path: string, body: unknown = {}) => call('POST', path, body), + patch: (path: string, body: unknown) => call('PATCH', path, body), + put: (path: string, body: unknown) => call('PUT', path, body), + del: (path: string) => call('DELETE', path), +}; diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte new file mode 100644 index 0000000..c16b3d3 --- /dev/null +++ b/web/src/lib/components/DeepList.svelte @@ -0,0 +1,111 @@ + + +
    + {#each items as { unit, proposal: p }, i (p.id)} + {#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section} +
  • {sectionLabel(unit.section)}
  • + {/if} +
  • + +
  • + {:else} +
  • {t('deep.none')}
  • + {/each} +
+ + diff --git a/web/src/lib/components/DiffText.svelte b/web/src/lib/components/DiffText.svelte new file mode 100644 index 0000000..b5f411b --- /dev/null +++ b/web/src/lib/components/DiffText.svelte @@ -0,0 +1,17 @@ + + +

+ {#each parts as part, i (i)} + {#if part.op === 'added'}{part.text}{:else if part.op === 'removed'}{part.text}{:else}{part.text}{/if} + {/each} +

+ + diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte new file mode 100644 index 0000000..6d299fe --- /dev/null +++ b/web/src/lib/components/DocPanel.svelte @@ -0,0 +1,209 @@ + + + + + diff --git a/web/src/lib/components/FeedbackDialog.svelte b/web/src/lib/components/FeedbackDialog.svelte new file mode 100644 index 0000000..74d372d --- /dev/null +++ b/web/src/lib/components/FeedbackDialog.svelte @@ -0,0 +1,89 @@ + + + +
+

{t('feedback.title')}

+ +

{t('feedback.context')}

+ {#if error}{/if} +
+ + +
+
+
+ + diff --git a/web/src/lib/components/Formula.svelte b/web/src/lib/components/Formula.svelte new file mode 100644 index 0000000..b8d8b86 --- /dev/null +++ b/web/src/lib/components/Formula.svelte @@ -0,0 +1,89 @@ + + +
+ {t('formula.title')} +

{t('formula.intro')}

+ + + R=C+BW+ + (M+(1−M)(1− + (TW)G)) + ·max(0,T−C) + + +
    +
  • {t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}
  • +
  • {t('formula.T', { n: tally.for + tally.against })}
  • + {#if p} +
  • {t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}
  • +
  • {t('formula.G', { n: p.gradient })}
  • + {/if} +
  • {t('formula.B', { n: base })}
  • +
  • {t('formula.C', { n: floor })}
  • + {#if evaluation}
  • {t('formula.R', { n: evaluation.required })}
  • {/if} +
+

{t('formula.reading')}

+
+ + diff --git a/web/src/lib/components/Guard.svelte b/web/src/lib/components/Guard.svelte new file mode 100644 index 0000000..15d4d2a --- /dev/null +++ b/web/src/lib/components/Guard.svelte @@ -0,0 +1,29 @@ + + +
+

{t(`error.${code}` as Key)}

+ {#if code === 'unauthenticated'} + {t('nav.signin')} + {:else if onretry && (code === 'network' || code === 'internal')} + + {/if} +
+ + diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte new file mode 100644 index 0000000..527ecd4 --- /dev/null +++ b/web/src/lib/components/Icon.svelte @@ -0,0 +1,26 @@ + + + diff --git a/web/src/lib/components/ProposeView.svelte b/web/src/lib/components/ProposeView.svelte new file mode 100644 index 0000000..a90b1a4 --- /dev/null +++ b/web/src/lib/components/ProposeView.svelte @@ -0,0 +1,265 @@ + + +
+ {#if !draft} +
+ {#if unit} + + {#if unit.inForce}{/if} + {/if} + +
+ {/if} + + {#if mode === 'new'} + + {/if} + + {#if mode === 'remove'} +

{t('propose.remove.note')}

+ {:else} + + + +
+

{t('assist.title')}

+ {#each hints as h, i (i)} + {#if h.kind === 'split'} +

{t('assist.split', { n: h.parts.length })}

+ + {:else if h.kind === 'similar'} +

{t('assist.similar', { title: h.title ?? '', where: h.inForce ? t('assist.similar.inForce') : '' })}

+ {#if h.unitId && !savedId}{/if} + {:else if h.kind === 'long'} +

{t('assist.long', { n: h.chars })}

+ {/if} + {:else} +

{t('assist.quiet')}

+ {/each} +
+ {/if} + +

{t('propose.rule')}

+ {#if error}{/if} +
+ + +
+
+ + diff --git a/web/src/lib/components/Prose.svelte b/web/src/lib/components/Prose.svelte new file mode 100644 index 0000000..a0194b0 --- /dev/null +++ b/web/src/lib/components/Prose.svelte @@ -0,0 +1,7 @@ + + + +
{@html render(text)}
diff --git a/web/src/lib/components/Seal.svelte b/web/src/lib/components/Seal.svelte new file mode 100644 index 0000000..2d8baf3 --- /dev/null +++ b/web/src/lib/components/Seal.svelte @@ -0,0 +1,33 @@ + + + + {#each lines as yang, i (i)} + {@const y = 91 - i * 17} + {#if yang} + + {:else} + + + {/if} + {/each} + + + diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..7099f5b --- /dev/null +++ b/web/src/lib/components/Sidebar.svelte @@ -0,0 +1,253 @@ + + + + + diff --git a/web/src/lib/components/Stamp.svelte b/web/src/lib/components/Stamp.svelte new file mode 100644 index 0000000..b9cef0b --- /dev/null +++ b/web/src/lib/components/Stamp.svelte @@ -0,0 +1,35 @@ + + + + + diff --git a/web/src/lib/components/Toasts.svelte b/web/src/lib/components/Toasts.svelte new file mode 100644 index 0000000..b42c8a3 --- /dev/null +++ b/web/src/lib/components/Toasts.svelte @@ -0,0 +1,47 @@ + + +
+ {#each toasts.list as toast (toast.id)} + + {/each} +
+ + diff --git a/web/src/lib/components/UnitBlock.svelte b/web/src/lib/components/UnitBlock.svelte new file mode 100644 index 0000000..f2977b5 --- /dev/null +++ b/web/src/lib/components/UnitBlock.svelte @@ -0,0 +1,121 @@ + + +
+
+

{text.title}

+ {#if mark !== 'unchanged'}{t(`doc.mark.${mark}`)}{/if} + {#if text.stale}{t('doc.lang.stale')}{:else if text.missing}{t('doc.lang.missing')}{/if} +
+ + {#if actions} +
+ + {#if seedMinimal}{t('doc.seed')} · 0 vote + {:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if} + + + + + + +
+ {/if} +
+ + diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte new file mode 100644 index 0000000..b70edf3 --- /dev/null +++ b/web/src/lib/components/VoteView.svelte @@ -0,0 +1,520 @@ + + +{#if detail && p && text && view} +
+
+ {p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} +

{p.kind === 'text' ? text.title : titleOf(p, unit)}

+

+ {#if p.seedMinimal}{t('doc.seed')} + {:else if p.confirmedAt}{t('doc.confirmed')} + {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} + {#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if} +

+
+ + {#if p.kind === 'text'} + {#if detail.diff && showDiff} + + {:else} + + {/if} + {#if detail.diff} + + {/if} + {#if text.translated}

{t('doc.lang.translation')}{#if text.stale} · {t('doc.lang.stale')}{/if}

{/if} + {:else if p.kind === 'remove' && detail.inForce} + + {/if} + +
+
+ +
+

{t('tally.short', { for: p.tally.for, against: p.tally.against })}

+ {#if p.evaluation} +

{t('tally.required', { n: p.evaluation.required })}

+

{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}

+ {/if} +

{t(`inertia.${p.votedAtInertia}`)}

+
+
+ {#if p.blocked}

{t('vote.blocked')}

{/if} + + {#if votable} + {#if p.myVoteStale}

{t('vote.stale')}

{/if} +
+ {t('vote.yours')} + + + {#if p.myVote}{/if} +
+

{t('vote.private')}

+ {:else} +

{t('vote.closed')}

+ {/if} +
+ + {#if p.kind !== 'inertia' && unit} +
+

{t('inertia.title')} · {t(`inertia.level.${unit.inertia}`)}

+

{t('inertia.explain')}

+ {#each inertiaOpen as ip (ip.id)} + + {/each} +
+ {t('inertia.change')} +
+ + +
+

{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}

+
+
+ {/if} + + + + {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable} +
+
+ {p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')} +
+

{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}

+ + + {#if typoSubstantive} +

{t('typo.substantive')}

+ + {/if} + +
+
+
+ {/if} + + {#if lang !== source && p.kind === 'text'} +
+
+ {t('translate.title', { lang: lang.toUpperCase() })} +
+

{t('translate.note')}

+ + + +
+
+
+ {/if} + + {#if detail.history.length > 1} +
+

{t('history.title')}

+
    + {#each detail.history.slice(1) as h (h.at + h.revision)} +
  1. +

    {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}

    + {#if h.diff}{/if} +
  2. + {/each} +
+
+ {/if} + + {#if p.kind === 'text' && votable} +
+

{t('signal.title')}

+ {#each detail.signals as s (s.id)} +
+

{s.other?.title ?? ''} — {s.reason}

+

+ {t('signal.by', { name: s.author })} · {i18n.date(s.at)} + {#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if} +

+ {#if s.mine && !s.liftedAt}{/if} +
+ {:else} +

{t('signal.none')}

+ {/each} +
+ {t('signal.add')} +
+

{t('signal.note')}

+ + + +
+
+
+ {/if} + + {#if p.mine && p.status === 'open'} + + {/if} + + {#if error}{/if} +
+{:else if error} + +{:else} +

{t('common.loading')}

+{/if} + + diff --git a/web/src/lib/components/WaterLevel.svelte b/web/src/lib/components/WaterLevel.svelte new file mode 100644 index 0000000..18777a0 --- /dev/null +++ b/web/src/lib/components/WaterLevel.svelte @@ -0,0 +1,61 @@ + + + + + + + diff --git a/web/src/lib/components/WaveRule.svelte b/web/src/lib/components/WaveRule.svelte new file mode 100644 index 0000000..2cb64be --- /dev/null +++ b/web/src/lib/components/WaveRule.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/web/src/lib/doc.svelte.ts b/web/src/lib/doc.svelte.ts new file mode 100644 index 0000000..c85aefe --- /dev/null +++ b/web/src/lib/doc.svelte.ts @@ -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> }, + 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(null); + error = $state(null); + mode = $state('inforce'); + preview = $state(null); + original = $state(false); + + async load(group: string, slug: string) { + try { + const g = await api.get(`/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(`/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(`/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; + } +} diff --git a/web/src/lib/group.ts b/web/src/lib/group.ts new file mode 100644 index 0000000..1e2656a --- /dev/null +++ b/web/src/lib/group.ts @@ -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 }[]; +} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts new file mode 100644 index 0000000..7bb4050 --- /dev/null +++ b/web/src/lib/i18n/en.ts @@ -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', +}; diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts new file mode 100644 index 0000000..53177f1 --- /dev/null +++ b/web/src/lib/i18n/es.ts @@ -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', +}; diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts new file mode 100644 index 0000000..2064f04 --- /dev/null +++ b/web/src/lib/i18n/fr.ts @@ -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; diff --git a/web/src/lib/i18n/index.svelte.ts b/web/src/lib/i18n/index.svelte.ts new file mode 100644 index 0000000..3694682 --- /dev/null +++ b/web/src/lib/i18n/index.svelte.ts @@ -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 = { fr, en, es }; +const LOCALES: Record = { 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('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 => { + const raw = CATALOGS[this.lang][key] ?? fr[key] ?? key; + return raw.replace(/\{(\w+)\}/g, (_, k: string) => (k in params ? String(params[k]) : `{${k}}`)); + }; + + /** `.one` / `.other` — French and Spanish use « one » for 0 and 1. */ + plural = (key: string, n: number, params: Record = {}): 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 }; diff --git a/web/src/lib/labels.ts b/web/src/lib/labels.ts new file mode 100644 index 0000000..f510a5d --- /dev/null +++ b/web/src/lib/labels.ts @@ -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 ''; +} diff --git a/web/src/lib/load.svelte.ts b/web/src/lib/load.svelte.ts new file mode 100644 index 0000000..da1005a --- /dev/null +++ b/web/src/lib/load.svelte.ts @@ -0,0 +1,22 @@ +import { ApiError } from './api'; + +/** A page's remote data: value, error code, reload. */ +export class Remote { + value = $state(null); + error = $state(null); + loading = $state(true); + + constructor(private fetcher: () => Promise) {} + + 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; + } + } +} diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts new file mode 100644 index 0000000..9c3bde3 --- /dev/null +++ b/web/src/lib/markdown.ts @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +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, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1$2'); +} + +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 `
    ${lines.map((l) => `
  • ${inline(l.replace(BULLET, ''))}
  • `).join('')}
`; + } + if (lines.length > 0 && lines.every((l) => NUMBERED.test(l))) { + return `
    ${lines.map((l) => `
  1. ${inline(l.replace(NUMBERED, ''))}
  2. `).join('')}
`; + } + return `

${lines.map(inline).join('
')}

`; + }) + .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(); +} diff --git a/web/src/lib/mood.svelte.ts b/web/src/lib/mood.svelte.ts new file mode 100644 index 0000000..c297ff6 --- /dev/null +++ b/web/src/lib/mood.svelte.ts @@ -0,0 +1,23 @@ +// Two moods, light and dark: « pierre » (stone) and « nuit » (night). +export type Mood = 'pierre' | 'nuit'; + +class MoodState { + current = $state('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(); diff --git a/web/src/lib/session.svelte.ts b/web/src/lib/session.svelte.ts new file mode 100644 index 0000000..fe32d1b --- /dev/null +++ b/web/src/lib/session.svelte.ts @@ -0,0 +1,27 @@ +import { api } from './api'; +import type { Me } from './types'; + +class Session { + me = $state(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(); diff --git a/web/src/lib/toast.svelte.ts b/web/src/lib/toast.svelte.ts new file mode 100644 index 0000000..f421f4a --- /dev/null +++ b/web/src/lib/toast.svelte.ts @@ -0,0 +1,22 @@ +export interface Toast { + id: number; + text: string; + tone: 'info' | 'rise' | 'warn'; +} + +class Toasts { + list = $state([]); + #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(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..6f81130 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,127 @@ +export type Lang = 'fr' | 'en' | 'es'; +export type LangMap = Partial>; +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>; +} + +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>; +} + +export interface Entered { + proposalId: string; + unitId: string; + code: string; + kind: 'text' | 'remove' | 'inertia'; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..c12be89 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,174 @@ + + + + {t('app.name')} + + + + +
+ + +
+ libreDecision + +
+ + {#if drawer} + + + {/if} + +
+ {#if session.ready} + {@render children()} + {/if} +
+
+ + + + + + e.key === 'Escape' && (drawer = false)} /> + + diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 0000000..ec3dd17 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -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'; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..9b835da --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,121 @@ + + +
+
+

{t('home.title')}

+

{t('home.subtitle')}

+
+ +
    + {#each sections as s (s.name)} +
  • +
    +

    {s.name}

    + {s.open ? t('home.open') : t('home.soon')} +
    +

    {s.text}

    +
  • + {/each} +
+ +
+ {#if session.me} + {t('home.continue')} + {:else} + {t('home.signin')} +

{t('home.signin.note')}

+ {/if} +
+
+ + diff --git a/web/src/routes/acces/+page.svelte b/web/src/routes/acces/+page.svelte new file mode 100644 index 0000000..c3b541f --- /dev/null +++ b/web/src/routes/acces/+page.svelte @@ -0,0 +1,267 @@ + + +{#if !session.me} + +{:else if !session.me.admin} +

{t('access.denied')}

+{:else if data.error} + data.load()} /> +{:else if data.value} +
+

{t('access.title')}

+

{t('access.intro')}

+ {#if error}{/if} + +
+

{t('access.groups')}

+ {#each data.value.groups as g (g.slug)} +
+

{g.name}

+ {#if g.policy === 'g1-members'}

{t('access.members.wot')}

{/if} +
    + {#each g.members as m (m.identityId)} +
  • + {nameOf(m.identityId)} + +
  • + {:else} +
  • {t('access.members.none')}
  • + {/each} +
+
+ + +
+
+ {/each} +
+ +
+

{t('access.invitations')}

+
+ +
+ {t('access.invite.groups')} + {#each data.value.groups as g (g.slug)} + + {/each} + +
+ + {#if link} + + {/if} +
+ {#if data.value.invitations.length} +
    + {#each data.value.invitations as inv (inv.id)} +
  • + {inv.label} · {t('access.invite.pending')} · {i18n.date(inv.expiresAt)} + +
  • + {/each} +
+ {/if} +
+ +
+

{t('access.identities')}

+
    + {#each data.value.identities as i (i.id)} +
  • + + {i.displayName} · {t(`access.kind.${i.kind}`)} + {#if i.g1Member} · {t('access.member')}{/if} + {#if i.admin} · {t('access.admin')}{/if} + + +
  • + {/each} +
+
+ +
+

{t('access.feedback')}

+ {#each fb.value?.feedback ?? [] as f (f.id)} +
+

{f.author ?? '—'} · {i18n.date(f.createdAt)} · {f.route}

+

{f.body}

+
+ {#if f.status === 'new'}{/if} + {#if f.status !== 'done'}{/if} +
+
+ {:else} +

{t('access.feedback.none')}

+ {/each} +
+
+{/if} + + diff --git a/web/src/routes/connexion/+page.svelte b/web/src/routes/connexion/+page.svelte new file mode 100644 index 0000000..5dc6588 --- /dev/null +++ b/web/src/routes/connexion/+page.svelte @@ -0,0 +1,100 @@ + + +
+

{t('signin.title')}

+ + {#if error} + + {/if} + +
+ {#if session.g1} + {t('signin.g1')} +

{t('signin.g1.how')}

+

{t('signin.g1.members')}

+ {:else} + +

{t('signin.g1.pending')}

+ {/if} +
+ +

{t('signin.invite')}

+ + {#if session.dev} +
+

{t('signin.dev')}

+ + + {#if devError}

{devError}

{/if} + +
+ {/if} +
+ + diff --git a/web/src/routes/decisions/+page.svelte b/web/src/routes/decisions/+page.svelte new file mode 100644 index 0000000..e06cf52 --- /dev/null +++ b/web/src/routes/decisions/+page.svelte @@ -0,0 +1,23 @@ + + +
+

{t('soon.title')}

+

{t('soon.body')}

+
+ + diff --git a/web/src/routes/g/[group]/+page.svelte b/web/src/routes/g/[group]/+page.svelte new file mode 100644 index 0000000..b5caad0 --- /dev/null +++ b/web/src/routes/g/[group]/+page.svelte @@ -0,0 +1,118 @@ + + +{#if data.error} + data.load()} /> +{:else if data.value} + {@const g = data.value.group} +
+ {t('nav.groups')} +

{g.name}

+ {#if !g.open} +

{t('groups.locked.long')}

+ {:else} +

{t('group.tools')}

+ + {/if} +
+{/if} + + diff --git a/web/src/routes/g/[group]/docs/+page.svelte b/web/src/routes/g/[group]/docs/+page.svelte new file mode 100644 index 0000000..c908415 --- /dev/null +++ b/web/src/routes/g/[group]/docs/+page.svelte @@ -0,0 +1,94 @@ + + +{#if data.error} + data.load()} /> +{:else if data.value} + {@const g = data.value.group} +
+ {g.name} +

{t('docs.title')}

+ {#if !g.open} +

{t('groups.locked.long')}

+ {:else if !data.value.docs.length} +

{t('group.docs.empty')}

+ {:else} + + {/if} +
+{/if} + + diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte new file mode 100644 index 0000000..8787c57 --- /dev/null +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -0,0 +1,302 @@ + + +{title ? `${title} · libreDecision` : 'libreDecision'} + +{#if doc.error} + doc.load(group, slug)} /> +{:else if view} +
+
+

+ {view.doc.groupName}{t('nav.docs')} +

+

{title}

+

{summary}

+

+ {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} +

+ +
+
+ {#each VIEWS as v (v)} + + {/each} +
+
+ {#each LANGS as l (l)} + + {/each} +
+
+ {#if lang !== source} +

{t('doc.lang.translation')}

+ {/if} + {#if doc.mode !== 'inforce'}

{t(`doc.view.${doc.mode}.note`)}

{/if} +
+ +
+ {#if doc.mode === 'inforce' || !doc.preview} + {#each doc.inForce as u (u.id)} + {#if u.inForce} + onUnit(u, tab)} + /> + {/if} + {:else} +

{t('doc.empty')}

+ {/each} + {:else} + {#each doc.preview as pu (pu.unitId)} + {@const u = doc.unit(pu.unitId)} + u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))} + /> + {/each} + {#if doc.preview.every((x) => x.mark === 'unchanged')}

{t('doc.view.empty')}

{/if} + {/if} +
+ + + +
+ {#if doc.drafts.length} +
+

{t('deep.drafts')}

+

{t('deep.drafts.note')}

+
    + {#each doc.drafts as { unit, proposal } (proposal.id)} +
  • + {/each} +
+
+ {/if} + +
+

{t('deep.title')}

+

{t('deep.note', { floor: view.doc.voteFloor })}

+
+ {#each ['all', 'wellrated', 'mine'] as const as f (f)} + + {/each} +
+
+ + +
+
+ + {#if panel} + openPanel(p)} onclose={() => openPanel(null)} /> + {/if} +{/if} + + e.key === 'Escape' && panel && openPanel(null)} /> + + diff --git a/web/src/routes/groupes/+page.svelte b/web/src/routes/groupes/+page.svelte new file mode 100644 index 0000000..7f9df67 --- /dev/null +++ b/web/src/routes/groupes/+page.svelte @@ -0,0 +1,139 @@ + + +{#if data.error} + data.load()} /> +{:else if data.value} +
+

{t('groups.title')}

+ + {#if root} + + Ğ1 + {root.name} + {#if !root.open}{t('groups.locked')}{/if} + + {/if} + + {#if sameKey.length} +

{t('groups.same.key')}

+
    + {#each sameKey as g (g.slug)} +
  • + {#if g.open} + {g.name} + {:else} +
    + {g.name} + {t('groups.locked')} +
    + {/if} +
  • + {/each} +
+ {#if sameKey.some((g) => !g.open)}

{t('groups.locked.long')}

{/if} + {/if} + + {#if others.length} +

{t('groups.test')}

+ + {/if} + + {#if !groups.length}

{t('groups.empty')}

{/if} +
+{/if} + + diff --git a/web/src/routes/invitation/+page.svelte b/web/src/routes/invitation/+page.svelte new file mode 100644 index 0000000..25d53ce --- /dev/null +++ b/web/src/routes/invitation/+page.svelte @@ -0,0 +1,47 @@ + + +
+

{t('invite.title')}

+

{t(`invite.${state}`)}

+
+ + diff --git a/web/src/routes/mandats/+page.svelte b/web/src/routes/mandats/+page.svelte new file mode 100644 index 0000000..e06cf52 --- /dev/null +++ b/web/src/routes/mandats/+page.svelte @@ -0,0 +1,23 @@ + + +
+

{t('soon.title')}

+

{t('soon.body')}

+
+ + diff --git a/web/src/routes/protocoles/+page.svelte b/web/src/routes/protocoles/+page.svelte new file mode 100644 index 0000000..e06cf52 --- /dev/null +++ b/web/src/routes/protocoles/+page.svelte @@ -0,0 +1,23 @@ + + +
+

{t('soon.title')}

+

{t('soon.body')}

+
+ + diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..5f352f5 --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/static/hexagram-tsing.svg b/web/static/hexagram-tsing.svg new file mode 100644 index 0000000..7385fb8 --- /dev/null +++ b/web/static/hexagram-tsing.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..7113885 --- /dev/null +++ b/web/svelte.config.js @@ -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' }, + }, +}; diff --git a/web/tests/unit/lexicon.test.ts b/web/tests/unit/lexicon.test.ts new file mode 100644 index 0000000..42fc195 --- /dev/null +++ b/web/tests/unit/lexicon.test.ts @@ -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(//g, '') + .replace(//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); + } + }); +}); diff --git a/web/tests/unit/markdown.test.ts b/web/tests/unit/markdown.test.ts new file mode 100644 index 0000000..9f8e5b6 --- /dev/null +++ b/web/tests/unit/markdown.test.ts @@ -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('')).toBe('

<script>alert(1)</script>

'); + expect(render('**x**')).toBe('

<b>x</b>

'); + }); + it('paragraphs, breaks, emphasis, code', () => { + expect(render('un **être humain** vivant\nligne\n\n*note*')).toBe( + '

un être humain vivant
ligne

note

', + ); + expect(render('`DU(n+1) = DU(n)`')).toBe('

DU(n+1) = DU(n)

'); + }); + it('lists', () => { + expect(render('- Stock de **100**\n- 5 jours')).toBe('
  • Stock de 100
  • 5 jours
'); + expect(render('1. un\n2. deux')).toBe('
  1. un
  2. deux
'); + }); + it('numbered paragraphs that are not lists stay paragraphs', () => { + expect(render('**1.** Derrière une clé')).toBe('

1. Derrière une clé

'); + }); + it('plain excerpts', () => { + expect(plain('**1.** Derrière\n- une clé')).toBe('1. Derrière une clé'); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a8fed3d --- /dev/null +++ b/web/tsconfig.json @@ -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" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..733fb3c --- /dev/null +++ b/web/vite.config.ts @@ -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' }, +});