v3 : l'entrée — accueil avec le choix du groupe, logo « Clé de voûte », quatre ambiances, menu et pages d'outil par groupe

- Accueil : le titre, le leitmotiv en trois lignes (contexte et cadrage ·
  processus et modalités · boîte à outils), puis directement le choix du
  groupe ; sans session, la connexion à sa place. /groupes redirige vers /,
  « Mes groupes » et le retour après connexion mènent à /.
- Logo « Clé de voûte » du DA (signe en currentColor, clé en --cle, libre
  Literata italique, Decision romain), droit, dans le menu et la barre
  mobile ; Stamp retiré, le sceau 井 ne bouge pas. Favicon, icônes 16/32/48,
  apple-touch, 192/512, manifeste.
- Quatre ambiances : pierre et calcaire (claires), nuit et ardoise
  (sombres), --cle par ambiance ; quatre pastilles à la place du soleil /
  lune ; première visite selon l'appareil (pierre / nuit). Calcaire et
  ardoise : textes, accent, mousse et avertissement à 4,5:1 au moins.
- Page d'un groupe : les quatre piliers, puis « En cours ».
- Menu : nombre de docs en cours à côté de « Docs de référence », surligné
  seulement dedans, seul Docs réagit au survol ; les trois autres éteints
  mais ouvrent leur page ; hors d'un groupe, le dernier groupe ouvert.
- /g/[group]/docs « En cours » + « À venir » ; /mandats et /protocoles du
  groupe : « À venir » éteints ; /g/[group]/decisions : une ligne par doc,
  « N modules à voter », « dont N sans ton vote ». Anciennes adresses
  /decisions, /mandats, /protocoles : vers le groupe courant, sinon /.
