a11y: WCAG AA contrast + ARIA roles + focus management across all pages

- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px)
- js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support
- test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px
- board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed
- All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-16 11:42:38 +03:00
parent 3a4623a60a
commit 26ba289019
22 changed files with 362 additions and 299 deletions
+29 -12
View File
@@ -111,9 +111,9 @@
.confirm-desc { font-size: 0.88rem; color: var(--text-2); line-height: 1.6; margin-bottom: 24px; }
.confirm-skipped { display: inline-block; margin: 8px 0 4px; padding: 6px 16px; border-radius: var(--r-pill); background: rgba(255,179,71,0.12); color: #c47f00; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
.confirm-cancel { padding: 11px 26px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.confirm-cancel { padding: 11px 26px; min-height: 44px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.confirm-cancel:hover { border-color: var(--violet); color: var(--violet); }
.confirm-ok { padding: 11px 26px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
.confirm-ok { padding: 11px 26px; min-height: 44px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
.confirm-ok:hover { transform: translateY(-1px); }
/* ── btn-finish counter ── */
@@ -201,13 +201,13 @@
</div>
<!-- Confirm finish modal -->
<div class="confirm-overlay" id="confirm-overlay" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-overlay" id="confirm-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-dlg-title" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-box">
<div class="confirm-icon" id="confirm-icon"></div>
<div class="confirm-title">Завершить тест?</div>
<div class="confirm-title" id="confirm-dlg-title">Завершить тест?</div>
<div class="confirm-desc" id="confirm-desc"></div>
<div class="confirm-actions">
<button class="confirm-cancel" onclick="closeConfirm()">Вернуться</button>
<button class="confirm-cancel" id="confirm-cancel-btn" onclick="closeConfirm()">Вернуться</button>
<button class="confirm-ok" onclick="doFinish()">Завершить</button>
</div>
</div>
@@ -403,12 +403,16 @@
? (Array.isArray(answers[q.id]) && answers[q.id].includes(opt.id))
: answers[q.id] === opt.id;
const keyLabel = isMulti ? (sel ? lsIcon('check', 14) : lsIcon('square', 14)) : String.fromCharCode(65 + i);
return `<div class="q-opt${sel ? ' selected' : ''}" data-opt-id="${opt.id}" data-i="${i}">
<div class="q-opt-key">${keyLabel}</div>
return `<div class="q-opt${sel ? ' selected' : ''}"
role="${isMulti ? 'checkbox' : 'radio'}"
aria-checked="${sel}"
tabindex="0"
data-opt-id="${opt.id}" data-i="${i}">
<div class="q-opt-key" aria-hidden="true">${keyLabel}</div>
<div class="q-opt-text">${esc(opt.text)}</div>
</div>`;
}).join('');
bodyHtml = `<div class="q-options" id="opts">${optHtml}</div>`;
bodyHtml = `<div class="q-options" id="opts" role="${isMulti ? 'group' : 'radiogroup'}" aria-label="Варианты ответа">${optHtml}</div>`;
if (isMulti) {
bodyHtml += `<div class="q-hint" style="margin-top:10px">Можно выбрать несколько вариантов</div>`;
}
@@ -421,7 +425,7 @@
document.getElementById('q-area').innerHTML = `
<div class="q-card active">
<button class="btn-flag${flags[q.id] ? ' flagged' : ''}" id="btn-flag-${q.id}" title="Отметить для проверки" onclick="toggleFlag(${q.id})">
<button class="btn-flag${flags[q.id] ? ' flagged' : ''}" id="btn-flag-${q.id}" aria-label="${flags[q.id] ? 'Снять отметку' : 'Отметить для проверки'}" aria-pressed="${flags[q.id] ? 'true' : 'false'}" onclick="toggleFlag(${q.id})">
<svg viewBox="0 0 24 24" width="16" height="16" fill="${flags[q.id] ? '#f59e0b' : 'none'}" stroke="${flags[q.id] ? '#f59e0b' : 'currentColor'}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
</button>
<div class="q-num-badge">
@@ -455,10 +459,14 @@
} else {
const isMulti = type === 'multi';
document.querySelectorAll('.q-opt').forEach(el => {
el.addEventListener('click', () => {
const handleSelect = () => {
const optId = Number(el.dataset.optId);
if (isMulti) toggleMultiOpt(q, optId);
else selectSingleOpt(q, optId);
};
el.addEventListener('click', handleSelect);
el.addEventListener('keydown', e => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleSelect(); }
});
});
}
@@ -518,6 +526,7 @@
document.querySelectorAll('.q-opt').forEach((el, i) => {
const sel = q.options[i].id === option_id;
el.classList.toggle('selected', sel);
el.setAttribute('aria-checked', sel ? 'true' : 'false');
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : String.fromCharCode(65 + i);
if (sel) {
// attach progress ring svg
@@ -558,6 +567,7 @@
const id = Number(el.dataset.optId);
const sel = arr.includes(id);
el.classList.toggle('selected', sel);
el.setAttribute('aria-checked', sel ? 'true' : 'false');
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : lsIcon('square', 14);
});
@@ -603,7 +613,9 @@
desc.innerHTML = skipped === total
? `Вы не ответили ни на один вопрос.<br>Результат будет нулевым.`
: `<span class="confirm-skipped">${lsIcon('warning', 16)} ${skipped} ${skipped === 1 ? 'вопрос без ответа' : skipped < 5 ? 'вопроса без ответа' : 'вопросов без ответа'}</span><br>Пропущенные вопросы будут засчитаны как неверные.`;
document.getElementById('confirm-overlay').classList.add('open');
const ov = document.getElementById('confirm-overlay');
ov.classList.add('open');
setTimeout(() => document.getElementById('confirm-cancel-btn')?.focus(), 50);
return;
}
@@ -722,6 +734,7 @@
const dot = document.createElement('button');
dot.className = 'dot';
dot.textContent = i + 1;
dot.setAttribute('aria-label', `Вопрос ${i + 1}`);
dot.addEventListener('click', () => { cancelAutoAdvance(); renderQuestion(i); });
el.appendChild(dot);
});
@@ -729,9 +742,11 @@
function updateDots() {
document.querySelectorAll('.dot').forEach((dot, i) => {
dot.classList.toggle('current', i === currentIdx);
const isCurrent = i === currentIdx;
dot.classList.toggle('current', isCurrent);
dot.classList.toggle('answered', isAnswered(questions[i]));
dot.classList.toggle('flagged', !!flags[questions[i].id]);
dot.setAttribute('aria-current', isCurrent ? 'step' : 'false');
});
}
@@ -742,6 +757,8 @@
const btn = document.getElementById(`btn-flag-${qid}`);
if (btn) {
btn.classList.toggle('flagged', flags[qid]);
btn.setAttribute('aria-pressed', flags[qid] ? 'true' : 'false');
btn.setAttribute('aria-label', flags[qid] ? 'Снять отметку' : 'Отметить для проверки');
btn.querySelector('svg').setAttribute('fill', flags[qid] ? '#f59e0b' : 'none');
btn.querySelector('svg').setAttribute('stroke', flags[qid] ? '#f59e0b' : 'currentColor');
}