feat(borne): police OpenDyslexic auto-hebergee + bascule accessibilite (RGAA Cr 1.c.2)
All checks were successful
CI / static-tests (pull_request) Successful in 45s
CI / js-tests (pull_request) Successful in 23s
CI / static-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 8s
CI / php-lint (pull_request) Successful in 19s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 20s
CI / js-tests (push) Successful in 23s

This commit is contained in:
Imugiii 2026-06-22 06:30:49 +00:00
parent 7ab9a5a8cf
commit 256f24ab7a
13 changed files with 336 additions and 2 deletions

View file

@ -12,8 +12,11 @@
* - Card border : 2px solid #FFC72C when selected / active * - Card border : 2px solid #FFC72C when selected / active
* *
* Kiosk target: 1080x1920 portrait (touch screen). * Kiosk target: 1080x1920 portrait (touch screen).
* Font stack: system-ui fallback school font is not available as a web asset. * Base font stack: system-ui fallback (the school font is not available as a web asset).
* OpenDys is loaded conditionally for accessibility (RGAA Cr 1.c.4). * Accessibility (RGAA Cr 1.c.2): the OpenDyslexic font (OFL 1.1) is self-hosted under
* assets/fonts (see @font-face in section 12). a11y.js adds the .dys-font class on
* <html> on demand, which redefines --font-family-base for the whole interface, and
* persists the choice in localStorage. Base stack is the default.
*/ */
/* ============================================================ /* ============================================================
@ -2149,3 +2152,69 @@ button {
text-align: center; text-align: center;
margin: var(--space-4) 0; margin: var(--space-4) 0;
} }
/* ============================================================
12. ACCESSIBILITY dyslexia-friendly font + toggle (RGAA Cr 1.c.2)
============================================================ */
/* OpenDyslexic (OFL 1.1), self-hosted under assets/fonts (see LICENSE-OpenDyslexic.txt).
font-display: swap keeps text visible while the face downloads. */
@font-face {
font-family: "OpenDyslexic";
src: url("../fonts/opendyslexic-latin-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../fonts/opendyslexic-latin-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* a11y.js sets .dys-font on <html>; the whole interface then inherits the
dyslexia-friendly stack via --font-family-base. Higher specificity than :root,
so it overrides the base token. */
html.dys-font {
--font-family-base: "OpenDyslexic", system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
}
/* Fixed accessibility control, present on every screen (injected by a11y.js). */
.a11y-toggle {
position: fixed;
top: var(--space-3);
right: var(--space-3);
z-index: 1000;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-bg-card);
color: var(--color-text-primary);
border: 2px solid var(--color-border-default);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
font-family: var(--font-family-base);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.a11y-toggle__icon {
font-size: var(--font-size-md);
line-height: 1;
}
/* Active state: not signalled by colour alone (RGAA 1.4.1) aria-pressed exposes
the state to assistive tech, and the label text stays visible. */
.a11y-toggle[aria-pressed="true"] {
background: var(--color-brand-yellow);
border-color: var(--color-brand-yellow-dk);
}
.a11y-toggle:focus-visible {
outline: 3px solid var(--color-brand-yellow-dk);
outline-offset: 2px;
}

View file

@ -0,0 +1,21 @@
OpenDyslexic
============
Files:
opendyslexic-latin-400-normal.woff2
opendyslexic-latin-700-normal.woff2
Copyright (c) Abelardo Gonzalez, with Reserved Font Name "OpenDyslexic".
OpenDyslexic is licensed under the SIL Open Font License, Version 1.1 (OFL-1.1).
This license is available with a FAQ at: https://openfontlicense.org
These web font files (latin subset, woff2) were obtained from the npm package
@fontsource/opendyslexic (jsDelivr mirror) and are redistributed here unmodified,
as permitted by the OFL, to be served self-hosted by the Wakdo kiosk for the
dyslexia-friendly display option (RGAA Cr 1.c.2).
Under the OFL: the font may be used, studied, modified and redistributed freely
as long as it is not sold by itself; redistributions must retain this notice and
the license; and the Reserved Font Name may not be used to promote derivative
fonts. The full license text accompanies the upstream font package.

View file

@ -0,0 +1,131 @@
/*
* a11y.js Bascule de police adaptee aux personnes dyslexiques (front borne).
*
* Accessibilite RGAA Cr 1.c.2 : une police specifique pour les personnes
* dyslexiques est prevue ET integree. La police OpenDyslexic (OFL 1.1) est
* auto-hebergee (assets/fonts, @font-face dans style.css). Ce module ajoute un
* bouton fixe present sur chaque ecran : au clic, il pose la classe .dys-font sur
* <html> (qui redefinit --font-family-base, applique a tout le texte) et persiste
* le choix dans localStorage pour le conserver d'un ecran a l'autre.
*
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
* l'API (createElement/textContent). Les fonctions sont exportees pour etre testees
* sans navigateur (jsdom) ; l'auto-init au chargement est gardee pour ne pas
* s'executer a l'import en environnement de test (document absent a ce moment-la).
*/
export const STORAGE_KEY = 'wakdo_dyslexia_font';
export const ROOT_CLASS = 'dys-font';
const TOGGLE_SELECTOR = '[data-a11y-dys-toggle]';
/**
* Lit la preference persistee. Tolere l'absence de storage (mode prive, quota) :
* toute erreur d'acces renvoie false (police de base par defaut).
* @param {Storage|null} storage
* @returns {boolean}
*/
export function isDyslexiaEnabled(storage) {
try {
return storage != null && storage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}
/**
* Applique (ou retire) la classe .dys-font sur l'element racine fourni.
* @param {boolean} enabled
* @param {HTMLElement} root typiquement document.documentElement
*/
export function applyDyslexiaPreference(enabled, root) {
if (root && root.classList) {
root.classList.toggle(ROOT_CLASS, Boolean(enabled));
}
}
/**
* Persiste la preference. Silencieux si le storage est indisponible.
* @param {boolean} enabled
* @param {Storage|null} storage
*/
export function persistDyslexiaPreference(enabled, storage) {
try {
if (storage != null) {
storage.setItem(STORAGE_KEY, enabled ? '1' : '0');
}
} catch {
/* storage indisponible : la preference reste valable pour la session en cours */
}
}
/**
* Construit le bouton de bascule (aria-pressed reflete l'etat). `onToggle` est
* appele au clic avec le nouvel etat booleen ; l'appelant persiste et applique.
* @param {boolean} initialEnabled
* @param {(next: boolean) => void} onToggle
* @returns {HTMLButtonElement}
*/
export function buildDyslexiaToggle(initialEnabled, onToggle) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'a11y-toggle';
btn.setAttribute('data-a11y-dys-toggle', '');
btn.setAttribute('aria-pressed', initialEnabled ? 'true' : 'false');
// Libelle neutre (decrit le controle, pas l'action) : reste correct dans les
// deux etats ; l'etat actif/inactif est porte par aria-pressed.
btn.setAttribute('aria-label', 'Police adaptee aux personnes dyslexiques');
const icon = document.createElement('span');
icon.className = 'a11y-toggle__icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = 'Aa';
btn.appendChild(icon);
const label = document.createElement('span');
label.className = 'a11y-toggle__label';
label.textContent = 'Police adaptee';
btn.appendChild(label);
btn.addEventListener('click', () => {
const next = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', next ? 'true' : 'false');
if (typeof onToggle === 'function') {
onToggle(next);
}
});
return btn;
}
/**
* Initialise la bascule : applique la preference persistee, puis injecte le bouton
* dans le conteneur (idempotent : ne reinjecte pas si un bouton existe deja).
* @param {{storage?: Storage|null, root?: HTMLElement, container?: HTMLElement}} [options]
* @returns {HTMLButtonElement|null} le bouton injecte, ou null si deja present
*/
export function initDyslexiaToggle(options = {}) {
const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
const root = options.root ?? (typeof document !== 'undefined' ? document.documentElement : null);
const container = options.container ?? (typeof document !== 'undefined' ? document.body : null);
const enabled = isDyslexiaEnabled(storage);
applyDyslexiaPreference(enabled, root);
if (!container || container.querySelector(TOGGLE_SELECTOR)) {
return null;
}
const btn = buildDyslexiaToggle(enabled, (next) => {
applyDyslexiaPreference(next, root);
persistDyslexiaPreference(next, storage);
});
container.appendChild(btn);
return btn;
}
/* Auto-init au chargement. Gardee : a l'import en test (document absent), ne
s'enregistre pas ; en navigateur, s'execute une fois le DOM pret. */
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => initDyslexiaToggle());
}

