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
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 @@ | ||
| 124 | 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 | 158 | // Amélioration progressive : le temps écoulé rendu par le serveur reste |
| 128 | 159 | // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. |
| 129 | 160 | (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 reste | 158 | // 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 () { |