From 6f8552c09b6a0cd91747a68564af53c5781b1252 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Fri, 30 Oct 2026 14:43:18 -0400 Subject: [PATCH] fix(ui): Ledger accessibility follow-ups (ROADMAP 3.24) - Theme menu: presentational group labels and aria-haspopup="menu" on its button - Selected preset stop is aria-current, not aria-pressed - Focus moves to a neighbouring Delete (or Edit) after deleting a history row - Add profile renders open so it works without JavaScript; script folds it on load Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 14 ++++++++++++++ static/ledger.css | 6 +++--- static/ledger.js | 1 + static/themes.css | 2 +- templates/_belt_ladder.html | 4 ++-- templates/active_session.html | 2 +- templates/base.html | 2 +- templates/settings.html | 13 ++++++++----- templates/start_session.html | 4 ++++ tests/test_routes.py | 25 ++++++++++++++++++++----- tests/test_theme_contrast.py | 2 +- 11 files changed, 56 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3823bde..3228045 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -583,6 +583,20 @@ Added the missing viewport meta, so phones get a real phone layout instead of a --- +### ✅ 3.24 Ledger Accessibility Follow-ups +**Status:** ✅ Complete +**Changelog:** `[Unreleased]` (part of 3.21's new interface) +**Priority:** Low +**Files Modified:** `static/ledger.js`, `static/ledger.css`, `static/themes.css`, `templates/base.html`, `templates/_belt_ladder.html`, `templates/active_session.html`, `templates/start_session.html`, `templates/settings.html`, `tests/test_routes.py`, `tests/test_theme_contrast.py` + +Four small gaps from 3.21's final review, closed before release: +- **Theme menu:** its group labels are `role="presentation"`, since a menu may hold only groups and items, and its rail button has `aria-haspopup="menu"`. +- **Preset stops:** the selected stop is marked `aria-current="true"` instead of `aria-pressed`, so screen readers announce the current preset rather than a toggle. +- **History:** after a row is deleted, focus moves to the neighbouring row's Delete, or to Edit when none is left, instead of falling to the page (WCAG 2.4.3). +- **Settings:** Add profile renders open, so it works without JavaScript; the page's script folds it behind its button on load. + +--- + ## Stats & Motivation ### 4.1 Estimated Time to Distance Goal diff --git a/static/ledger.css b/static/ledger.css index 4b16ca3..6926751 100644 --- a/static/ledger.css +++ b/static/ledger.css @@ -188,15 +188,15 @@ kbd { font-family: inherit; font-size: 11px; font-weight: 700; padding: 0 5px; b background: var(--surface); color: var(--text); display: grid; justify-items: start; text-align: left; line-height: 16px; white-space: nowrap; } .stop::before { content: ""; position: absolute; top: -13px; left: var(--tick, 50%); width: 1px; height: 9px; background: var(--muted); } .stop:hover { background: var(--raised); } -.stop[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); border-color: var(--primary); } -.stop[aria-pressed="true"] small { opacity: 1; } +.stop[aria-current="true"] { background: var(--primary); color: var(--on-primary); border-color: var(--primary); } +.stop[aria-current="true"] small { opacity: 1; } .stop small { font-size: 12px; color: inherit; opacity: .8; font-variant-numeric: tabular-nums; display: flex; gap: 6px; align-items: center; } .stop:disabled, .step:disabled { opacity: .45; } .is-parked .ladder { pointer-events: none; } .is-parked .track .fill { background: var(--chart-muted); } .is-parked .stop { border-style: dashed; } .is-parked .stop:disabled { opacity: 1; } -.is-parked .stop[aria-pressed="true"] { background: var(--raised); color: var(--text); border-color: var(--control-quiet); } +.is-parked .stop[aria-current="true"] { background: var(--raised); color: var(--text); border-color: var(--control-quiet); } .kv { margin: 0; display: grid; grid-template-columns: max-content 1fr; gap: 0 32px; background: var(--raised); border-radius: var(--radius-card); padding: 4px 16px; } .kv dt, .kv dd { margin: 0; padding: 8px 0; border-top: 1px solid var(--rule); } .kv dt:first-of-type, .kv dd:first-of-type { border-top: 0; } diff --git a/static/ledger.js b/static/ledger.js index d63021b..3112c49 100644 --- a/static/ledger.js +++ b/static/ledger.js @@ -138,6 +138,7 @@ function renderThemeFx() { var head = document.createElement('p'); head.className = 'wd-menu-label'; head.id = 'theme-group-' + label.toLowerCase(); + head.setAttribute('role', 'presentation'); // a menu may only hold groups and items; the label names its group head.textContent = label; var box = document.createElement('div'); box.setAttribute('role', 'group'); diff --git a/static/themes.css b/static/themes.css index fca2ab9..dd615cc 100644 --- a/static/themes.css +++ b/static/themes.css @@ -54,4 +54,4 @@ :root[data-color-theme="vt"] :is(.panel-h h2, .ledger dt, .wd-set-head h2)::before { content: ""; display: inline-block; width: 6px; height: 6px; margin: 0 8px 2px 0; background: #e5751f; vertical-align: middle; } /* Full-strength maroon is only 1.6:1 on VT's dark panels, and VT never lightens it, so the selected preset also gets a 2px Burnt Orange ring (VT's detail lines may be 2px). */ -:root[data-color-theme="vt"][data-theme="dark"] .stop[aria-pressed="true"] { box-shadow: 0 0 0 2px #e5751f; } +:root[data-color-theme="vt"][data-theme="dark"] .stop[aria-current="true"] { box-shadow: 0 0 0 2px #e5751f; } diff --git a/templates/_belt_ladder.html b/templates/_belt_ladder.html index 582be45..ce037c6 100644 --- a/templates/_belt_ladder.html +++ b/templates/_belt_ladder.html @@ -17,10 +17,10 @@ ('max_speed', 'Max', max_mph, 'M', 1, '--tx:-100%;--tick:100%')] %} {% set pressed = (speed - mph) | abs < 0.05 %} {% if parked %} - + {% else %}
- +
{% endif %} {% endfor %} diff --git a/templates/active_session.html b/templates/active_session.html index beb773d..8f2ecd2 100644 --- a/templates/active_session.html +++ b/templates/active_session.html @@ -94,7 +94,7 @@