View file

@ -95,5 +95,6 @@
<script type="module" src="assets/js/nav.js"></script> <script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-cart.js"></script> <script type="module" src="assets/js/page-cart.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -175,5 +175,6 @@
</nav> </nav>
</main> </main>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -65,5 +65,6 @@
</main> </main>
<script type="module" src="assets/js/page-confirmation.js"></script> <script type="module" src="assets/js/page-confirmation.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -77,5 +77,6 @@
</main> </main>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -87,5 +87,6 @@
<script type="module" src="assets/js/page-payment.js"></script> <script type="module" src="assets/js/page-payment.js"></script>
<script type="module" src="assets/js/nav.js"></script> <script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -66,5 +66,6 @@
<script type="module" src="assets/js/nav.js"></script> <script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-product.js"></script> <script type="module" src="assets/js/page-product.js"></script>
<script type="module" src="assets/js/order-panel.js"></script> <script type="module" src="assets/js/order-panel.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

View file

@ -72,5 +72,6 @@
<script type="module" src="assets/js/page-products.js"></script> <script type="module" src="assets/js/page-products.js"></script>
<script type="module" src="assets/js/category-strip.js"></script> <script type="module" src="assets/js/category-strip.js"></script>
<script type="module" src="assets/js/order-panel.js"></script> <script type="module" src="assets/js/order-panel.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