- e2e : C1, C2, C11, C12, M1 mis à jour ; C13–C17 pour L1–L6 ; CONTROLES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 11:21:57 +01:00
co-authored by Claude Opus 5.5
parent c640413a70
commit 81b604e5f2
48 changed files with 1323 additions and 613 deletions
+161
View File
@@ -0,0 +1,161 @@
import { expect, test, type Page } from '@playwright/test';
import { DOC, person, signIn } from './helpers';
const ML = '/g/monnaie-libre';
const path = (p: string) => (u: URL) => u.pathname === p;
async function background(page: Page, selector: string) {
return page.locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor);
}
test('C13 — logo « Clé de voûte » sans tampon, quatre ambiances, icônes', async ({ browser }) => {
// First visit: the device's light or dark setting picks pierre or nuit.
for (const [scheme, expected] of [['light', 'pierre'], ['dark', 'nuit']] as const) {
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: scheme });
const page = await ctx.newPage();
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-mood', expected);
await ctx.close();
}
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: 'light', viewport: { width: 1360, height: 900 } });
const page = await ctx.newPage();
await page.goto('/');
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
const brand = nav.locator('a.brand');
await expect(brand).toHaveAccessibleName('libreDecision');
await expect(brand.locator('svg')).toBeVisible();
await expect(page.locator('.stamp')).toHaveCount(0);
expect(await brand.locator('svg').evaluate((el) => getComputedStyle(el).transform)).toBe('none');
// The seal stays where it is.
await expect(page.locator('svg.seal')).toBeVisible();
const moods = nav.getByRole('radiogroup', { name: 'Ambiance' });
for (const [name, mood, cle] of [
['Calcaire', 'calcaire', '#a8661e'],
['Ardoise', 'ardoise', '#e0a65c'],
['Nuit', 'nuit', '#6ec1c6'],
['Pierre', 'pierre', '#16808c'],
]) {
await moods.getByRole('radio', { name }).click();
await expect(page.locator('html')).toHaveAttribute('data-mood', mood);
await expect(moods.getByRole('radio', { name })).toHaveAttribute('aria-checked', 'true');
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--cle').trim())).toBe(cle);
}
await moods.getByRole('radio', { name: 'Ardoise' }).click();
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-mood', 'ardoise');
// Favicon and icons.
for (const href of ['/favicon.svg', '/apple-touch-icon.png', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest']) {
expect((await page.request.get(href)).status(), href).toBe(200);
}
await expect(page.locator('link[rel="icon"][type="image/svg+xml"]')).toHaveAttribute('href', /favicon\.svg$/);
await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveAttribute('href', /apple-touch-icon\.png$/);
await ctx.close();
});
test('C14 — la page d’un groupe : les quatre piliers, puis « En cours »', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(ML);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Monnaie libre');
const pillars = page.locator('.pillars .pillar');
await expect(pillars).toHaveCount(4);
for (const name of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
await expect(pillars.filter({ hasText: name })).toHaveCount(1);
}
await expect(pillars.filter({ hasText: 'Docs de référence' })).toContainText('ouvert');
await expect(pillars.filter({ hasText: 'à venir' })).toHaveCount(3);
const current = page.locator('section', { has: page.getByRole('heading', { name: 'En cours' }) });
await current.getByRole('link', { name: /Acte d'engagement Certification/ }).click();
await expect(page).toHaveURL(path(DOC));
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
});
test('C15 — le menu : le nombre de docs, surligné seulement dedans, seul Docs réagit au survol', async ({ browser }) => {
const page = await person(browser, 'Lina');
// Nothing remembered yet: the tools lead home.
await page.goto('/');
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
const tool = (name: string) => nav.locator('.tool', { hasText: name });
await expect(tool('Docs de référence')).toHaveAttribute('href', '/');
await page.goto(ML);
await expect(tool('Docs de référence').locator('.count')).toHaveText('1');
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
await expect(nav.locator('.tool.off')).toHaveCount(3);
// Hover: Docs reacts, a tool to come does not.
const docsBefore = await background(page, 'nav .tool.docs');
await tool('Docs de référence').hover();
await expect.poll(() => background(page, 'nav .tool.docs')).not.toBe(docsBefore);
const mandatesBefore = await background(page, 'nav .tool.mandats');
await tool('Mandats').hover();
await page.waitForTimeout(400);
expect(await background(page, 'nav .tool.mandats')).toBe(mandatesBefore);
// Inside a tool, it alone is highlighted.
await tool('Docs de référence').click();
await expect(page).toHaveURL(path(`${ML}/docs`));
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
await page.goto(DOC);
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
// A tool to come opens its page, which shows what is coming, dimmed.
await tool('Mandats').click();
await expect(page).toHaveURL(path(`${ML}/mandats`));
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Mandats/);
// Outside a group, the tools aim at the last group opened.
await page.goto('/');
await expect(tool('Docs de référence')).toHaveAttribute('href', `${ML}/docs`);
await expect(tool('Protocoles')).toHaveAttribute('href', `${ML}/protocoles`);
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
});
test('C16 — Docs, Mandats, Protocoles : « En cours » et « À venir », éteints', async ({ browser }) => {
const page = await person(browser, 'Noé');
// Old addresses, before any group: home.
await page.goto('/mandats');
await expect(page).toHaveURL(path('/'));
await page.goto(`${ML}/docs`);
const section = (name: string) => page.locator('section', { has: page.getByRole('heading', { name, exact: true }) });
await expect(section('En cours').getByRole('link', { name: /Acte d'engagement Certification/ })).toBeVisible();
const soon = section('À venir');
await expect(soon.locator('li')).toHaveText(["Acte d'engagement forgerons"]);
await expect(soon.getByRole('link')).toHaveCount(0);
await page.goto(`${ML}/mandats`);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Mandats');
await expect(section('À venir').locator('li')).toHaveText(['Administration forum Duniter', 'Administration forum Monnaie libre']);
await expect(section('À venir').getByRole('link')).toHaveCount(0);
await page.goto(`${ML}/protocoles`);
await expect(section('À venir').locator('li')).toHaveText(['Runtime upgrade — décision on-chain']);
// Old addresses lead to the tools of the current group.
for (const tool of ['decisions', 'mandats', 'protocoles']) {
await page.goto(`/${tool}`);
await expect(page).toHaveURL(path(`${ML}/${tool}`));
}
// In English and Spanish, what is coming follows.
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('button', { name: 'ES' }).click();
await page.goto(`${ML}/protocoles`);
await expect(page.locator('section.soon li')).toHaveText(['Runtime upgrade — decisión on-chain']);
await page.getByRole('navigation', { name: 'Caja de herramientas' }).getByRole('button', { name: 'FR' }).click();
});
test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans ton vote', async ({ browser }) => {
const page = await person(browser, 'Maëlle');
await page.goto(`${ML}/decisions`);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Décisions');
const rows = page.locator('ul.lines > li');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("Acte d'engagement Certification");
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 33 sans ton vote');
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
// counts as much, and leaves the previews of C9 unchanged (the server is shared).
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
data: { choice: 'against' },
headers: { 'x-requested-with': 'libredecision' },
});
expect(r.ok()).toBe(true);
await page.reload();
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 32 sans ton vote');
await rows.first().getByRole('link').click();
await expect(page).toHaveURL(path(DOC));
});
+8 -1
View File
@@ -1,12 +1,19 @@
import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers';
test('M1 — mobile : menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/');
const brand = page.locator('header.topbar a.brand');
await expect(brand).toHaveAccessibleName('libreDecision');
await expect(brand.locator('svg')).toBeVisible();
await expect(page.locator('a.root')).toContainText('Monnaie libre');
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 expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1');
await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4);
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();
+19 -7
View File
@@ -1,25 +1,37 @@
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 }) => {
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, 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();
await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']);
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(nav.locator('.tool.off')).toHaveCount(3);
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
// The four pillars live on the page of a group, no longer here.
await expect(page.locator('.pillars')).toHaveCount(0);
await expect(page.getByText('Monnaie libre', { exact: true })).toHaveCount(0);
});
test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({ page }) => {
test('C2 — l’accueil avec session : le choix du groupe, ceux de la même clé ; pas l’Atelier du canal', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/groupes');
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
await expect(page.locator('a.root')).toContainText('Monnaie libre');
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);
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0);
// The old address and « Mes groupes » lead here.
await page.goto('/groupes');
await expect(page).toHaveURL((u) => u.pathname === '/');
await page.goto('/g/monnaie-libre');
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Mes groupes' }).click();
await expect(page).toHaveURL((u) => u.pathname === '/');
});
test('C3 — le texte en vigueur : préambule et deux engagements, seed a minima', async ({ page }) => {
@@ -144,7 +156,7 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
const ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage();
await invited.goto(`/invitation#${link.split('#')[1]}`);
await expect(invited).toHaveURL(/\/groupes$/);
await expect(invited).toHaveURL((u) => u.pathname === '/');
await invited.goto(DOC);
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
const panel = invited.locator('aside.panel');
@@ -172,7 +184,7 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
await page.getByLabel('Ton nom').fill(name);
await page.getByRole('button', { name: 'Rejoindre' }).click();
await expect(page).toHaveURL(/\/groupes$/);
await expect(page).toHaveURL((u) => u.pathname === '/');
await ctx.close();
}
// A shared browser: the person signed in steps aside, the next one gives a name.
+55 -2
View File
@@ -1,5 +1,8 @@
/* libreDecision v3 — tokens and base.
Two moods: « pierre » (cool stone, light) and « nuit » (well at night, dark).
Four moods, two light and two dark: « pierre » (cool stone) and « nuit » (well at night)
carry the water; « calcaire » (warm limestone) and « ardoise » (dark slate) carry the ochre.
`--water` is the accent of the mood (links, buttons, current place), whatever its hue;
`--cle` is the keystone of the logo (Clé de voûte, DA 2026-10-28).
Borderless: depth comes from background and shadow, never from strokes.
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
@@ -23,12 +26,38 @@
--ochre: #a8661e;
--ochre-wash: rgb(168 102 30 / 0.12);
--on-water: #f6f8f7;
--cle: #16808c;
--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;
}
/* Calcaire: warm limestone, the ochre. The accent is darker than the keystone
(#a8661e, 3.5:1) so that a link or a label reads at 4.5:1 or more; warnings
turn to brick, away from the accent. */
[data-mood='calcaire'] {
--bg: #f2ede4;
--surface: #fbf8f2;
--surface-2: #e8e0d2;
--ink: #2a241e;
--ink-soft: #5c5247;
--ink-faint: #857868;
--water: #8a5214;
--water-light: #e6c79c;
--water-wash: rgb(138 82 20 / 0.08);
--moss: #446b38;
--moss-wash: rgb(68 107 56 / 0.12);
--ochre: #a3432b;
--ochre-wash: rgb(163 67 43 / 0.1);
--on-water: #fbf8f2;
--cle: #a8661e;
--shadow: 0 1px 2px rgb(60 42 20 / 0.06), 0 8px 28px rgb(60 42 20 / 0.07);
--shadow-strong: 0 2px 6px rgb(60 42 20 / 0.1), 0 18px 48px rgb(60 42 20 / 0.16);
--veil: rgb(42 32 20 / 0.28);
color-scheme: light;
}
[data-mood='nuit'] {
--bg: #0e1819;
--surface: #142224;
@@ -44,12 +73,36 @@
--ochre: #e0a458;
--ochre-wash: rgb(224 164 88 / 0.12);
--on-water: #0e1819;
--cle: #6ec1c6;
--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;
}
/* Ardoise: dark slate, the ochre; warnings in a light brick. */
[data-mood='ardoise'] {
--bg: #171b20;
--surface: #1d2228;
--surface-2: #262c34;
--ink: #e4e6e2;
--ink-soft: #a9afb5;
--ink-faint: #7a828b;
--water: #e0a65c;
--water-light: #6b4c26;
--water-wash: rgb(224 166 92 / 0.09);
--moss: #93c08a;
--moss-wash: rgb(147 192 138 / 0.12);
--ochre: #e98d72;
--ochre-wash: rgb(233 141 114 / 0.12);
--on-water: #171b20;
--cle: #e0a65c;
--shadow: 0 1px 2px rgb(0 0 0 / 0.32), 0 8px 28px rgb(0 0 0 / 0.3);
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.42), 0 18px 48px rgb(0 0 0 / 0.52);
--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;
@@ -166,7 +219,7 @@ button {
.btn.primary {
background: var(--water);
color: var(--on-water);
box-shadow: 0 6px 18px rgb(31 111 120 / 0.22);
box-shadow: 0 6px 18px color-mix(in srgb, var(--water) 22%, transparent);
}
.btn.quiet {
background: transparent;
+18 -6
View File
@@ -4,15 +4,27 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#e9eeec" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="%sveltekit.assets%/favicon-48.png" type="image/png" sizes="48x48" />
<link rel="icon" href="%sveltekit.assets%/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="icon" href="%sveltekit.assets%/favicon-16.png" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" sizes="180x180" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<title>libreDecision</title>
<script>
// Mood before first paint: no flash.
try {
const m = localStorage.getItem('ld:mood');
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre';
} catch { document.documentElement.dataset.mood = 'pierre'; }
// Mood before first paint: no flash. Four moods, two light (pierre, calcaire) and two dark
// (nuit, ardoise); a first visit follows the device's light or dark setting.
(() => {
const bg = { pierre: '#e9eeec', calcaire: '#f2ede4', nuit: '#0e1819', ardoise: '#171b20' };
let mood = 'pierre';
try {
const saved = localStorage.getItem('ld:mood');
mood = Object.hasOwn(bg, saved ?? '') ? saved : matchMedia('(prefers-color-scheme: dark)').matches ? 'nuit' : 'pierre';
} catch {}
document.documentElement.dataset.mood = mood;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg[mood]);
})();
</script>
%sveltekit.head%
</head>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
// The documents in progress of a group, each a way into its reader.
import type { GroupView } from '$lib/group';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { LangMap } from '$lib/types';
let { group, docs, summary = true }: { group: string; docs: GroupView['docs']; summary?: boolean } = $props();
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if docs.length}
<ul class="docs">
{#each docs as d (d.id)}
<li>
<a class="doc card" href="/g/{group}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
{#if summary}<span class="summary">{pick(d.summary)}</span>{/if}
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="muted">{t('group.current.none')}</p>
{/if}
<style>
.docs {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.doc {
display: grid;
gap: 0.45rem;
padding: 1.35rem 1.5rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.doc:hover {
transform: translateY(-3px);
}
.title {
font-family: var(--font-text);
font-size: 1.4rem;
font-weight: 500;
}
.summary {
color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
// The choice of a group: Monnaie libre first and large, then the groups that use the same
// key, then the test groups; a group one does not open shows its lock.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupSummary } from '$lib/types';
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
onMount(() => void data.load());
const all = $derived(data.value?.groups ?? []);
const root = $derived(all.find((g) => g.slug === 'monnaie-libre'));
const sameKey = $derived(all.filter((g) => g.parent === 'monnaie-libre'));
const others = $derived(all.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
</script>
<section class="choice" aria-labelledby="groups-title">
<h2 class="eyebrow" id="groups-title">{t('groups.title')}</h2>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{#if root}
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
<span class="eyebrow">Ğ1</span>
<span class="name">{root.name}</span>
{#if root.open}<span class="go" aria-hidden="true"><Icon name="chevron" size={20} /></span>{/if}
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
</a>
{/if}
{#if sameKey.length}
<h3 class="eyebrow sub">{t('groups.same.key')}</h3>
<ul class="list">
{#each sameKey as g (g.slug)}
<li>
{#if g.open}
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
{:else}
<div class="row card locked" title={t('groups.locked.long')}>
<span class="name">{g.name}</span>
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
{/if}
{#if others.length}
<h3 class="eyebrow sub">{t('groups.test')}</h3>
<ul class="list">
{#each others as g (g.slug)}
<li>
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
</a>
</li>
{/each}
</ul>
{/if}
{#if !all.length}<p class="muted">{t('groups.empty')}</p>{/if}
{/if}
</section>
<style>
.choice {
display: grid;
gap: 1rem;
}
.choice > .eyebrow {
color: var(--ink-soft);
}
.root {
position: relative;
display: grid;
gap: 0.3rem;
padding: 1.6rem 1.6rem 1.7rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
a.root[href]:hover,
a.row[href]:hover {
transform: translateY(-3px);
}
.root .name {
font-family: var(--font-text);
font-size: clamp(1.7rem, 1.4rem + 1.2vw, 2.2rem);
font-weight: 500;
}
.go {
position: absolute;
right: 1.4rem;
top: 50%;
translate: 0 -50%;
color: var(--water);
}
.sub {
margin-top: 1rem;
color: var(--ink-soft);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.row .name {
font-weight: 750;
}
.locked {
background: transparent;
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.lock {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.82rem;
font-weight: 700;
}
.note {
font-size: 0.88rem;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
// The logotype « Clé de voûte » (DA, validated by Yvv on 2026-10-28): the sign — stones in
// the ink of the mood, the keystone in `--cle` — then *libre* in Literata italic, the colour
// of the stones, and **Decision** in roman, the colour of the keystone. Never tilted.
// Proportions of the DA's lockup (prod_cle.py), for a font size S: the stones run from 0.98 S
// above the baseline to 0.06 S below it, then a gap of 0.3 S. The SVG keeps the DA's 3-unit
// margin around 59 units of stone height, hence the sizes below.
import sign from '$lib/assets/cle-de-voute.svg?raw';
let { size = 1.45 }: { size?: number } = $props();
</script>
<span class="logo" style:--s="{size}rem">
<span class="sign" aria-hidden="true">{@html sign}</span><span class="word"><span class="libre">libre</span><span class="decision">Decision</span></span>
</span>
<style>
.logo {
display: inline-block;
font-size: var(--s);
line-height: 1.2;
white-space: nowrap;
}
.sign {
display: inline-block;
color: var(--ink);
/* 1.04 S of stones, plus the 3-unit margin above and below (65 / 59); the bottom of the
stones 0.06 S under the baseline. */
height: calc(1.04em * 65 / 59);
margin-right: calc(0.3em - 1.04em * 3 / 59);
vertical-align: calc(-0.06em - 1.04em * 3 / 59);
}
.sign :global(svg) {
display: block;
height: 100%;
width: auto;
}
.word {
font-family: var(--font-text);
font-variation-settings: 'opsz' 72;
}
.libre {
font-style: italic;
font-weight: 420;
letter-spacing: -0.004em;
color: var(--ink);
}
.decision {
font-weight: 620;
letter-spacing: -0.006em;
margin-left: 0.02em;
color: var(--cle);
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
// The four pillars of a group: name, one sentence, « ouvert » or « à venir ». Each leads to its
// tool; the open one answers the pointer, the others stay dim, like in the menu.
import { TOOLS, type Tool } from '$lib/group';
import { t, type Key } from '$lib/i18n/index.svelte';
let { group, tools }: { group: string; tools: { key: string; active: boolean }[] } = $props();
const NAMES: Record<Tool, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const TEXTS: Record<Tool, Key> = {
decisions: 'pillar.decisions',
docs: 'pillar.docs',
mandats: 'pillar.mandates',
protocoles: 'pillar.protocols',
};
const active = $derived(new Set(tools.filter((x) => x.active).map((x) => x.key)));
</script>
<ul class="pillars">
{#each TOOLS as tool (tool)}
{@const open = active.has(tool)}
<li>
<a class="pillar" class:open href="/g/{group}/{tool}">
<span class="head">
<h3>{t(NAMES[tool])}</h3>
<span class="tag" class:water={open}>{open ? t('pillar.open') : t('pillar.soon')}</span>
</span>
<span class="text">{t(TEXTS[tool])}</span>
</a>
</li>
{/each}
</ul>
<style>
.pillars {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--surface-2);
border-radius: var(--r-card);
overflow: hidden;
box-shadow: var(--shadow);
}
li {
display: grid;
}
.pillar {
display: grid;
gap: 0.55rem;
align-content: start;
padding: 1.4rem 1.5rem 1.5rem;
background: var(--surface);
color: var(--ink-faint);
text-decoration: none;
transition: background-color 0.15s ease;
}
.pillar.open {
color: var(--ink-soft);
}
.pillar.open:hover {
background: color-mix(in srgb, var(--water) 5%, var(--surface));
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
h3 {
font-size: 1.08rem;
font-weight: 800;
color: var(--ink-soft);
}
.open h3 {
color: var(--ink);
}
.text {
font-size: 0.96rem;
line-height: 1.55;
}
@media (max-width: 640px) {
.pillars {
grid-template-columns: 1fr;
}
}
</style>
+112 -79
View File
@@ -1,61 +1,70 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { TOOLS, type Tool } from '$lib/group';
import { groups, toolHref } from '$lib/groups.svelte';
import { i18n, LANGS, t, type Key } from '$lib/i18n/index.svelte';
import { MOODS, mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
import Icon from './Icon.svelte';
import Stamp from './Stamp.svelte';
import Logo from './Logo.svelte';
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
const group = $derived(page.params.group ?? null);
const docsHref = $derived(
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
);
const LABELS: Record<Tool, Key> = {
decisions: 'nav.decisions',
docs: 'nav.docs',
mandats: 'nav.mandates',
protocoles: 'nav.protocols',
};
const path = $derived(page.url.pathname);
const tools = $derived([
{ key: 'decisions', label: t('nav.decisions'), href: null },
{ key: 'docs', label: t('nav.docs'), href: docsHref },
{ key: 'mandats', label: t('nav.mandates'), href: null },
{ key: 'protocoles', label: t('nav.protocols'), href: null },
]);
/** The group of the page, if any. */
const here = $derived(page.params.group ?? null);
/** Whose tools the menu shows: the group of the page, or else the last one opened. */
const target = $derived(here ?? (session.me ? groups.last : null));
$effect(() => {
if (session.me && target) groups.ensure(target);
});
const view = $derived(target ? groups.views[target] : undefined);
/** Documents in progress in that group, next to « Docs de référence ». */
const count = $derived(view?.group.open ? view.docs.length : null);
/** A tool is highlighted only when one is in it. */
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
async function signOut() {
await session.logout();
groups.reset();
onnavigate?.();
await goto('/');
}
</script>
<nav class="sidebar" aria-label={t('nav.label')}>
<a class="brand" href="/" onclick={() => onnavigate?.()}>
<Stamp />
<span class="word">libre<b>Decision</b></span>
</a>
<a class="brand" href="/" onclick={() => onnavigate?.()}><Logo /></a>
{#if session.me}
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
<span class="eyebrow">{t('nav.groups')}</span>
</a>
{/if}
<ul class="tools">
{#each tools as tool (tool.key)}
{#each TOOLS as tool (tool)}
{@const on = inTool(tool)}
<li>
{#if tool.href}
<a
href={tool.href}
class="tool"
class:current={path.includes('/docs')}
aria-current={path.includes('/docs') ? 'page' : undefined}
onclick={() => onnavigate?.()}>{tool.label}</a
>
{:else}
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
{tool.label}<span class="soon">{t('nav.soon')}</span>
</span>
{/if}
<a
href={toolHref(tool, target)}
class="tool {tool}"
class:off={tool !== 'docs'}
class:current={on}
aria-current={on ? 'page' : undefined}
onclick={() => onnavigate?.()}
>
<span>{t(LABELS[tool])}</span>
{#if tool === 'docs' && count !== null}
<span class="count" aria-hidden="true">{count}</span><span class="visually-hidden">{i18n.plural('nav.docs.count', count)}</span>
{/if}
</a>
</li>
{/each}
</ul>
@@ -76,15 +85,21 @@
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each}
</div>
<button
class="icon-btn"
type="button"
onclick={() => mood.toggle()}
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
>
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
</button>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
{#each MOODS as m (m)}
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
<button
type="button"
role="radio"
class="swatch"
data-mood={m}
aria-checked={mood.current === m}
aria-label={t(`mood.${m}`)}
title={t(`mood.${m}`)}
onclick={() => mood.set(m)}
></button>
{/each}
</div>
</div>
{#if session.me}
@@ -109,17 +124,8 @@
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
color: var(--ink);
text-decoration: none;
padding: 0.2rem 0.4rem;
}
.word {
font-size: 1.08rem;
letter-spacing: 0.01em;
}
.word b {
font-weight: 800;
text-decoration: none;
}
.groups {
padding: 0 0.6rem;
@@ -135,35 +141,47 @@
display: grid;
gap: 0.15rem;
}
/* The four tools look alike; only « Docs de référence » is lit and answers the pointer.
The three others stay dim, without any hover effect, and still lead to their page. */
.tool {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
min-height: 2.5rem;
padding: 0.5rem 0.7rem;
border-radius: var(--r-input);
color: var(--ink);
font-weight: 700;
text-decoration: none;
transition: background-color 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease;
}
a.tool:hover {
.tool.off {
color: var(--ink-faint);
}
.tool.docs:hover,
.tool.current {
background: var(--water-wash);
}
a.tool.current {
background: var(--water-wash);
.tool.docs.current {
color: var(--water);
}
.tool.disabled {
color: var(--ink-faint);
font-weight: 600;
cursor: default;
.tool.off.current {
color: var(--ink-soft);
}
.soon {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--ink-faint);
.count {
min-width: 1.6rem;
padding: 0.05rem 0.45rem;
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink-soft);
font-size: 0.78rem;
font-weight: 800;
text-align: center;
}
.tool.current .count {
background: var(--surface);
color: var(--water);
}
.foot {
margin-top: auto;
@@ -197,6 +215,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.2rem 0.3rem;
}
.langs {
@@ -209,7 +228,7 @@
border: 0;
background: transparent;
border-radius: var(--r-pill);
min-width: 2.25rem;
min-width: 2.05rem;
min-height: 2rem;
font-size: 0.78rem;
font-weight: 800;
@@ -220,19 +239,33 @@
color: var(--water);
box-shadow: var(--shadow);
}
.icon-btn {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: var(--ink-soft);
.moods {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.icon-btn:hover {
background: var(--water-wash);
color: var(--ink);
.swatch {
position: relative;
width: 1.3rem;
height: 1.3rem;
padding: 0;
border: 0;
border-radius: 50%;
/* var(--bg), var(--ink) and var(--cle) are those of the swatch's own mood. */
background: var(--bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent);
}
.swatch::after {
content: '';
position: absolute;
inset: 32%;
border-radius: 50%;
background: var(--cle);
}
.swatch[aria-checked='true'] {
/* currentColor is the ink of the page, inherited. */
outline: 2px solid currentColor;
outline-offset: 2px;
}
.who {
display: flex;
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
// « À venir »: what is announced in a tool of the group — dim, and not a way in yet.
import type { UpcomingItem } from '$lib/group';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { LangMap } from '$lib/types';
let { items }: { items: UpcomingItem[] } = $props();
const id = $props.id();
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
<section class="soon" aria-labelledby="{id}-soon">
<h2 class="eyebrow" id="{id}-soon">{t('group.upcoming')}</h2>
{#if items.length}
<ul>
{#each items as item (item.key)}
<li>{pick(item.title)}</li>
{/each}
</ul>
{:else}
<p class="faint">{t('group.upcoming.none')}</p>
{/if}
</section>
<style>
.soon {
display: grid;
gap: 0.8rem;
}
h2 {
color: var(--ink-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
li {
padding: 1.05rem 1.5rem;
border-radius: var(--r-card);
background: color-mix(in srgb, var(--surface) 45%, transparent);
color: var(--ink-faint);
font-family: var(--font-text);
font-size: 1.2rem;
cursor: default;
}
</style>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
let { size = 2.2 }: { size?: number } = $props();
const lines: boolean[] = [false, true, true, false, true, false];
</script>
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
<svg viewBox="0 0 112 105">
{#each lines as yang, i (i)}
{@const y = 91 - i * 17}
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
{/each}
</svg>
</span>
<style>
.stamp {
display: grid;
place-items: center;
flex: none;
width: var(--s);
height: var(--s);
border-radius: var(--r-icon);
background: var(--water-wash);
transform: rotate(-10deg);
transition: transform 0.2s var(--ease);
}
svg {
width: 56%;
fill: var(--water);
}
:global(a:hover) > .stamp {
transform: rotate(-2deg) scale(1.06);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
// The page of a tool not open yet in a group (Mandats, Protocoles): what it is for, and what is
// announced there, dim.
import { page } from '$app/state';
import Guard from '$lib/components/Guard.svelte';
import SoonList from '$lib/components/SoonList.svelte';
import { groups } from '$lib/groups.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
let { tool }: { tool: 'mandats' | 'protocoles' } = $props();
const NAMES: Record<typeof tool, Key> = { mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const TEXTS: Record<typeof tool, Key> = { mandats: 'pillar.mandates', protocoles: 'pillar.protocols' };
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => groups.load(slug));
$effect(() => {
void slug;
void data.load();
});
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
<h1>{t(NAMES[tool])}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<p class="lead">{t(TEXTS[tool])}</p>
<SoonList items={data.value.upcoming[tool]} />
{/if}
</div>
{/if}
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
color: var(--ink-soft);
}
.lead {
color: var(--ink-faint);
max-width: 44ch;
line-height: 1.55;
margin-bottom: 1.8rem;
}
</style>
+17
View File
@@ -1,7 +1,24 @@
import type { LangMap } from './types';
/** The tools of a group, in the order of the menu. */
export const TOOLS = ['decisions', 'docs', 'mandats', 'protocoles'] as const;
export type Tool = (typeof TOOLS)[number];
/** Announced in a tool, not open yet (`seeds/upcoming/<group>.json` on the server). */
export interface UpcomingItem {
key: string;
title: LangMap;
}
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 }[];
upcoming: { docs: UpcomingItem[]; mandats: UpcomingItem[]; protocoles: UpcomingItem[] };
}
/** « Décisions »: one line per document in progress. */
export interface DecisionsView {
canVote: boolean;
docs: { id: string; slug: string; title: LangMap; toVote: number; withoutMyVote: number | null }[];
}
+93
View File
@@ -0,0 +1,93 @@
// The groups as the menu and the pages share them: the last group opened on this
// device (outside a group, the tools of the menu aim at it), and the views
// already read (the menu counts the documents of the current group).
import { api, ApiError } from './api';
import type { GroupView, Tool } from './group';
const KEY = 'ld:group';
/** The last group opened, read straight from storage (usable before any mount). */
export function lastGroup(): string | null {
try {
return localStorage.getItem(KEY);
} catch {
return null;
}
}
/** Where a tool leads: its page in the group, or home when no group is known. */
export function toolHref(tool: Tool, group: string | null): string {
return group ? `/g/${group}/${tool}` : '/';
}
class Groups {
last = $state<string | null>(null);
views = $state<Record<string, GroupView>>({});
private asked = new Set<string>();
private running = new Map<string, Promise<GroupView>>();
init() {
this.last = lastGroup();
}
/** Read a group (a read already under way is shared); one that opens becomes the last group. */
load(slug: string): Promise<GroupView> {
const running = this.running.get(slug);
if (running) return running;
this.asked.add(slug);
const p: Promise<GroupView> = this.fetch(slug).finally(() => {
if (this.running.get(slug) === p) this.running.delete(slug);
});
this.running.set(slug, p);
return p;
}
private async fetch(slug: string): Promise<GroupView> {
try {
const v = await api.get<GroupView>(`/groups/${slug}`);
this.views[slug] = v;
if (v.group.open) this.remember(slug);
else if (this.last === slug) this.forget();
return v;
} catch (e) {
// A group that no longer shows or opens is no longer the last group; a network hiccup keeps it.
if (this.last === slug && e instanceof ApiError && (e.code === 'not_found' || e.code === 'forbidden')) this.forget();
throw e;
}
}
/** Read a group once, for the menu; errors stay silent there. */
ensure(slug: string) {
if (this.views[slug] || this.asked.has(slug)) return;
this.load(slug).catch(() => {});
}
remember(slug: string) {
this.last = slug;
try {
localStorage.setItem(KEY, slug);
} catch {
/* no storage: the menu simply leads home */
}
}
/** On sign-out, or when the last group no longer opens. */
forget() {
this.last = null;
try {
localStorage.removeItem(KEY);
} catch {
/* no storage */
}
}
/** On sign-out: nothing read under the previous identity is kept. */
reset() {
this.forget();
this.views = {};
this.asked.clear();
this.running.clear();
}
}
export const groups = new Groups();
+27 -15
View File
@@ -10,30 +10,29 @@ export const en: Messages = {
'nav.docs': 'Reference documents',
'nav.mandates': 'Mandates',
'nav.protocols': 'Protocols',
'nav.soon': 'coming soon',
'nav.groups': 'My groups',
'nav.docs.count.one': '{n} document in progress',
'nav.docs.count.other': '{n} documents in progress',
'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.mood': 'Mood',
'mood.pierre': 'Stone',
'mood.calcaire': 'Limestone',
'mood.nuit': 'Night',
'mood.ardoise': 'Slate',
'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.leitmotiv.context': 'context and framing',
'home.leitmotiv.process': 'processes and methods',
'home.leitmotiv.tools': 'a toolbox',
'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',
@@ -77,8 +76,23 @@ export const en: Messages = {
'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.',
'group.current': 'In progress',
'group.current.none': 'No document in progress in this group yet.',
'group.upcoming': 'Coming up',
'group.upcoming.none': 'Nothing is announced here yet.',
'pillar.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
'pillar.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
'pillar.mandates': 'Create and revoke any kind of mandate.',
'pillar.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
'pillar.open': 'open',
'pillar.soon': 'coming soon',
'decisions.note': 'A module is an article of the document. It remains to be voted on until one of its versions has been adopted by a vote.',
'decisions.toVote.one': '{n} module to vote on',
'decisions.toVote.other': '{n} modules to vote on',
'decisions.withoutMyVote.one': 'of which {n} without your vote',
'decisions.withoutMyVote.other': 'of which {n} without your vote',
'docs.title': 'Reference documents',
'docs.inForce.one': '{n} article in force',
@@ -331,8 +345,6 @@ export const en: Messages = {
'access.publication.queued': 'Publication started again.',
'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.",
+27 -15
View File
@@ -10,30 +10,29 @@ export const es: Messages = {
'nav.docs': 'Documentos de referencia',
'nav.mandates': 'Mandatos',
'nav.protocols': 'Protocolos',
'nav.soon': 'próximamente',
'nav.groups': 'Mis grupos',
'nav.docs.count.one': '{n} documento en curso',
'nav.docs.count.other': '{n} documentos en curso',
'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.mood': 'Ambiente',
'mood.pierre': 'Piedra',
'mood.calcaire': 'Caliza',
'mood.nuit': 'Noche',
'mood.ardoise': 'Pizarra',
'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.leitmotiv.context': 'contexto y encuadre',
'home.leitmotiv.process': 'procesos y modalidades',
'home.leitmotiv.tools': 'caja de herramientas',
'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',
@@ -77,8 +76,23 @@ export const es: Messages = {
'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.',
'group.current': 'En curso',
'group.current.none': 'Por ahora no hay ningún documento en curso en este grupo.',
'group.upcoming': 'Próximamente',
'group.upcoming.none': 'Por ahora no hay nada anunciado aquí.',
'pillar.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
'pillar.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
'pillar.mandates': 'Crear y revocar todo tipo de mandato.',
'pillar.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
'pillar.open': 'abierto',
'pillar.soon': 'próximamente',
'decisions.note': 'Un módulo es un artículo del documento. Queda por votar mientras ninguna de sus versiones haya sido adoptada por votación.',
'decisions.toVote.one': '{n} módulo por votar',
'decisions.toVote.other': '{n} módulos por votar',
'decisions.withoutMyVote.one': 'de ellos, {n} sin tu voto',
'decisions.withoutMyVote.other': 'de ellos, {n} sin tu voto',
'docs.title': 'Documentos de referencia',
'docs.inForce.one': '{n} artículo en vigor',
@@ -331,8 +345,6 @@ export const es: Messages = {
'access.publication.queued': 'Publicación relanzada.',
'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.',
+27 -15
View File
@@ -13,30 +13,29 @@ export const fr = {
'nav.docs': 'Docs de référence',
'nav.mandates': 'Mandats',
'nav.protocols': 'Protocoles',
'nav.soon': 'à venir',
'nav.groups': 'Mes groupes',
'nav.docs.count.one': '{n} doc en cours',
'nav.docs.count.other': '{n} docs en cours',
'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.mood': 'Ambiance',
'mood.pierre': 'Pierre',
'mood.calcaire': 'Calcaire',
'mood.nuit': 'Nuit',
'mood.ardoise': 'Ardoise',
'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.leitmotiv.context': 'contexte et cadrage',
'home.leitmotiv.process': 'processus et modalités',
'home.leitmotiv.tools': 'boîte à outils',
'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',
@@ -80,8 +79,23 @@ export const fr = {
'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.",
'group.current': 'En cours',
'group.current.none': "Aucun doc en cours dans ce groupe pour l'instant.",
'group.upcoming': 'À venir',
'group.upcoming.none': "Rien n'est annoncé ici pour l'instant.",
'pillar.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
'pillar.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
'pillar.mandates': 'Créer et révoquer tout type de mandat.',
'pillar.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
'pillar.open': 'ouvert',
'pillar.soon': 'à venir',
'decisions.note': "Un module est un article du doc. Il reste à voter tant qu'aucune de ses versions n'a été adoptée par un vote.",
'decisions.toVote.one': '{n} module à voter',
'decisions.toVote.other': '{n} modules à voter',
'decisions.withoutMyVote.one': 'dont {n} sans ton vote',
'decisions.withoutMyVote.other': 'dont {n} sans ton vote',
'docs.title': 'Docs de référence',
'docs.inForce.one': '{n} article en vigueur',
@@ -334,8 +348,6 @@ export const fr = {
'access.publication.queued': 'Publication relancée.',
'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.",
+15 -7
View File
@@ -1,19 +1,27 @@
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
export type Mood = 'pierre' | 'nuit';
// Four moods, two light and two dark: « pierre » and « nuit » (the water),
// « calcaire » and « ardoise » (the ochre). Set before the first paint by
// app.html; a first visit follows the device's light or dark setting.
export const MOODS = ['pierre', 'calcaire', 'nuit', 'ardoise'] as const;
export type Mood = (typeof MOODS)[number];
const isMood = (m: string | undefined): m is Mood => MOODS.includes(m as Mood);
class MoodState {
current = $state<Mood>('pierre');
init() {
const m = document.documentElement.dataset.mood;
this.current = m === 'nuit' ? 'nuit' : 'pierre';
this.current = isMood(m) ? m : 'pierre';
}
toggle() {
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
document.documentElement.dataset.mood = this.current;
set(m: Mood) {
this.current = m;
const root = document.documentElement;
root.dataset.mood = m;
const bg = getComputedStyle(root).getPropertyValue('--bg').trim();
if (bg) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
try {
localStorage.setItem('ld:mood', this.current);
localStorage.setItem('ld:mood', m);
} catch {
/* private mode: the mood just won't persist */
}
+5 -8
View File
@@ -5,9 +5,10 @@
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
import Icon from '$lib/components/Icon.svelte';
import Seal from '$lib/components/Seal.svelte';
import Logo from '$lib/components/Logo.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import Stamp from '$lib/components/Stamp.svelte';
import Toasts from '$lib/components/Toasts.svelte';
import { groups } from '$lib/groups.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
@@ -19,6 +20,7 @@
onMount(() => {
i18n.init();
mood.init();
groups.init();
void session.load();
});
@@ -39,7 +41,7 @@
</aside>
<header class="topbar">
<a class="brand" href="/"><Stamp size={2} /><span>libre<b>Decision</b></span></a>
<a class="brand" href="/"><Logo size={1.2} /></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
</button>
@@ -151,13 +153,8 @@
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--ink);
padding: 0.15rem 0.1rem;
text-decoration: none;
font-size: 1.02rem;
}
.brand b {
font-weight: 800;
}
.menu {
display: grid;
+35 -73
View File
@@ -1,48 +1,36 @@
<script lang="ts">
import GroupChoice from '$lib/components/GroupChoice.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
const sections = $derived([
{ name: t('nav.decisions'), text: t('home.decisions'), open: false },
{ name: t('nav.docs'), text: t('home.docs'), open: true },
{ name: t('nav.mandates'), text: t('home.mandates'), open: false },
{ name: t('nav.protocols'), text: t('home.protocols'), open: false },
]);
</script>
<div class="home">
<header class="intro">
<h1>{t('home.title')}</h1>
<p class="sub">{t('home.subtitle')}</p>
<ul class="leitmotiv">
<li>{t('home.leitmotiv.context')}</li>
<li>{t('home.leitmotiv.process')}</li>
<li>{t('home.leitmotiv.tools')}</li>
</ul>
</header>
<ul class="sections">
{#each sections as s (s.name)}
<li class="section" class:open={s.open}>
<div class="head">
<h2>{s.name}</h2>
<span class="tag" class:water={s.open}>{s.open ? t('home.open') : t('home.soon')}</span>
</div>
<p>{s.text}</p>
</li>
{/each}
</ul>
<div class="enter">
{#if session.me}
<a class="btn primary" href="/groupes">{t('home.continue')}</a>
{:else}
{#if session.me}
<GroupChoice />
{:else}
<div class="enter">
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
<p class="faint">{t('home.signin.note')}</p>
{/if}
</div>
</div>
{/if}
</div>
<style>
.home {
max-width: 50rem;
max-width: 44rem;
margin: 0 auto;
padding: clamp(3rem, 10vh, 7rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
padding: clamp(3rem, 10vh, 6.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
display: grid;
gap: clamp(2.4rem, 6vh, 3.6rem);
}
.intro h1 {
font-family: var(--font-text);
@@ -54,56 +42,35 @@
max-width: 15ch;
text-wrap: balance;
}
.sub {
margin-top: 1.1rem;
font-family: var(--font-text);
font-style: italic;
font-size: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
color: var(--ink-soft);
max-width: 30ch;
}
.sections {
/* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */
.leitmotiv {
list-style: none;
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0;
margin: 1.4rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--surface-2);
border-radius: var(--r-card);
overflow: hidden;
box-shadow: var(--shadow);
}
.section {
background: var(--surface);
padding: 1.4rem 1.5rem 1.5rem;
display: grid;
gap: 0.55rem;
align-content: start;
}
.section:not(.open) {
gap: 0.2rem;
font-family: var(--font-text);
font-style: italic;
font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem);
line-height: 1.45;
color: var(--ink-soft);
}
.head {
.leitmotiv li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
gap: 0.7rem;
}
h2 {
font-size: 1.08rem;
font-weight: 800;
color: var(--ink);
}
.section:not(.open) h2 {
color: var(--ink-soft);
}
.section p {
font-size: 0.96rem;
line-height: 1.55;
.leitmotiv li::before {
content: '';
flex: none;
width: 0.62em;
height: 0.34em;
border-radius: 0.1em;
background: var(--cle);
opacity: 0.75;
translate: 0 -0.12em;
}
.enter {
margin-top: 2.6rem;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -113,9 +80,4 @@
font-size: 0.9rem;
max-width: 34ch;
}
@media (max-width: 640px) {
.sections {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
const back = $derived(page.url.searchParams.get('return') ?? '/groupes');
const back = $derived(page.url.searchParams.get('return') ?? '/');
const error = $derived(page.url.searchParams.get('erreur'));
let devName = $state('');
let devAdmin = $state(false);
+1 -23
View File
@@ -1,23 +1 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
<!-- Redirects, see +page.ts. -->
+7
View File
@@ -0,0 +1,7 @@
// Old address: the tool now lives in each group — the current one, or home.
import { redirect } from '@sveltejs/kit';
import { lastGroup, toolHref } from '$lib/groups.svelte';
export function load() {
redirect(307, toolHref('decisions', lastGroup()));
}
+1 -1
View File
@@ -58,7 +58,7 @@
try {
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
session.me = r.me;
await goto('/groupes', { replaceState: true });
await goto('/', { replaceState: true });
} catch (err) {
const code = err instanceof ApiError ? err.code : 'internal';
if (code === 'invitation_invalid') phase = 'invalid';
+17 -72
View File
@@ -1,19 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import DocCards from '$lib/components/DocCards.svelte';
import Guard from '$lib/components/Guard.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import Pillars from '$lib/components/Pillars.svelte';
import { groups } from '$lib/groups.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
const data = new Remote(() => groups.load(slug));
$effect(() => {
void slug;
void data.load();
});
const labels: Record<string, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const texts: Record<string, Key> = { decisions: 'home.decisions', docs: 'home.docs', mandats: 'home.mandates', protocoles: 'home.protocols' };
</script>
{#if data.error}
@@ -21,36 +20,24 @@
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/groupes">{t('nav.groups')}</a>
<a class="eyebrow back" href="/">{t('nav.groups')}</a>
<h1>{g.name}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<h2 class="eyebrow sub">{t('group.tools')}</h2>
<ul class="tools">
{#each data.value.tools as tool (tool.key)}
<li>
{#if tool.active}
<a class="tool card" href="/g/{g.slug}/docs">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}</span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</a>
{:else}
<div class="tool off" aria-disabled="true">
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}<span class="tag">{t('home.soon')}</span></span>
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
</div>
{/if}
</li>
{/each}
</ul>
<h2 class="visually-hidden">{t('group.tools')}</h2>
<Pillars group={g.slug} tools={data.value.tools} />
<section class="current" aria-labelledby="current-title">
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
<DocCards group={g.slug} docs={data.value.docs} summary={false} />
</section>
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
@@ -63,56 +50,14 @@
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1.2rem;
}
.sub {
margin-top: 1.6rem;
color: var(--ink-soft);
}
.tools {
list-style: none;
margin: 0;
padding: 0;
.current {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 2.2rem;
}
.tool {
display: grid;
gap: 0.45rem;
padding: 1.3rem 1.4rem;
height: 100%;
color: var(--ink);
text-decoration: none;
}
a.tool {
transition: transform 0.12s ease;
}
a.tool:hover {
transform: translateY(-3px);
}
.tool.off {
border-radius: var(--r-card);
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.name {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-weight: 800;
font-size: 1.05rem;
}
.text {
font-size: 0.93rem;
line-height: 1.5;
.current h2 {
color: var(--ink-soft);
}
.off .text {
color: var(--ink-faint);
}
@media (max-width: 640px) {
.tools {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,133 @@
<script lang="ts">
// « Décisions »: the documents in progress, one line each, with what is left to vote.
import { page } from '$app/state';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import type { DecisionsView } from '$lib/group';
import { groups } from '$lib/groups.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { LangMap } from '$lib/types';
const slug = $derived(page.params.group ?? '');
const data = new Remote(async () => {
const view = await groups.load(slug);
const decisions = view.group.open ? await api.get<DecisionsView>(`/groups/${slug}/decisions`) : null;
return { view, decisions };
});
$effect(() => {
void slug;
void data.load();
});
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.view.group}
{@const decisions = data.value.decisions}
<div class="page">
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
<h1>{t('nav.decisions')}</h1>
{#if !decisions}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<p class="lead">{t('decisions.note')}</p>
{#if decisions.docs.length}
<ul class="lines">
{#each decisions.docs as d (d.id)}
<li>
<a class="line card" href="/g/{g.slug}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
<span class="counts">
<span class="to-vote">{i18n.plural('decisions.toVote', d.toVote)}</span>
{#if d.withoutMyVote !== null && d.toVote > 0}
<span class="mine">{i18n.plural('decisions.withoutMyVote', d.withoutMyVote)}</span>
{/if}
</span>
<span class="go" aria-hidden="true"><Icon name="chevron" size={16} /></span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="muted">{t('group.current.none')}</p>
{/if}
{/if}
</div>
{/if}
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
}
.lead {
color: var(--ink-soft);
max-width: 52ch;
line-height: 1.55;
margin-bottom: 1.4rem;
}
.lines {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem 1.4rem;
padding: 1.15rem 3rem 1.15rem 1.5rem;
position: relative;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.line:hover {
transform: translateY(-2px);
}
.title {
flex: 1 1 16rem;
font-family: var(--font-text);
font-size: 1.3rem;
font-weight: 500;
}
.counts {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.6rem;
}
.to-vote {
font-weight: 800;
color: var(--water);
}
.mine {
font-size: 0.9rem;
font-weight: 700;
color: var(--ink-soft);
}
.go {
position: absolute;
right: 1.1rem;
top: 50%;
translate: 0 -50%;
color: var(--ink-faint);
}
</style>
+14 -46
View File
@@ -1,19 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/api';
import DocCards from '$lib/components/DocCards.svelte';
import Guard from '$lib/components/Guard.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import SoonList from '$lib/components/SoonList.svelte';
import { groups } from '$lib/groups.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupView } from '$lib/group';
import type { LangMap } from '$lib/types';
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
const data = new Remote(() => groups.load(slug));
$effect(() => {
void slug;
void data.load();
});
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if data.error}
@@ -25,27 +24,19 @@
<h1>{t('docs.title')}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else if !data.value.docs.length}
<p class="muted">{t('group.docs.empty')}</p>
{:else}
<ul class="docs">
{#each data.value.docs as d (d.id)}
<li>
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
<span class="summary">{pick(d.summary)}</span>
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
</a>
</li>
{/each}
</ul>
<section class="current" aria-labelledby="current-title">
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
<DocCards group={g.slug} docs={data.value.docs} />
</section>
<SoonList items={data.value.upcoming.docs} />
{/if}
</div>
{/if}
<style>
.page {
max-width: 46rem;
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
@@ -60,35 +51,12 @@
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
margin-bottom: 1rem;
}
.docs {
list-style: none;
margin: 0;
padding: 0;
.current {
display: grid;
gap: 0.8rem;
margin-bottom: 1.8rem;
}
.doc {
display: grid;
gap: 0.5rem;
padding: 1.5rem 1.6rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.doc:hover {
transform: translateY(-3px);
}
.title {
font-family: var(--font-text);
font-size: 1.45rem;
font-weight: 500;
}
.summary {
.current h2 {
color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,5 @@
<script lang="ts">
import ToolSoon from '$lib/components/ToolSoon.svelte';
</script>
<ToolSoon tool="mandats" />
@@ -0,0 +1,5 @@
<script lang="ts">
import ToolSoon from '$lib/components/ToolSoon.svelte';
</script>
<ToolSoon tool="protocoles" />
+1 -139
View File
@@ -1,139 +1 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupSummary } from '$lib/types';
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
onMount(() => void data.load());
const groups = $derived(data.value?.groups ?? []);
const root = $derived(groups.find((g) => g.slug === 'monnaie-libre'));
const sameKey = $derived(groups.filter((g) => g.parent === 'monnaie-libre'));
const others = $derived(groups.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('groups.title')}</h1>
{#if root}
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
<span class="eyebrow">Ğ1</span>
<span class="name">{root.name}</span>
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
</a>
{/if}
{#if sameKey.length}
<h2 class="eyebrow sub">{t('groups.same.key')}</h2>
<ul class="list">
{#each sameKey as g (g.slug)}
<li>
{#if g.open}
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
{:else}
<div class="row card locked" title={t('groups.locked.long')}>
<span class="name">{g.name}</span>
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
{/if}
{#if others.length}
<h2 class="eyebrow sub">{t('groups.test')}</h2>
<ul class="list">
{#each others as g (g.slug)}
<li>
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
</a>
</li>
{/each}
</ul>
{/if}
{#if !groups.length}<p class="muted">{t('groups.empty')}</p>{/if}
</div>
{/if}
<style>
.page {
max-width: 42rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.9rem, 1.5rem + 1.5vw, 2.5rem);
margin-bottom: 0.6rem;
}
.root {
display: grid;
gap: 0.3rem;
padding: 1.6rem 1.6rem 1.7rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
a.root:hover,
a.row:hover {
transform: translateY(-3px);
}
.root .name {
font-family: var(--font-text);
font-size: 1.7rem;
font-weight: 500;
}
.sub {
margin-top: 1.4rem;
color: var(--ink-soft);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.row .name {
font-weight: 750;
}
.locked {
background: transparent;
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.lock {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.82rem;
font-weight: 700;
}
.note {
font-size: 0.88rem;
}
</style>
<!-- Redirects, see +page.ts. -->
+6
View File
@@ -0,0 +1,6 @@
// Old address: the choice of a group is on the home page.
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(307, '/');
}
+1 -1
View File
@@ -19,7 +19,7 @@
try {
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
session.me = r.me;
await goto('/groupes', { replaceState: true });
await goto('/', { replaceState: true });
} catch {
state = 'invalid';
}
+1 -23
View File
@@ -1,23 +1 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
<!-- Redirects, see +page.ts. -->
+7
View File
@@ -0,0 +1,7 @@
// Old address: the tool now lives in each group — the current one, or home.
import { redirect } from '@sveltejs/kit';
import { lastGroup, toolHref } from '$lib/groups.svelte';
export function load() {
redirect(307, toolHref('mandats', lastGroup()));
}
+1 -23
View File
@@ -1,23 +1 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
</script>
<div class="page">
<h1>{t('soon.title')}</h1>
<p class="muted">{t('soon.body')}</p>
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
</style>
<!-- Redirects, see +page.ts. -->
+7
View File
@@ -0,0 +1,7 @@
// Old address: the tool now lives in each group — the current one, or home.
import { redirect } from '@sveltejs/kit';
import { lastGroup, toolHref } from '$lib/groups.svelte';
export function load() {
redirect(307, toolHref('protocoles', lastGroup()));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "libreDecision",
"short_name": "libreDecision",
"description": "Rendre possible la décision collective",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#e9eeec",
"theme_color": "#e9eeec",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}