feat: improve training dashboard accessibility contract

This commit is contained in:
2026-05-01 11:35:28 -04:00
parent 635e08451b
commit ab65e8d43e
4 changed files with 224 additions and 27 deletions
+27 -7
View File
@@ -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();
}
}
+36 -18
View File
@@ -55,29 +55,47 @@
<section id="trainingSection" class="panel panel-wide training-panel">
<h2>Training / 训练</h2>
<div id="trainingError" class="training-error" hidden></div>
<div class="training-toolbar">
<textarea id="trainingImportText" class="training-import" rows="2" placeholder="Paste case package JSON"></textarea>
<button id="trainingImportBtn" type="button">Import cases</button>
<button id="trainingExportBtn" type="button">Export cases</button>
<nav class="training-nav" aria-label="Training dashboard sections">
<a href="#trainingTransferPanel">Import / Export</a>
<a href="#trainingCasesPanel">Cases</a>
<a href="#trainingDetailPanel">Case Detail</a>
<a href="#trainingProgressPanel">Progress</a>
</nav>
<div id="trainingError" class="training-error" role="alert" aria-live="assertive" hidden></div>
<div id="trainingTransferPanel" class="training-transfer" aria-labelledby="trainingTransferHeading">
<h3 id="trainingTransferHeading">Import / Export</h3>
<p id="trainingImportHelp" class="training-help">Paste a case package JSON from a trusted export.</p>
<div class="training-toolbar">
<div class="training-field">
<label for="trainingImportText">Import case package JSON</label>
<textarea id="trainingImportText" class="training-import" rows="2" placeholder="Paste case package JSON" aria-describedby="trainingImportHelp"></textarea>
</div>
<button id="trainingImportBtn" type="button">Import cases</button>
<button id="trainingExportBtn" type="button">Export cases</button>
</div>
</div>
<div class="training-grid">
<div>
<h3>Cases</h3>
<div id="trainingCasesList" class="training-case-list">Loading cases...</div>
<div id="trainingCasesPanel" aria-labelledby="trainingCasesHeading">
<h3 id="trainingCasesHeading">Cases</h3>
<div id="trainingCasesList" class="training-case-list" role="list" aria-live="polite" aria-busy="true">Loading cases...</div>
</div>
<div>
<h3>Case Detail</h3>
<div id="trainingCaseDetail" class="training-detail">Select a case to review its public context.</div>
<div id="trainingDetailPanel" aria-labelledby="trainingDetailHeading">
<h3 id="trainingDetailHeading">Case Detail</h3>
<p class="training-help">Only public training context is shown.</p>
<div id="trainingCaseDetail" class="training-detail" aria-live="polite">Select a case to review its public context.</div>
</div>
<div>
<h3>Progress</h3>
<div id="trainingProgress" class="training-progress">Loading progress...</div>
<div id="trainingAttemptStatus" class="metric">No active attempt</div>
<textarea id="trainingAnnotationText" rows="4" placeholder="Annotation note" disabled></textarea>
<div id="trainingProgressPanel" aria-labelledby="trainingProgressHeading">
<h3 id="trainingProgressHeading">Progress</h3>
<div id="trainingProgress" class="training-progress" role="list" aria-live="polite" aria-busy="true">Loading progress...</div>
<div id="trainingAttemptStatus" class="metric" role="status" aria-live="polite">No active attempt</div>
<div class="training-field">
<label for="trainingAnnotationText">Training annotation note</label>
<textarea id="trainingAnnotationText" rows="4" placeholder="Annotation note" aria-describedby="trainingAnnotationHelp" disabled></textarea>
<p id="trainingAnnotationHelp" class="training-help">Record observations for the active attempt.</p>
</div>
<div class="training-actions">
<button id="trainingSaveAnnotationBtn" disabled>Save annotation</button>
<button id="trainingSubmitBtn" disabled>Submit attempt</button>
<button id="trainingSaveAnnotationBtn" type="button" disabled>Save annotation</button>
<button id="trainingSubmitBtn" type="button" disabled>Submit attempt</button>
</div>
</div>
</div>
+47 -2
View File
@@ -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%;
+114
View File
@@ -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")