From ab65e8d43e7452316ca9cd0d398e2b01aa44a482 Mon Sep 17 00:00:00 2001 From: yuqei Date: Fri, 1 May 2026 11:35:28 -0400 Subject: [PATCH] feat: improve training dashboard accessibility contract --- src/web/app.js | 34 +++++-- src/web/index.html | 54 +++++++---- src/web/styles.css | 49 +++++++++- tests/unit/test_training_dashboard_m3.py | 114 +++++++++++++++++++++++ 4 files changed, 224 insertions(+), 27 deletions(-) diff --git a/src/web/app.js b/src/web/app.js index 8e39bb6..7c37053 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -416,10 +416,20 @@ let activeTrainingAttempt = null; function setTrainingError(error) { if (!trainingError) return; const message = error ? (error.message || String(error)) : ''; - setText(trainingError, message ? `Training error: ${message}` : ''); + setText(trainingError, message ? `Training action failed: ${message}` : ''); trainingError.hidden = !message; } +function markTrainingLoading() { + if (trainingCasesList) trainingCasesList.setAttribute('aria-busy', 'true'); + if (trainingProgress) trainingProgress.setAttribute('aria-busy', 'true'); +} + +function markTrainingLoaded() { + if (trainingCasesList) trainingCasesList.setAttribute('aria-busy', 'false'); + if (trainingProgress) trainingProgress.setAttribute('aria-busy', 'false'); +} + function setTrainingAttemptControls(enabled) { if (trainingAnnotationText) trainingAnnotationText.disabled = !enabled; if (trainingSaveAnnotationBtn) trainingSaveAnnotationBtn.disabled = !enabled; @@ -478,7 +488,7 @@ function renderTrainingCaseDetail(item) { if (!item) { const empty = document.createElement('div'); - empty.className = 'metric'; + empty.className = 'metric training-empty'; setText(empty, 'Select a case to review its public context.'); trainingCaseDetail.appendChild(empty); return; @@ -509,8 +519,9 @@ function renderTrainingCases(items) { if (!items.length) { const empty = document.createElement('div'); - empty.className = 'metric'; - setText(empty, 'No training cases available'); + empty.className = 'metric training-empty'; + empty.setAttribute('role', 'listitem'); + setText(empty, 'No training cases available. Import a case package to begin.'); trainingCasesList.appendChild(empty); renderTrainingCaseDetail(null); return; @@ -519,10 +530,12 @@ function renderTrainingCases(items) { items.forEach((item) => { const card = document.createElement('div'); card.className = 'training-case-card'; + card.setAttribute('role', 'listitem'); + const itemTitle = trainingCaseTitle(item); const title = document.createElement('div'); title.className = 'value-small'; - setText(title, trainingCaseTitle(item)); + setText(title, itemTitle); const skill = document.createElement('div'); skill.className = 'metric'; @@ -541,11 +554,13 @@ function renderTrainingCases(items) { const detailBtn = document.createElement('button'); detailBtn.type = 'button'; + detailBtn.setAttribute('aria-label', `View detail for ${itemTitle}`); setText(detailBtn, 'View detail'); detailBtn.addEventListener('click', () => loadTrainingCaseDetail(item)); const startBtn = document.createElement('button'); startBtn.type = 'button'; + startBtn.setAttribute('aria-label', `Start attempt for ${itemTitle}`); setText(startBtn, 'Start attempt'); startBtn.addEventListener('click', () => startTrainingAttempt(item)); @@ -562,8 +577,9 @@ function renderTrainingProgress(items) { if (!items.length) { const empty = document.createElement('div'); - empty.className = 'metric'; - setText(empty, 'No progress yet'); + empty.className = 'metric training-empty'; + empty.setAttribute('role', 'listitem'); + setText(empty, 'No progress yet. Start an attempt to track progress.'); trainingProgress.appendChild(empty); return; } @@ -571,6 +587,7 @@ function renderTrainingProgress(items) { items.forEach((item) => { const row = document.createElement('div'); row.className = 'training-progress-row'; + row.setAttribute('role', 'listitem'); const skill = document.createElement('div'); skill.className = 'value-small'; @@ -593,6 +610,7 @@ function renderTrainingProgress(items) { async function loadTrainingDashboard() { if (!trainingCasesList || !trainingProgress) return; + markTrainingLoading(); try { setTrainingError(null); const [cases, progress] = await Promise.all([ @@ -604,6 +622,8 @@ async function loadTrainingDashboard() { renderTrainingProgress(progress.items || []); } catch (error) { setTrainingError(error); + } finally { + markTrainingLoaded(); } } diff --git a/src/web/index.html b/src/web/index.html index b998e26..84283e1 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -55,29 +55,47 @@

Training / 训练

- -
- - - + + +
+

Import / Export

+

Paste a case package JSON from a trusted export.

+
+
+ + +
+ + +
-
-

Cases

-
Loading cases...
+
+

Cases

+
Loading cases...
-
-

Case Detail

-
Select a case to review its public context.
+
+

Case Detail

+

Only public training context is shown.

+
Select a case to review its public context.
-
-

Progress

-
Loading progress...
-
No active attempt
- +
+

Progress

+
Loading progress...
+
No active attempt
+
+ + +

Record observations for the active attempt.

+
- - + +
diff --git a/src/web/styles.css b/src/web/styles.css index 507f996..88be23b 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -180,6 +180,34 @@ th { color: #9db0d3; } .card.clickable-card:hover { border-color: #4f7cff; } +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid #fbbf24; + outline-offset: 2px; +} +.training-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +.training-nav a { + padding: 7px 10px; + border: 1px solid #31436c; + border-radius: 8px; + color: #dbe6ff; + text-decoration: none; + background: #0f1530; +} +.training-nav a:hover { + border-color: #4f7cff; +} +.training-transfer { + margin-bottom: 14px; +} .training-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) minmax(260px, 0.8fr); @@ -194,8 +222,22 @@ th { color: #9db0d3; } display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; - align-items: stretch; - margin-bottom: 14px; + align-items: end; +} +.training-field { + display: grid; + gap: 6px; +} +.training-field label { + color: #cbd5e1; + font-size: 13px; + font-weight: 600; +} +.training-help { + margin: 0 0 8px; + color: #91a2c2; + font-size: 12px; + line-height: 1.4; } .training-case-list, .training-progress { @@ -260,6 +302,9 @@ th { color: #9db0d3; } cursor: not-allowed; opacity: 0.55; } +.training-empty { + padding: 10px 0; +} #trainingImportText, #trainingAnnotationText { width: 100%; diff --git a/tests/unit/test_training_dashboard_m3.py b/tests/unit/test_training_dashboard_m3.py index 540c104..40bedef 100644 --- a/tests/unit/test_training_dashboard_m3.py +++ b/tests/unit/test_training_dashboard_m3.py @@ -1,5 +1,6 @@ import re import unittest +from html.parser import HTMLParser from pathlib import Path @@ -10,6 +11,46 @@ def _read_web_file(name: str) -> str: return (WEB_ROOT / name).read_text(encoding="utf-8") +class _TrainingMarkupParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.elements: list[tuple[str, dict[str, str]]] = [] + self.labels_by_for: dict[str, str] = {} + self.links: list[dict[str, str]] = [] + self.buttons_by_id: dict[str, dict[str, str]] = {} + self._active_label_for: str | None = None + self._active_label_text: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attr_map = {key: value or "" for key, value in attrs} + self.elements.append((tag, attr_map)) + if tag == "label": + self._active_label_for = attr_map.get("for") + self._active_label_text = [] + elif tag == "a": + self.links.append(attr_map) + elif tag == "button" and attr_map.get("id"): + self.buttons_by_id[attr_map["id"]] = attr_map + + def handle_data(self, data: str) -> None: + if self._active_label_for: + self._active_label_text.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "label" and self._active_label_for: + self.labels_by_for[self._active_label_for] = " ".join( + part.strip() for part in self._active_label_text if part.strip() + ) + self._active_label_for = None + self._active_label_text = [] + + +def _parse_index_html() -> _TrainingMarkupParser: + parser = _TrainingMarkupParser() + parser.feed(_read_web_file("index.html")) + return parser + + class TrainingDashboardM3Tests(unittest.TestCase): def test_training_entry_and_minimal_shell_exist(self) -> None: index_html = _read_web_file("index.html") @@ -121,6 +162,79 @@ class TrainingDashboardM3Tests(unittest.TestCase): with self.subTest(handler=handler): self.assertIn(handler, app_js) + def test_training_dashboard_accessibility_and_navigation_contract(self) -> None: + index_html = _read_web_file("index.html") + styles_css = _read_web_file("styles.css") + parser = _parse_index_html() + attrs_by_id = {attrs["id"]: attrs for _tag, attrs in parser.elements if attrs.get("id")} + + expected_labels = { + "trainingImportText": "Import case package JSON", + "trainingAnnotationText": "Training annotation note", + } + for control_id, label_text in expected_labels.items(): + with self.subTest(control_id=control_id): + self.assertEqual(parser.labels_by_for.get(control_id), label_text) + + expected_nav_targets = { + "#trainingTransferPanel", + "#trainingCasesPanel", + "#trainingDetailPanel", + "#trainingProgressPanel", + } + self.assertTrue(expected_nav_targets.issubset({link.get("href") for link in parser.links})) + + expected_regions = { + "trainingTransferPanel", + "trainingCasesPanel", + "trainingDetailPanel", + "trainingProgressPanel", + } + self.assertTrue(expected_regions.issubset(attrs_by_id)) + + self.assertEqual(attrs_by_id["trainingError"].get("role"), "alert") + self.assertEqual(attrs_by_id["trainingError"].get("aria-live"), "assertive") + self.assertEqual(attrs_by_id["trainingCasesList"].get("role"), "list") + self.assertEqual(attrs_by_id["trainingCasesList"].get("aria-live"), "polite") + self.assertEqual(attrs_by_id["trainingProgress"].get("aria-live"), "polite") + self.assertIn("trainingImportHelp", index_html) + self.assertIn("trainingAnnotationHelp", index_html) + self.assertIn("Only public training context is shown.", index_html) + + for button_id in ( + "trainingImportBtn", + "trainingExportBtn", + "trainingSaveAnnotationBtn", + "trainingSubmitBtn", + ): + with self.subTest(button_id=button_id): + self.assertEqual(parser.buttons_by_id[button_id].get("type"), "button") + + for expected_css in ( + ".training-nav", + ".training-empty", + ":focus-visible", + ): + with self.subTest(expected_css=expected_css): + self.assertIn(expected_css, styles_css) + + def test_training_dashboard_dynamic_states_are_actionable_and_a11y_friendly(self) -> None: + app_js = _read_web_file("app.js") + + for expected in ( + "No training cases available. Import a case package to begin.", + "No progress yet. Start an attempt to track progress.", + "Training action failed:", + "trainingCasesList.setAttribute('aria-busy', 'true')", + "trainingCasesList.setAttribute('aria-busy', 'false')", + "detailBtn.setAttribute('aria-label'", + "startBtn.setAttribute('aria-label'", + "card.setAttribute('role', 'listitem')", + "empty.className = 'metric training-empty'", + ): + with self.subTest(expected=expected): + self.assertIn(expected, app_js) + def test_training_optional_controls_are_guarded(self) -> None: app_js = _read_web_file("app.js")