judu/ex-egopublic Fork
⑂ 5d9c31d
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

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.
Julien Durillon committed 2026-08-16T23:53:32+02:00 Browse files
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
585585 font-size: 15px; border-radius: 0;
586586 }
587587 .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; }
589591 .category-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); }
590592 .category-add-row .input { flex: 1; min-height: 44px; }
591593 .visually-hidden {
@@ -682,8 +684,27 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
682684 .swt-check:has(input:checked) .swt { background: var(--color-accent-700); }
683685 .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }
684686 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
687+.done-note { font-size: 13px; opacity: .7; }
685688 .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; }
687708 /* Sélecteur de thème (Réglages, section Affichage). */
688709 .theme-choice {
689710 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 @@
194194 var b = e.target.closest('.stepper-btn[data-step], .preset[data-minutes], a[data-date-step]');
195195 if (!b) return;
196196 e.preventDefault();
197+ if (b.getAttribute('aria-disabled') === 'true') return;
197198 if (b.dataset.dateStep) {
198199 var inp = root.querySelector('#' + b.dataset.dateFor);
199200 var d = inp.valueAsDate || new Date();
@@ -214,6 +215,29 @@
214215 }
215216 });
216217 })();
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+})();
217241 // Amélioration progressive : le temps écoulé rendu par le serveur reste
218242 // juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger.
219243 (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 reste241 // 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 @@
4040 </label>
4141 </div>
4242 <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>
4444 {% 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>
4646 </div>
4747 </section>
4848
@@ -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 @@
154154 wrap.querySelector('.close').addEventListener('click', function () { close(wrap); });
155155 document.addEventListener('keydown', wrap.tt_esc);
156156 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+ });
157163 wrap.querySelector('.close').focus();
158164 }
159165 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) {