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:
parent
7725cf1f65
commit
5a7155ecbc
90
app.js
90
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) => `
|
||||
<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();
|
||||
})();
|
||||
|
|
|
|||
60
index.html
60
index.html
|
|
@ -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>
|
||||
|
|
|
|||
77
styles.css
77
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue