From 5a7155ecbc32d8e7711237a81768b1000935be35 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sat, 20 Jun 2026 18:40:51 +0800 Subject: [PATCH] fix(a11y): improve accessibility across all views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FAQ: add aria-expanded/aria-controls/role=region via initFaqA11y() - TabBar/Auth/Center/Preview tabs: add role=tablist/tab/aria-selected - View switching: focus management + aria-live announcement region - Role cards: role=button, tabindex=0, Enter/Space keyboard support - Login form: autocomplete=username/current-password (was off) - Register form: autocomplete=username/new-password - Add skip-link for keyboard users - Add :focus-visible outlines on all interactive elements - Improve placeholder contrast (0.45 → 0.7 opacity) - Add prefers-reduced-motion media query - Add aria-live=polite on dynamic role-list/income-list containers - Add aria-label on all view sections --- app.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++---- index.html | 60 +++++++++++++++++++----------------- styles.css | 77 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 35 deletions(-) diff --git a/app.js b/app.js index ccdde6d..5df7d10 100644 --- a/app.js +++ b/app.js @@ -96,6 +96,19 @@ let viewHistory = ['landing']; // --- U9: Unified showView with history and tab-bar sync --- + // Human-readable labels for screen-reader announcements + const viewLabels = { + landing: '首页', + auth: '登录 / 注册', + 'role-library': '角色库', + 'role-detail': '角色详情', + distill: '蒸馏前任', + about: '关于 Eternal AI', + onboarding: '创作者入驻', + 'creator-center': '创作者管理中心', + creator: '角色编辑', + }; + function showView(name, trackHistory = true) { Object.entries(views).forEach(([key, el]) => { if (el) el.classList.toggle('active', key === name); @@ -105,6 +118,20 @@ } window.scrollTo({ top: 0, behavior: 'smooth' }); updateTabBar(name); + + // a11y: move focus to the new view so screen readers announce it + const target = views[name]; + if (target) { + target.setAttribute('tabindex', '-1'); + // Defer focus to after the scroll/layout settles + setTimeout(() => target.focus({ preventScroll: true }), 50); + } + + // a11y: announce the view change to screen readers via live region + const announcer = document.getElementById('sr-announce'); + if (announcer) { + announcer.textContent = viewLabels[name] ? `已进入${viewLabels[name]}` : ''; + } } function goBack() { @@ -127,7 +154,9 @@ }; const activeTab = tabMap[viewName] || 'tab-home'; document.querySelectorAll('.tab-bar__item').forEach((item) => { - item.classList.toggle('active', item.dataset.tabAction === activeTab); + const isActive = item.dataset.tabAction === activeTab; + item.classList.toggle('active', isActive); + item.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); } @@ -180,7 +209,9 @@ function switchAuthTab(tab) { activeAuthTab = tab; document.querySelectorAll('.auth-tab').forEach((t) => { - t.classList.toggle('active', t.dataset.tab === tab); + const isActive = t.dataset.tab === tab; + t.classList.toggle('active', isActive); + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); document.querySelectorAll('.auth-form').forEach((f) => { f.classList.toggle('active', f.dataset.form === tab); @@ -235,7 +266,7 @@ listEl.innerHTML = mockRoles .map( (role) => ` -
+
${role.name}

${role.name}

@@ -279,6 +310,35 @@ const isOpen = answer.style.display === 'block'; answer.style.display = isOpen ? 'none' : 'block'; icon.textContent = isOpen ? '+' : '−'; + // a11y: sync aria-expanded so screen readers know the toggle state + button.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); + } + + // a11y: wire up FAQ buttons with aria-expanded / aria-controls on load + function initFaqA11y() { + const faqButtons = document.querySelectorAll('.faq-q'); + faqButtons.forEach((btn, index) => { + const item = btn.closest('.faq-item'); + const answer = item.querySelector('.faq-a'); + const answerId = `faq-a-${index + 1}`; + if (answer) { + answer.id = answerId; + answer.setAttribute('role', 'region'); + answer.setAttribute('aria-labelledby', `faq-q-${index + 1}`); + } + btn.id = `faq-q-${index + 1}`; + btn.setAttribute('aria-expanded', 'false'); + btn.setAttribute('aria-controls', answerId); + }); + } + + // a11y: label each view section for screen-reader navigation + function initViewA11y() { + Object.entries(views).forEach(([key, el]) => { + if (el && viewLabels[key]) { + el.setAttribute('aria-label', viewLabels[key]); + } + }); } // --- U7: Creator Center --- @@ -294,7 +354,7 @@ listEl.innerHTML = roles .map( (role) => ` -
+
${role.name}

${role.name}

@@ -336,7 +396,9 @@ function switchCenterTab(tab) { activeCenterTab = tab; document.querySelectorAll('.center-tab').forEach((t) => { - t.classList.toggle('active', t.dataset.centerTab === tab); + const isActive = t.dataset.centerTab === tab; + t.classList.toggle('active', isActive); + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); document.querySelectorAll('.center-panel').forEach((p) => { p.classList.toggle('active', p.id === `center-${tab}`); @@ -571,11 +633,25 @@ ${data.secrets || '无'} function updateTabs() { document.querySelectorAll('.preview-tab').forEach((tab) => { - tab.classList.toggle('active', tab.dataset.tab === activePreview); + const isActive = tab.dataset.tab === activePreview; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); } // --- Event delegation --- + // a11y: keyboard support for role cards (Enter / Space activates them) + document.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const roleCard = e.target.closest('.role-card'); + if (roleCard && !e.target.closest('[data-action]')) { + e.preventDefault(); + const roleId = roleCard.dataset.roleId; + renderRoleDetail(roleId); + showView('role-detail'); + } + }); + document.addEventListener('click', (e) => { // FAQ toggle (U4) const faqBtn = e.target.closest('.faq-q'); @@ -826,4 +902,6 @@ ${data.secrets || '无'} updateSystemPromptPreview(); updateLandingCard(); updateTabBar('landing'); + initFaqA11y(); + initViewA11y(); })(); diff --git a/index.html b/index.html index 0109848..d58a31d 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,10 @@ - + +
@@ -53,20 +54,20 @@ -
- - +
+ +
-
+
@@ -75,19 +76,19 @@

登录后可查看角色库、管理已订阅的角色。

-
+
@@ -108,7 +109,7 @@ -
+
- + +
+ diff --git a/styles.css b/styles.css index a2324c3..06b4a69 100644 --- a/styles.css +++ b/styles.css @@ -433,7 +433,7 @@ body { } .field__input::placeholder { - color: rgba(154, 163, 194, 0.45); + color: rgba(154, 163, 194, 0.7); } .field__input:focus { @@ -1339,3 +1339,78 @@ textarea.field__input--tall { max-width: 560px; } } + +/* ===== Accessibility (a11y) ===== */ + +/* Visually hidden, but available to screen readers */ +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* Visible keyboard focus for all interactive elements */ +.btn:focus-visible, +.icon-btn:focus-visible, +.tab-bar__item:focus-visible, +.auth-tab:focus-visible, +.center-tab:focus-visible, +.faq-q:focus-visible, +.preview-tab:focus-visible, +.footer-link:focus-visible, +.role-card:focus-visible { + outline: 2px solid var(--accent-soft); + outline-offset: 2px; + border-radius: 6px; +} + +/* Views can receive focus via JS for screen-reader navigation */ +.view { + outline: none; +} + +.view:focus-visible { + outline: none; +} + +/* FAQ answer region: ensure it's focusable-friendly */ +.faq-a { + outline: none; +} + +/* Skip link for keyboard users */ +.skip-link { + position: absolute; + top: -40px; + left: 8px; + z-index: 1000; + padding: 8px 16px; + background: var(--accent); + color: #fff; + border-radius: 8px; + font-size: 14px; + text-decoration: none; + transition: top 0.2s; +} + +.skip-link:focus { + top: 8px; +} + +/* Respect reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +}