diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index 0de6e8b..bd9bbc9 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -12,8 +12,11 @@ * - Card border : 2px solid #FFC72C when selected / active * * Kiosk target: 1080x1920 portrait (touch screen). - * Font stack: system-ui fallback — school font is not available as a web asset. - * OpenDys is loaded conditionally for accessibility (RGAA Cr 1.c.4). + * Base font stack: system-ui fallback (the school font is not available as a web asset). + * 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 + * 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; 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 ; 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; +} diff --git a/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt b/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt new file mode 100644 index 0000000..8ee9c14 --- /dev/null +++ b/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt @@ -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. diff --git a/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 b/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 new file mode 100644 index 0000000..47e26d8 Binary files /dev/null and b/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 differ diff --git a/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 b/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 new file mode 100644 index 0000000..2f04ad1 Binary files /dev/null and b/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 differ diff --git a/src/public/borne/assets/js/a11y.js b/src/public/borne/assets/js/a11y.js new file mode 100644 index 0000000..74522e5 --- /dev/null +++ b/src/public/borne/assets/js/a11y.js @@ -0,0 +1,129 @@ +/* + * 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 + * (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'); + btn.setAttribute('aria-label', 'Activer la 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()); +} diff --git a/src/public/borne/cart.html b/src/public/borne/cart.html index 6c49f84..221c8bb 100644 --- a/src/public/borne/cart.html +++ b/src/public/borne/cart.html @@ -95,5 +95,6 @@ + diff --git a/src/public/borne/categories.html b/src/public/borne/categories.html index 64a0453..9f5993e 100644 --- a/src/public/borne/categories.html +++ b/src/public/borne/categories.html @@ -175,5 +175,6 @@ + diff --git a/src/public/borne/confirmation.html b/src/public/borne/confirmation.html index e4be215..834d49c 100644 --- a/src/public/borne/confirmation.html +++ b/src/public/borne/confirmation.html @@ -65,5 +65,6 @@ + diff --git a/src/public/borne/index.html b/src/public/borne/index.html index e07ccef..b6350d1 100644 --- a/src/public/borne/index.html +++ b/src/public/borne/index.html @@ -77,5 +77,6 @@ + diff --git a/src/public/borne/payment.html b/src/public/borne/payment.html index efb8525..1f2c5ae 100644 --- a/src/public/borne/payment.html +++ b/src/public/borne/payment.html @@ -87,5 +87,6 @@ + diff --git a/src/public/borne/product.html b/src/public/borne/product.html index 0c5e23f..8985f28 100644 --- a/src/public/borne/product.html +++ b/src/public/borne/product.html @@ -66,5 +66,6 @@ + diff --git a/src/public/borne/products.html b/src/public/borne/products.html index 969f872..71a9f23 100644 --- a/src/public/borne/products.html +++ b/src/public/borne/products.html @@ -72,5 +72,6 @@ + diff --git a/tests/js/a11y.test.js b/tests/js/a11y.test.js new file mode 100644 index 0000000..4ad08d1 --- /dev/null +++ b/tests/js/a11y.test.js @@ -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(''); + 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'); +});