feat: improve training dashboard accessibility contract
This commit is contained in:
+27
-7
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user