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.