Belt speed

ladder.setAttribute('aria-valuetext', text + ' mph'); ladder.style.setProperty('--speed-frac', Math.min(1, Math.max(0, (mph - minMph) / ((maxMph - minMph) || 1))).toFixed(3)); document.querySelectorAll('.stop[data-mph]').forEach(function(s) { - s.setAttribute('aria-pressed', Math.abs(parseFloat(s.dataset.mph) - mph) < 0.05 ? 'true' : 'false'); + if (Math.abs(parseFloat(s.dataset.mph) - mph) < 0.05) s.setAttribute('aria-current', 'true'); else s.removeAttribute('aria-current'); }); } diff --git a/templates/base.html b/templates/base.html index 917574d..55a6236 100644 --- a/templates/base.html +++ b/templates/base.html @@ -234,7 +234,7 @@ {% set on_settings = request.endpoint == 'settings_page' %}
- + {% if keyboard_shortcuts_enabled %} diff --git a/templates/settings.html b/templates/settings.html index dbf4359..57927a3 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -57,16 +57,17 @@

Profiles

{% endfor %} {% set adding = profile_form | length > 0 %}
-
-
+ {# Rendered open so it works without JavaScript; the script below folds it behind the button. #} +
{{ profile_fields(profile_form, 'new') }}
- +
@@ -219,12 +220,14 @@

Profiles

var addBtn = document.getElementById('add-profile-btn'); var addBox = document.getElementById('add-profile'); - function setAdding(on) { + function setAdding(on, quiet) { addBox.hidden = !on; addBtn.hidden = on; addBtn.setAttribute('aria-expanded', on ? 'true' : 'false'); - (on ? document.getElementById('name-new') : addBtn).focus(); + if (!quiet) (on ? document.getElementById('name-new') : addBtn).focus(); } + document.getElementById('add-profile-cancel').hidden = false; + setAdding(addBox.dataset.open === 'true', true); addBtn.addEventListener('click', function() { setAdding(true); }); document.getElementById('add-profile-cancel').addEventListener('click', function() { setAdding(false); }); diff --git a/templates/start_session.html b/templates/start_session.html index ff8a85c..cd1cac1 100644 --- a/templates/start_session.html +++ b/templates/start_session.html @@ -224,7 +224,11 @@

{{ history_heading }}

var row = btn.closest('tr'); var banner = document.getElementById('health-export-banner'); if (banner && banner.dataset.sessionId === row.dataset.sessionId) banner.remove(); + // Focus would fall to with the removed button (WCAG 2.4.3): move it to a neighbour's Delete, else Edit. + var near = row.nextElementSibling || row.previousElementSibling; + var next = near && near.querySelector('[data-delete-url]'); row.remove(); + (next || editBtn).focus(); }); }); }); diff --git a/tests/test_routes.py b/tests/test_routes.py index 6f14916..a1070e7 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -189,6 +189,20 @@ def test_active_belt_ladder_is_a_slider(client, running, monkeypatch): assert 'aria-valuenow="2.8"' in ladder and 'aria-valuetext="2.8 mph"' in ladder +def test_selected_preset_is_current_not_pressed(client, running, monkeypatch): + monkeypatch.setattr(app, "SLOW_WALK_SPEED_KMH", 4.5) + monkeypatch.setattr(app, "current_speed_kmh", 4.5) + html = client.get("/").get_data(as_text=True) + stops = re.findall(r'