fix(a11y): improve accessibility across all views

- 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
This commit is contained in:
chiguyong 2026-06-20 18:40:51 +08:00
parent 7725cf1f65
commit 5a7155ecbc
3 changed files with 192 additions and 35 deletions

90
app.js
View File

@ -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) => `
<article class="role-card" data-role-id="${role.id}">
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.name}${role.desc},每月${role.price}元">
<img class="role-card__avatar" src="${role.avatar}" alt="${role.name}" />
<div class="role-card__info">
<h3 class="role-card__name">${role.name}</h3>
@ -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) => `
<article class="role-card" data-role-id="${role.id}">
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.name}${role.status === 'running' ? '运行中' : '已停止'}">
<img class="role-card__avatar" src="${role.avatar}" alt="${role.name}" />
<div class="role-card__info">
<h3 class="role-card__name">${role.name}</h3>
@ -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();
})();

View File

@ -7,9 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css?v=6" />
<link rel="stylesheet" href="styles.css?v=7" />
</head>
<body>
<a href="#landing" class="skip-link">跳到主内容</a>
<main class="app">
<!-- P1: Landing View -->
<section id="landing" class="view view--landing active">
@ -53,20 +54,20 @@
<div class="stepper" aria-hidden="true"></div>
</header>
<div class="auth-tabs">
<button class="auth-tab active" type="button" data-tab="login">登录</button>
<button class="auth-tab" type="button" data-tab="register">注册</button>
<div class="auth-tabs" role="tablist" aria-label="登录与注册">
<button class="auth-tab active" type="button" role="tab" aria-selected="true" data-tab="login">登录</button>
<button class="auth-tab" type="button" role="tab" aria-selected="false" data-tab="register">注册</button>
</div>
<form id="login-form" class="auth-form active" autocomplete="off" data-form="login">
<form id="login-form" class="auth-form active" autocomplete="on" data-form="login">
<div class="field-group">
<label class="field">
<span class="field__label">手机号 / 用户名</span>
<input class="field__input" name="account" type="text" placeholder="请输入手机号或用户名" required />
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="请输入手机号或用户名" required />
</label>
<label class="field">
<span class="field__label">密码</span>
<input class="field__input" name="password" type="password" placeholder="请输入密码" required />
<input class="field__input" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" required />
</label>
</div>
<div class="form-actions">
@ -75,19 +76,19 @@
<p class="auth-hint">登录后可查看角色库、管理已订阅的角色。</p>
</form>
<form id="register-form" class="auth-form" autocomplete="off" data-form="register">
<form id="register-form" class="auth-form" autocomplete="on" data-form="register">
<div class="field-group">
<label class="field">
<span class="field__label">手机号 / 用户名</span>
<input class="field__input" name="account" type="text" placeholder="设置登录账号" required />
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="设置登录账号" required />
</label>
<label class="field">
<span class="field__label">密码</span>
<input class="field__input" name="password" type="password" placeholder="设置密码" required minlength="6" />
<input class="field__input" name="password" type="password" autocomplete="new-password" placeholder="设置密码" required minlength="6" />
</label>
<label class="field">
<span class="field__label">确认密码</span>
<input class="field__input" name="confirmPassword" type="password" placeholder="再次输入密码" required />
<input class="field__input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="再次输入密码" required />
</label>
</div>
<div class="form-actions">
@ -108,7 +109,7 @@
<div class="stepper" aria-hidden="true"></div>
</header>
<div class="role-list" id="role-list"></div>
<div class="role-list" id="role-list" aria-live="polite" aria-label="角色列表"></div>
<div class="empty-state" id="library-empty" hidden>
<p class="empty-state__text">你还没有绑定专属创作者</p>
@ -324,15 +325,15 @@
<div class="stepper" aria-hidden="true"></div>
</header>
<div class="center-tabs">
<button class="center-tab active" type="button" data-center-tab="roles">我的角色</button>
<button class="center-tab" type="button" data-center-tab="income">收入</button>
<button class="center-tab" type="button" data-center-tab="settings">我的</button>
<div class="center-tabs" role="tablist" aria-label="创作者管理中心">
<button class="center-tab active" type="button" role="tab" aria-selected="true" data-center-tab="roles">我的角色</button>
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="income">收入</button>
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="settings">我的</button>
</div>
<!-- Tab: Roles -->
<div class="center-panel active" id="center-roles">
<div class="role-list" id="creator-role-list"></div>
<div class="role-list" id="creator-role-list" aria-live="polite" aria-label="我的角色列表"></div>
<div class="form-actions">
<button class="btn btn--primary btn--block" type="button" data-action="new-role">+ 新建角色</button>
</div>
@ -346,7 +347,7 @@
</div>
<div class="income-section">
<h3 class="income-section__title">流水明细</h3>
<div class="income-list" id="income-list">
<div class="income-list" id="income-list" aria-live="polite" aria-label="收入流水">
<p class="income-empty">暂无流水记录</p>
</div>
</div>
@ -561,9 +562,9 @@
</div>
</div>
<div class="result-panel__preview">
<div class="preview-tabs">
<button class="preview-tab active" type="button" data-tab="soul">Soul.md</button>
<button class="preview-tab" type="button" data-tab="config">config.yaml</button>
<div class="preview-tabs" role="tablist" aria-label="配置文件预览">
<button class="preview-tab active" type="button" role="tab" aria-selected="true" data-tab="soul">Soul.md</button>
<button class="preview-tab" type="button" role="tab" aria-selected="false" data-tab="config">config.yaml</button>
</div>
<pre class="preview-code" id="preview-code"><code></code></pre>
</div>
@ -576,21 +577,24 @@
</main>
<!-- Tab Bar (U8) -->
<nav class="tab-bar" id="tab-bar">
<button class="tab-bar__item active" type="button" data-tab-action="tab-home">
<span class="tab-bar__icon"></span>
<nav class="tab-bar" id="tab-bar" role="tablist" aria-label="主导航">
<button class="tab-bar__item active" type="button" role="tab" aria-selected="true" data-tab-action="tab-home">
<span class="tab-bar__icon" aria-hidden="true"></span>
<span class="tab-bar__label">首页</span>
</button>
<button class="tab-bar__item" type="button" data-tab-action="tab-distill">
<span class="tab-bar__icon"></span>
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-distill">
<span class="tab-bar__icon" aria-hidden="true"></span>
<span class="tab-bar__label">蒸馏前任</span>
</button>
<button class="tab-bar__item" type="button" data-tab-action="tab-mine">
<span class="tab-bar__icon"></span>
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-mine">
<span class="tab-bar__icon" aria-hidden="true"></span>
<span class="tab-bar__label" id="tab-mine-label">我的</span>
</button>
</nav>
<!-- Screen reader live region for view change announcements -->
<div id="sr-announce" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"></div>
<script src="app.js"></script>
</body>
</html>

View File

@ -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;
}
}