judu/ex-egopublic Fork
⑂ 638f8c8
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(a11y): lot P3 — focus des sheets en amélioration progressive (ouverture → « Fermer », retour au déclencheur, Échap) et audit axe-core versionné dans a11y/ (exit 1 si violation critical/serious)Unverified

Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
Julien Durillon committed 2026-08-08T19:51:14+02:00 Browse files
638f8c8 parent: fd19cf8
added a11y/.gitignore +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+node_modules/
2+shots/
3+package-lock.json
new file mode 100644
@@ -0,0 +1,3 @@
1+node_modules/
2+shots/
3+package-lock.json
added a11y/audit.mjs +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+// Audit d'accessibilité récurrent — axe-core dans Chrome headless.
2+//
3+// Parcourt les 9 écrans de l'app (viewport mobile 390×844) et échoue
4+// (exit 1) à la moindre violation axe « critical » ou « serious » ;
5+// les violations moindres sont listées en avertissement.
6+//
7+// Prérequis : un serveur time-time qui tourne (base dev !) avec un compte
8+// membre d'au moins deux équipes, dont l'équipe active a ≥ 1 tâche et
9+// ≥ 1 temps saisi. Exemple de mise en place :
10+//
11+// cargo run -- team add "Audit" ; cargo run -- team add "Audit 2"
12+// cargo run -- user add audit --password xxxxxxxx --team Audit
13+// cargo run -- user join audit --team "Audit 2"
14+// (saisir un temps via l'UI, ou en SQL)
15+//
16+// Usage :
17+// cd a11y && npm install
18+// TT_USER=audit TT_PASS=xxxxxxxx npm run audit
19+//
20+// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS,
21+// CHROME (chemin du binaire, défaut google-chrome-stable du PATH).
22+
23+import { execSync } from 'node:child_process';
24+import { readFileSync } from 'node:fs';
25+import { createRequire } from 'node:module';
26+import puppeteer from 'puppeteer-core';
27+
28+const BASE = process.env.TT_URL ?? 'http://localhost:8090';
29+const USER = process.env.TT_USER;
30+const PASS = process.env.TT_PASS;
31+if (!USER || !PASS) {
32+ console.error('TT_USER et TT_PASS sont requis (compte de test, jamais un compte réel).');
33+ process.exit(2);
34+}
35+const CHROME =
36+ process.env.CHROME ?? execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim();
37+
38+const require = createRequire(import.meta.url);
39+const axeSource = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
40+
41+const browser = await puppeteer.launch({
42+ executablePath: CHROME,
43+ headless: 'new',
44+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
45+});
46+const viewport = { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 };
47+const page = await browser.newPage();
48+await page.setViewport(viewport);
49+
50+await page.goto(`${BASE}/login`, { waitUntil: 'networkidle0' });
51+await page.type('input[name="username"]', USER);
52+await page.type('input[name="password"]', PASS);
53+await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle0' }), page.click('button[type="submit"]')]);
54+if (page.url().includes('/login')) {
55+ console.error('Connexion échouée — vérifie TT_USER / TT_PASS.');
56+ await browser.close();
57+ process.exit(2);
58+}
59+
60+// Une tâche et une saisie existantes servent aux pages paramétrées.
61+const ids = await page.evaluate(async () => {
62+ const step1 = await fetch('/temps/nouveau').then((r) => r.text());
63+ const times = await fetch('/temps').then((r) => r.text());
64+ return {
65+ task: step1.match(/tache=([0-9a-f-]{36})/)?.[1] ?? null,
66+ entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
67+ };
68+});
69+
70+const pages = [
71+ { name: 'login', url: '/login', anon: true },
72+ { name: 'add-step1', url: '/temps/nouveau' },
73+ ids.task && { name: 'add-step2', url: `/temps/nouveau?tache=${ids.task}` },
74+ { name: 'stats', url: '/stats' },
75+ { name: 'times', url: '/temps' },
76+ ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` },
77+ { name: 'settings', url: '/parametres' },
78+ { name: 'tasks', url: '/parametres/taches' },
79+ { name: 'team-sheet', url: '/temps/nouveau#equipes' },
80+].filter(Boolean);
81+if (pages.length < 9) console.warn('⚠ tâche ou saisie manquante : certains écrans sont sautés.');
82+
83+let blocking = 0;
84+for (const p of pages) {
85+ const ctx = p.anon ? await browser.createBrowserContext() : null;
86+ const pg = ctx ? await ctx.newPage() : page;
87+ if (ctx) await pg.setViewport(viewport);
88+ await pg.goto(`${BASE}${p.url}`, { waitUntil: 'networkidle0' });
89+ await pg.evaluate(axeSource);
90+ const violations = await pg.evaluate(async () => {
91+ const r = await window.axe.run(document, {
92+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] },
93+ });
94+ return r.violations.map((v) => ({
95+ id: v.id,
96+ impact: v.impact,
97+ help: v.help,
98+ count: v.nodes.length,
99+ targets: v.nodes.slice(0, 5).map((n) => n.target.join(' ')),
100+ }));
101+ });
102+ for (const v of violations) {
103+ const hard = v.impact === 'critical' || v.impact === 'serious';
104+ if (hard) blocking += 1;
105+ console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`);
106+ for (const t of v.targets) console.log(` · ${t}`);
107+ }
108+ if (ctx) await ctx.close();
109+}
110+
111+await browser.close();
112+console.log(blocking === 0 ? '✓ aucune violation critical/serious' : `✘ ${blocking} violation(s) critical/serious`);
113+process.exit(blocking === 0 ? 0 : 1);
new file mode 100644
@@ -0,0 +1,113 @@
1+// Audit d'accessibilité récurrent — axe-core dans Chrome headless.
2+//
3+// Parcourt les 9 écrans de l'app (viewport mobile 390×844) et échoue
4+// (exit 1) à la moindre violation axe « critical » ou « serious » ;
5+// les violations moindres sont listées en avertissement.
6+//
7+// Prérequis : un serveur time-time qui tourne (base dev !) avec un compte
8+// membre d'au moins deux équipes, dont l'équipe active a ≥ 1 tâche et
9+// ≥ 1 temps saisi. Exemple de mise en place :
10+//
11+// cargo run -- team add "Audit" ; cargo run -- team add "Audit 2"
12+// cargo run -- user add audit --password xxxxxxxx --team Audit
13+// cargo run -- user join audit --team "Audit 2"
14+// (saisir un temps via l'UI, ou en SQL)
15+//
16+// Usage :
17+// cd a11y && npm install
18+// TT_USER=audit TT_PASS=xxxxxxxx npm run audit
19+//
20+// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS,
21+// CHROME (chemin du binaire, défaut google-chrome-stable du PATH).
22+
23+import { execSync } from 'node:child_process';
24+import { readFileSync } from 'node:fs';
25+import { createRequire } from 'node:module';
26+import puppeteer from 'puppeteer-core';
27+
28+const BASE = process.env.TT_URL ?? 'http://localhost:8090';
29+const USER = process.env.TT_USER;
30+const PASS = process.env.TT_PASS;
31+if (!USER || !PASS) {
32+ console.error('TT_USER et TT_PASS sont requis (compte de test, jamais un compte réel).');
33+ process.exit(2);
34+}
35+const CHROME =
36+ process.env.CHROME ?? execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim();
37+
38+const require = createRequire(import.meta.url);
39+const axeSource = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
40+
41+const browser = await puppeteer.launch({
42+ executablePath: CHROME,
43+ headless: 'new',
44+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
45+});
46+const viewport = { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 };
47+const page = await browser.newPage();
48+await page.setViewport(viewport);
49+
50+await page.goto(`${BASE}/login`, { waitUntil: 'networkidle0' });
51+await page.type('input[name="username"]', USER);
52+await page.type('input[name="password"]', PASS);
53+await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle0' }), page.click('button[type="submit"]')]);
54+if (page.url().includes('/login')) {
55+ console.error('Connexion échouée — vérifie TT_USER / TT_PASS.');
56+ await browser.close();
57+ process.exit(2);
58+}
59+
60+// Une tâche et une saisie existantes servent aux pages paramétrées.
61+const ids = await page.evaluate(async () => {
62+ const step1 = await fetch('/temps/nouveau').then((r) => r.text());
63+ const times = await fetch('/temps').then((r) => r.text());
64+ return {
65+ task: step1.match(/tache=([0-9a-f-]{36})/)?.[1] ?? null,
66+ entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
67+ };
68+});
69+
70+const pages = [
71+ { name: 'login', url: '/login', anon: true },
72+ { name: 'add-step1', url: '/temps/nouveau' },
73+ ids.task && { name: 'add-step2', url: `/temps/nouveau?tache=${ids.task}` },
74+ { name: 'stats', url: '/stats' },
75+ { name: 'times', url: '/temps' },
76+ ids.entry && { name: 'edit', url: `/temps/${ids.entry}/modifier` },
77+ { name: 'settings', url: '/parametres' },
78+ { name: 'tasks', url: '/parametres/taches' },
79+ { name: 'team-sheet', url: '/temps/nouveau#equipes' },
80+].filter(Boolean);
81+if (pages.length < 9) console.warn('⚠ tâche ou saisie manquante : certains écrans sont sautés.');
82+
83+let blocking = 0;
84+for (const p of pages) {
85+ const ctx = p.anon ? await browser.createBrowserContext() : null;
86+ const pg = ctx ? await ctx.newPage() : page;
87+ if (ctx) await pg.setViewport(viewport);
88+ await pg.goto(`${BASE}${p.url}`, { waitUntil: 'networkidle0' });
89+ await pg.evaluate(axeSource);
90+ const violations = await pg.evaluate(async () => {
91+ const r = await window.axe.run(document, {
92+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] },
93+ });
94+ return r.violations.map((v) => ({
95+ id: v.id,
96+ impact: v.impact,
97+ help: v.help,
98+ count: v.nodes.length,
99+ targets: v.nodes.slice(0, 5).map((n) => n.target.join(' ')),
100+ }));
101+ });
102+ for (const v of violations) {
103+ const hard = v.impact === 'critical' || v.impact === 'serious';
104+ if (hard) blocking += 1;
105+ console.log(`${hard ? '✘' : '⚠'} ${p.name}: [${v.impact}] ${v.id} ×${v.count} — ${v.help}`);
106+ for (const t of v.targets) console.log(` · ${t}`);
107+ }
108+ if (ctx) await ctx.close();
109+}
110+
111+await browser.close();
112+console.log(blocking === 0 ? '✓ aucune violation critical/serious' : `✘ ${blocking} violation(s) critical/serious`);
113+process.exit(blocking === 0 ? 0 : 1);
added a11y/package.json +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+{
2+ "name": "time-time-a11y",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "audit": "node audit.mjs"
7+ },
8+ "dependencies": {
9+ "axe-core": "^4.10.0",
10+ "puppeteer-core": "^24.0.0"
11+ }
12+}
new file mode 100644
@@ -0,0 +1,12 @@
1+{
2+ "name": "time-time-a11y",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "audit": "node audit.mjs"
7+ },
8+ "dependencies": {
9+ "axe-core": "^4.10.0",
10+ "puppeteer-core": "^24.0.0"
11+ }
12+}
modified templates/app.html +31 -0
@@ -124,6 +124,37 @@
124124 }, { once: true });
125125 }
126126 })();
127+// Amélioration progressive : focus des bottom sheets (:target). Sans JS les
128+// sheets s'ouvrent et se ferment déjà par liens ; ceci déplace le focus sur
129+// « Fermer » à l'ouverture, le rend au déclencheur à la fermeture, et ajoute
130+// la fermeture par Échap.
131+(function () {
132+ var trigger = null;
133+ function openSheet() {
134+ var id = location.hash.slice(1);
135+ var el = id && document.getElementById(id);
136+ return el && el.classList.contains('sheet-wrap') ? el : null;
137+ }
138+ document.addEventListener('click', function (e) {
139+ var a = e.target.closest ? e.target.closest('a[href*="#"]') : null;
140+ if (!a) return;
141+ var el = a.hash && document.getElementById(a.hash.slice(1));
142+ if (el && el.classList.contains('sheet-wrap')) trigger = a;
143+ });
144+ window.addEventListener('hashchange', function () {
145+ var w = openSheet();
146+ if (w) {
147+ var f = w.querySelector('.close') || w.querySelector('a, button');
148+ if (f) f.focus();
149+ } else if (trigger && document.contains(trigger)) {
150+ trigger.focus();
151+ trigger = null;
152+ }
153+ });
154+ document.addEventListener('keydown', function (e) {
155+ if (e.key === 'Escape' && openSheet()) location.hash = 'fermer';
156+ });
157+})();
127158 // Amélioration progressive : le temps écoulé rendu par le serveur reste
128159 // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger.
129160 (function () {
@@ -124,6 +124,37 @@
124 }, { once: true });124 }, { once: true });
125 }125 }
126 })();126 })();
127+// Amélioration progressive : focus des bottom sheets (:target). Sans JS les
128+// sheets s'ouvrent et se ferment déjà par liens ; ceci déplace le focus sur
129+// « Fermer » à l'ouverture, le rend au déclencheur à la fermeture, et ajoute
130+// la fermeture par Échap.
131+(function () {
132+ var trigger = null;
133+ function openSheet() {
134+ var id = location.hash.slice(1);
135+ var el = id && document.getElementById(id);
136+ return el && el.classList.contains('sheet-wrap') ? el : null;
137+ }
138+ document.addEventListener('click', function (e) {
139+ var a = e.target.closest ? e.target.closest('a[href*="#"]') : null;
140+ if (!a) return;
141+ var el = a.hash && document.getElementById(a.hash.slice(1));
142+ if (el && el.classList.contains('sheet-wrap')) trigger = a;
143+ });
144+ window.addEventListener('hashchange', function () {
145+ var w = openSheet();
146+ if (w) {
147+ var f = w.querySelector('.close') || w.querySelector('a, button');
148+ if (f) f.focus();
149+ } else if (trigger && document.contains(trigger)) {
150+ trigger.focus();
151+ trigger = null;
152+ }
153+ });
154+ document.addEventListener('keydown', function (e) {
155+ if (e.key === 'Escape' && openSheet()) location.hash = 'fermer';
156+ });
157+})();
127 // Amélioration progressive : le temps écoulé rendu par le serveur reste158 // Amélioration progressive : le temps écoulé rendu par le serveur reste
128 // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger.159 // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger.
129 (function () {160 (function () {