106
tests/js/a11y.test.js Normal file
View file

@ -0,0 +1,106 @@
/*
* Tests du module a11y du front borne (node:test + jsdom).
*
* Couvre la bascule de police adaptee aux dyslexiques (RGAA Cr 1.c.2) : lecture de
* la preference persistee, application de la classe .dys-font sur la racine,
* injection du bouton (idempotente, aria-pressed reflete l'etat), et le cycle de
* clic (flip de l'etat + persistance). DOM simule par jsdom : aucun navigateur requis.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
import {
STORAGE_KEY,
ROOT_CLASS,
isDyslexiaEnabled,
applyDyslexiaPreference,
initDyslexiaToggle,
} from '../../src/public/borne/assets/js/a11y.js';
function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
return dom;
}
/** Storage en memoire, contrat compatible localStorage. */
function fakeStorage(initial = {}) {
const m = new Map(Object.entries(initial));
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => { m.set(k, String(v)); },
removeItem: (k) => { m.delete(k); },
_dump: () => Object.fromEntries(m),
};
}
test('isDyslexiaEnabled : vrai seulement si la cle vaut "1"', () => {
assert.equal(isDyslexiaEnabled(fakeStorage({ [STORAGE_KEY]: '1' })), true);
assert.equal(isDyslexiaEnabled(fakeStorage({ [STORAGE_KEY]: '0' })), false);
assert.equal(isDyslexiaEnabled(fakeStorage()), false);
assert.equal(isDyslexiaEnabled(null), false);
});
test('isDyslexiaEnabled : un storage qui jette renvoie false (mode prive)', () => {
const throwing = { getItem() { throw new Error('denied'); } };
assert.equal(isDyslexiaEnabled(throwing), false);
});
test('applyDyslexiaPreference : ajoute/retire la classe sur la racine', () => {
setupDom();
const root = document.documentElement;
applyDyslexiaPreference(true, root);
assert.ok(root.classList.contains(ROOT_CLASS));
applyDyslexiaPreference(false, root);
assert.ok(!root.classList.contains(ROOT_CLASS));
});
test('initDyslexiaToggle : applique la preference persistee et reflete aria-pressed', () => {
setupDom();
const storage = fakeStorage({ [STORAGE_KEY]: '1' });
const btn = initDyslexiaToggle({ storage, root: document.documentElement, container: document.body });
assert.ok(btn, 'un bouton est injecte');
assert.equal(btn.getAttribute('aria-pressed'), 'true');
assert.ok(document.documentElement.classList.contains(ROOT_CLASS), 'classe appliquee au chargement');
assert.ok(btn.getAttribute('aria-label'));
});
test('initDyslexiaToggle : defaut = police de base (pas de preference)', () => {
setupDom();
const storage = fakeStorage();
const btn = initDyslexiaToggle({ storage, root: document.documentElement, container: document.body });
assert.equal(btn.getAttribute('aria-pressed'), 'false');
assert.ok(!document.documentElement.classList.contains(ROOT_CLASS));
});
test('initDyslexiaToggle : idempotent (pas de doublon de bouton)', () => {
setupDom();
const storage = fakeStorage();
initDyslexiaToggle({ storage, root: document.documentElement, container: document.body });
const second = initDyslexiaToggle({ storage, root: document.documentElement, container: document.body });
assert.equal(second, null, 'le second appel ne reinjecte pas');
assert.equal(document.querySelectorAll('[data-a11y-dys-toggle]').length, 1);
});
test('clic sur le bouton : bascule l etat, la classe et persiste', () => {
setupDom();
const storage = fakeStorage();
const btn = initDyslexiaToggle({ storage, root: document.documentElement, container: document.body });
// Activation.
btn.click();
assert.equal(btn.getAttribute('aria-pressed'), 'true');
assert.ok(document.documentElement.classList.contains(ROOT_CLASS));
assert.equal(storage.getItem(STORAGE_KEY), '1');
// Desactivation.
btn.click();
assert.equal(btn.getAttribute('aria-pressed'), 'false');
assert.ok(!document.documentElement.classList.contains(ROOT_CLASS));
assert.equal(storage.getItem(STORAGE_KEY), '0');
});