fix(a11y): rétablit le contraste AA des états atténuésUnverified
Deux opacity servaient de signal « inactif » et passaient sous le seuil AA sur du texte lisible et focusable : - categories : l'input d'une catégorie archivée à .45 (2,7:1) - todo-done : la section d'un interrupteur décoché à .35, soit 2:1, et 1,6:1 pour les textes déjà à .7 (double atténuation) Les deux passent à .7 sans cumul — d'où .done-note, qui remplace un style inline non neutralisable. Le preset sélectionné passe en contour : son texte est couleur du fond de page, donc l'opacité de groupe délave son aplat sans jamais bouger le texte (2,9:1 quelle que soit la valeur). L'état inactif se lit désormais aux aplats qui s'effacent, pas à un texte pâle. En amélioration progressive, le JS bascule disabled / aria-disabled sur les contrôles d'une section décochée : plus rien d'inactif n'est atteignable au clavier, et le serveur ignorait déjà ces champs. axe : zéro violation critical/serious sur les 13 écrans, dans les deux thèmes, avec et sans JS.
5d9c31d parent: 8a6909f modified
assets/style.css +23 -2 | @@ -585,7 +585,9 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 585 | 585 | font-size: 15px; border-radius: 0; |
| 586 | 586 | } |
| 587 | 587 | .category-row .rename .input:hover { border-color: var(--color-divider); } |
| 588 | -.category-row.archived .rename .input { opacity: .45; } | |
| 588 | +/* Archivée = grisée, mais le nom reste éditable (restauration) : .45 donnait | |
| 589 | + 2,7:1. .7 tient l'AA tout en restant nettement en retrait. */ | |
| 590 | +.category-row.archived .rename .input { opacity: .7; } | |
| 589 | 591 | .category-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } |
| 590 | 592 | .category-add-row .input { flex: 1; min-height: 44px; } |
| 591 | 593 | .visually-hidden { |
| @@ -682,8 +684,27 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 682 | 684 | .swt-check:has(input:checked) .swt { background: var(--color-accent-700); } |
| 683 | 685 | .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); } |
| 684 | 686 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 687 | +.done-note { font-size: 13px; opacity: .7; } | |
| 685 | 688 | .done-sec .done-body { transition: opacity .15s ease; } |
| 686 | -.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } | |
| 689 | +/* Section désactivée : atténuée, mais elle reste lisible et focusable — donc | |
| 690 | + soumise à l'AA. .35 tombait à 2:1, et à 1,6:1 pour les textes déjà à .7. | |
| 691 | + Plancher du projet : 70 % d'opacité, sans double atténuation. */ | |
| 692 | +.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .7; } | |
| 693 | +.done-sec:has(.swt-check input:not(:checked)) .done-body | |
| 694 | + :is(.unit, .pretty, .mini-label, .done-note) { opacity: 1; } | |
| 695 | +/* Le preset sélectionné est le seul texte crème sur aplat : l'opacité délave | |
| 696 | + son fond alors que son texte, couleur du fond de page, ne bouge pas (2,9:1 | |
| 697 | + à .7, quelle que soit la valeur). En contour il reste identifiable et net. | |
| 698 | + Le libellé prend la couleur de texte : sous l'opacité du groupe, même | |
| 699 | + accent-700 plafonne à 3,1:1. C'est l'anneau qui porte la sélection. */ | |
| 700 | +.done-sec:has(.swt-check input:not(:checked)) .preset[aria-current="true"] { | |
| 701 | + background: transparent; color: var(--color-text); | |
| 702 | + border-color: var(--color-accent-deep); | |
| 703 | +} | |
| 704 | +/* 70 % d'opacité se voit peu : l'état inactif se lit surtout aux aplats qui | |
| 705 | + s'effacent. Ça ne touche que des fonds, jamais le contraste du texte. */ | |
| 706 | +.done-sec:has(.swt-check input:not(:checked)) .done-body | |
| 707 | + :is(.stepper, .date-row .input) { background: transparent; } | |
| 687 | 708 | /* Sélecteur de thème (Réglages, section Affichage). */ |
| 688 | 709 | .theme-choice { |
| 689 | 710 | display: flex; align-items: center; gap: var(--space-3); width: 100%; |
| @@ -585,7 +585,9 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 585 | font-size: 15px; border-radius: 0; | 585 | font-size: 15px; border-radius: 0; |
| 586 | } | 586 | } |
| 587 | .category-row .rename .input:hover { border-color: var(--color-divider); } | 587 | .category-row .rename .input:hover { border-color: var(--color-divider); } |
| 588 | -.category-row.archived .rename .input { opacity: .45; } | 588 | +/* Archivée = grisée, mais le nom reste éditable (restauration) : .45 donnait |
| 589 | + 2,7:1. .7 tient l'AA tout en restant nettement en retrait. */ | ||
| 590 | +.category-row.archived .rename .input { opacity: .7; } | ||
| 589 | .category-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } | 591 | .category-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } |
| 590 | .category-add-row .input { flex: 1; min-height: 44px; } | 592 | .category-add-row .input { flex: 1; min-height: 44px; } |
| 591 | .visually-hidden { | 593 | .visually-hidden { |
| @@ -682,8 +684,27 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 682 | .swt-check:has(input:checked) .swt { background: var(--color-accent-700); } | 684 | .swt-check:has(input:checked) .swt { background: var(--color-accent-700); } |
| 683 | .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); } | 685 | .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); } |
| 684 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } | 686 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 687 | +.done-note { font-size: 13px; opacity: .7; } | ||
| 685 | .done-sec .done-body { transition: opacity .15s ease; } | 688 | .done-sec .done-body { transition: opacity .15s ease; } |
| 686 | -.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } | 689 | +/* Section désactivée : atténuée, mais elle reste lisible et focusable — donc |
| 690 | + soumise à l'AA. .35 tombait à 2:1, et à 1,6:1 pour les textes déjà à .7. | ||
| 691 | + Plancher du projet : 70 % d'opacité, sans double atténuation. */ | ||
| 692 | +.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .7; } | ||
| 693 | +.done-sec:has(.swt-check input:not(:checked)) .done-body | ||
| 694 | + :is(.unit, .pretty, .mini-label, .done-note) { opacity: 1; } | ||
| 695 | +/* Le preset sélectionné est le seul texte crème sur aplat : l'opacité délave | ||
| 696 | + son fond alors que son texte, couleur du fond de page, ne bouge pas (2,9:1 | ||
| 697 | + à .7, quelle que soit la valeur). En contour il reste identifiable et net. | ||
| 698 | + Le libellé prend la couleur de texte : sous l'opacité du groupe, même | ||
| 699 | + accent-700 plafonne à 3,1:1. C'est l'anneau qui porte la sélection. */ | ||
| 700 | +.done-sec:has(.swt-check input:not(:checked)) .preset[aria-current="true"] { | ||
| 701 | + background: transparent; color: var(--color-text); | ||
| 702 | + border-color: var(--color-accent-deep); | ||
| 703 | +} | ||
| 704 | +/* 70 % d'opacité se voit peu : l'état inactif se lit surtout aux aplats qui | ||
| 705 | + s'effacent. Ça ne touche que des fonds, jamais le contraste du texte. */ | ||
| 706 | +.done-sec:has(.swt-check input:not(:checked)) .done-body | ||
| 707 | + :is(.stepper, .date-row .input) { background: transparent; } | ||
| 687 | /* Sélecteur de thème (Réglages, section Affichage). */ | 708 | /* Sélecteur de thème (Réglages, section Affichage). */ |
| 688 | .theme-choice { | 709 | .theme-choice { |
| 689 | display: flex; align-items: center; gap: var(--space-3); width: 100%; | 710 | display: flex; align-items: center; gap: var(--space-3); width: 100%; |
modified
templates/app.html +24 -0 | @@ -194,6 +194,7 @@ | ||
| 194 | 194 | var b = e.target.closest('.stepper-btn[data-step], .preset[data-minutes], a[data-date-step]'); |
| 195 | 195 | if (!b) return; |
| 196 | 196 | e.preventDefault(); |
| 197 | + if (b.getAttribute('aria-disabled') === 'true') return; | |
| 197 | 198 | if (b.dataset.dateStep) { |
| 198 | 199 | var inp = root.querySelector('#' + b.dataset.dateFor); |
| 199 | 200 | var d = inp.valueAsDate || new Date(); |
| @@ -214,6 +215,29 @@ | ||
| 214 | 215 | } |
| 215 | 216 | }); |
| 216 | 217 | })(); |
| 218 | +// Amélioration progressive : sur « Tâche effectuée », l'interrupteur d'une | |
| 219 | +// section pilote l'état désactivé de ses contrôles (disabled sur les champs, | |
| 220 | +// aria-disabled + retrait du tab order sur les liens stepper/preset). Sans | |
| 221 | +// JS, l'atténuation CSS suffit : les aplats s'effacent et tous les libellés | |
| 222 | +// restent au contraste AA. Le serveur ignore déjà les champs d'une section | |
| 223 | +// décochée, donc ne pas les soumettre ne change rien. | |
| 224 | +(function () { | |
| 225 | + function sync(sec) { | |
| 226 | + var sw = sec.querySelector('.swt-check input[type="checkbox"]'); | |
| 227 | + var on = !sw || sw.checked; | |
| 228 | + sec.querySelectorAll('.done-body input').forEach(function (el) { el.disabled = !on; }); | |
| 229 | + sec.querySelectorAll('.done-body a').forEach(function (el) { | |
| 230 | + el.setAttribute('aria-disabled', on ? 'false' : 'true'); | |
| 231 | + el.tabIndex = on ? 0 : -1; | |
| 232 | + }); | |
| 233 | + } | |
| 234 | + document.addEventListener('change', function (e) { | |
| 235 | + if (!e.target.closest || !e.target.matches('.swt-check input[type="checkbox"]')) return; | |
| 236 | + var sec = e.target.closest('.done-sec'); | |
| 237 | + if (sec) sync(sec); | |
| 238 | + }); | |
| 239 | + document.querySelectorAll('.done-sec').forEach(sync); | |
| 240 | +})(); | |
| 217 | 241 | // Amélioration progressive : le temps écoulé rendu par le serveur reste |
| 218 | 242 | // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. |
| 219 | 243 | (function () { |
| @@ -194,6 +194,7 @@ | |||
| 194 | var b = e.target.closest('.stepper-btn[data-step], .preset[data-minutes], a[data-date-step]'); | 194 | var b = e.target.closest('.stepper-btn[data-step], .preset[data-minutes], a[data-date-step]'); |
| 195 | if (!b) return; | 195 | if (!b) return; |
| 196 | e.preventDefault(); | 196 | e.preventDefault(); |
| 197 | + if (b.getAttribute('aria-disabled') === 'true') return; | ||
| 197 | if (b.dataset.dateStep) { | 198 | if (b.dataset.dateStep) { |
| 198 | var inp = root.querySelector('#' + b.dataset.dateFor); | 199 | var inp = root.querySelector('#' + b.dataset.dateFor); |
| 199 | var d = inp.valueAsDate || new Date(); | 200 | var d = inp.valueAsDate || new Date(); |
| @@ -214,6 +215,29 @@ | |||
| 214 | } | 215 | } |
| 215 | }); | 216 | }); |
| 216 | })(); | 217 | })(); |
| 218 | +// Amélioration progressive : sur « Tâche effectuée », l'interrupteur d'une | ||
| 219 | +// section pilote l'état désactivé de ses contrôles (disabled sur les champs, | ||
| 220 | +// aria-disabled + retrait du tab order sur les liens stepper/preset). Sans | ||
| 221 | +// JS, l'atténuation CSS suffit : les aplats s'effacent et tous les libellés | ||
| 222 | +// restent au contraste AA. Le serveur ignore déjà les champs d'une section | ||
| 223 | +// décochée, donc ne pas les soumettre ne change rien. | ||
| 224 | +(function () { | ||
| 225 | + function sync(sec) { | ||
| 226 | + var sw = sec.querySelector('.swt-check input[type="checkbox"]'); | ||
| 227 | + var on = !sw || sw.checked; | ||
| 228 | + sec.querySelectorAll('.done-body input').forEach(function (el) { el.disabled = !on; }); | ||
| 229 | + sec.querySelectorAll('.done-body a').forEach(function (el) { | ||
| 230 | + el.setAttribute('aria-disabled', on ? 'false' : 'true'); | ||
| 231 | + el.tabIndex = on ? 0 : -1; | ||
| 232 | + }); | ||
| 233 | + } | ||
| 234 | + document.addEventListener('change', function (e) { | ||
| 235 | + if (!e.target.closest || !e.target.matches('.swt-check input[type="checkbox"]')) return; | ||
| 236 | + var sec = e.target.closest('.done-sec'); | ||
| 237 | + if (sec) sync(sec); | ||
| 238 | + }); | ||
| 239 | + document.querySelectorAll('.done-sec').forEach(sync); | ||
| 240 | +})(); | ||
| 217 | // Amélioration progressive : le temps écoulé rendu par le serveur reste | 241 | // Amélioration progressive : le temps écoulé rendu par le serveur reste |
| 218 | // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. | 242 | // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. |
| 219 | (function () { | 243 | (function () { |
modified
templates/todo_done.html +2 -2 | @@ -40,9 +40,9 @@ | ||
| 40 | 40 | </label> |
| 41 | 41 | </div> |
| 42 | 42 | <div class="done-body"> |
| 43 | - <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p> | |
| 43 | + <p class="done-note" style="margin:0 0 var(--space-3)">{{ rhythm }}</p> | |
| 44 | 44 | {% call c::date_row(shell, "next-date", "prochaine", next, "", shell.tr.next_activation_aria, next_prev, next_next, true) %}{% endcall %} |
| 45 | - <p style="font-size:13px;opacity:.7;margin:var(--space-2) 0 0">{{ next_label }}</p> | |
| 45 | + <p class="done-note" style="margin:var(--space-2) 0 0">{{ next_label }}</p> | |
| 46 | 46 | </div> |
| 47 | 47 | </section> |
| 48 | 48 | |
| @@ -40,9 +40,9 @@ | |||
| 40 | </label> | 40 | </label> |
| 41 | </div> | 41 | </div> |
| 42 | <div class="done-body"> | 42 | <div class="done-body"> |
| 43 | - <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p> | 43 | + <p class="done-note" style="margin:0 0 var(--space-3)">{{ rhythm }}</p> |
| 44 | {% call c::date_row(shell, "next-date", "prochaine", next, "", shell.tr.next_activation_aria, next_prev, next_next, true) %}{% endcall %} | 44 | {% call c::date_row(shell, "next-date", "prochaine", next, "", shell.tr.next_activation_aria, next_prev, next_next, true) %}{% endcall %} |
| 45 | - <p style="font-size:13px;opacity:.7;margin:var(--space-2) 0 0">{{ next_label }}</p> | 45 | + <p class="done-note" style="margin:var(--space-2) 0 0">{{ next_label }}</p> |
| 46 | </div> | 46 | </div> |
| 47 | </section> | 47 | </section> |
| 48 | 48 | ||
modified
templates/todos.html +6 -0 | @@ -154,6 +154,12 @@ | ||
| 154 | 154 | wrap.querySelector('.close').addEventListener('click', function () { close(wrap); }); |
| 155 | 155 | document.addEventListener('keydown', wrap.tt_esc); |
| 156 | 156 | document.querySelector('.shell').appendChild(wrap); |
| 157 | + // Le formulaire arrive après le passage initial d'app.html : une fois | |
| 158 | + // dans le document, un change synthétique par section lui applique | |
| 159 | + // l'état désactivé (le listener d'app.html est délégué sur document). | |
| 160 | + form.querySelectorAll('.swt-check input[type="checkbox"]').forEach(function (c) { | |
| 161 | + c.dispatchEvent(new Event('change', { bubbles: true })); | |
| 162 | + }); | |
| 157 | 163 | wrap.querySelector('.close').focus(); |
| 158 | 164 | } |
| 159 | 165 | document.addEventListener('click', function (e) { |
| @@ -154,6 +154,12 @@ | |||
| 154 | wrap.querySelector('.close').addEventListener('click', function () { close(wrap); }); | 154 | wrap.querySelector('.close').addEventListener('click', function () { close(wrap); }); |
| 155 | document.addEventListener('keydown', wrap.tt_esc); | 155 | document.addEventListener('keydown', wrap.tt_esc); |
| 156 | document.querySelector('.shell').appendChild(wrap); | 156 | document.querySelector('.shell').appendChild(wrap); |
| 157 | + // Le formulaire arrive après le passage initial d'app.html : une fois | ||
| 158 | + // dans le document, un change synthétique par section lui applique | ||
| 159 | + // l'état désactivé (le listener d'app.html est délégué sur document). | ||
| 160 | + form.querySelectorAll('.swt-check input[type="checkbox"]').forEach(function (c) { | ||
| 161 | + c.dispatchEvent(new Event('change', { bubbles: true })); | ||
| 162 | + }); | ||
| 157 | wrap.querySelector('.close').focus(); | 163 | wrap.querySelector('.close').focus(); |
| 158 | } | 164 | } |
| 159 | document.addEventListener('click', function (e) { | 165 | document.addEventListener('click', function (e) { |