openvibers/openvibe.live Commits
Accessibility (WS-T task 3): the browser smoke's --a11y runs axe-core (WCAG 2.1 A/AA) on every route and fails on serious or critical violations; fixed what it found — the decorative hero collage's links are not focusable, the key-sequence slots are a labelled group, chat send buttons are named, the chat mode select is labelled
Changes
@@@ -16,7 +16,7 @@ npm run seed # Seed sample datanode --check <file.js> # Syntax check (no linter configured)npm test # All unit/security/migration/deploy tests + size budgets (test/run.js)node test/<file>.test.js # One test--BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server + Chrome)++BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server + Chrome); add -- --a11y for axe (WCAG 2.1 AA)```**No build step.** Frontend is plain JS served directly — no bundler, no transpiler. Asset URLs are content-hashed at serve time (`server/web/assets.js`) — **never add `?v=` by hand**.@@@ -1161,7 +1161,7 @@ WantedBy=multi-user.target</code><div class="chat-input-row"><textarea id="bc-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>-- <button class="chat-send-btn" onclick="sendChat()">++ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message"><i class="fa-solid fa-paper-plane"></i></button></div>@@@ -182,7 +182,7 @@<div class="chat-input-row"><textarea id="chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>-- <button class="chat-send-btn" onclick="sendChat()">++ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message"><i class="fa-solid fa-paper-plane"></i></button></div>@@@ -304,7 +304,7 @@<div class="chat-input-row"><textarea id="offline-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>-- <button class="chat-send-btn" onclick="sendChat()">++ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message"><i class="fa-solid fa-paper-plane"></i></button></div>@@@ -101,7 +101,7 @@<p class="muted" id="chat-mode-subtitle">Live messages from all streams and the lobby</p></div><div class="chat-header-controls">-- <select id="chat-mode-select" class="form-input form-input-sm chat-mode-select" onchange="onChatModeChange(this.value)">++ <select id="chat-mode-select" class="form-input form-input-sm chat-mode-select" aria-label="Chat mode" onchange="onChatModeChange(this.value)"><option value="global">Global</option><option value="voice" id="chat-mode-voice-opt" disabled>Voice Call</option></select>@@@ -143,7 +143,7 @@<div class="chat-input-row"><textarea id="global-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>-- <button class="chat-send-btn" onclick="sendChat()">++ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message"><i class="fa-solid fa-paper-plane"></i></button></div>@@@ -272,7 +272,7 @@<div class="hero-egg-body"><div class="hero-egg-inner"><p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>-- <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>++ <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div><ol class="hero-egg-clues" id="hero-egg-clues"></ol><div class="hero-egg-pad" id="hero-egg-pad"><div class="hero-egg-pad-arrows">@@@ -721,6 +721,7 @@ function renderHeroCollage(media) {const cards = [];for (let i = 0; i < count; i++) {const el = document.createElement('a');++ el.tabIndex = -1; // decorative (the collage is aria-hidden): the same items are reachable in the page// Depth: z in [-260, 120]. Far cards (negative z) are smaller, dimmer, softly blurred;// near cards are bigger, brighter, crisp — with mouse-parallax on the whole scene this// reads as real 3D depth.@@@ -33,6 +33,18 @@ const ROUTES = [['/no-such-page', 'page-not-found'],];const ONLY_SIGNED = process.argv.includes('--signed-in-only');++// --a11y (or A11Y=1): axe-core on every route at the widest width; serious and critical WCAG 2.1 A/AA++// violations fail (roadmap WS-T task 3). axe is fetched once from jsDelivr at this pinned version.++const A11Y = process.argv.includes('--a11y') || process.env.A11Y === '1';++const AXE_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/axe.min.js';++let axeSource = null;++async function axeViolations(cdp) {++ if (!axeSource) { const r = await fetch(AXE_URL); if (!r.ok) throw new Error(`axe-core: ${r.status}`); axeSource = await r.text(); }++ await cdp.evaluate(`${axeSource};0`);++ const json = await cdp.evaluate(`axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, resultTypes: ['violations'] })++ .then((r) => JSON.stringify(r.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical').map((v) => \`\${v.id} (\${v.impact}) \${v.nodes.slice(0, 2).map((n) => n.target.join(' ')).join(', ')}\`)))`);++ return JSON.parse(json || '[]');++}const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];// Counters installed before any page script runs.@@@ -106,6 +118,10 @@ const pageState = `(() => ({if (errors.length) problems.push(`errors: ${errors.slice(0, 2).join(' | ')}`);if (st.hscroll) problems.push(`horizontal overflow (${st.overflowers.join(', ')})`);if (st.skeletons) problems.push(`${st.skeletons} skeleton(s) still showing`);++ if (A11Y && width === Math.max(...WIDTHS)) {++ const v = await axeViolations(cdp);++ if (v.length) problems.push(`accessibility: ${v.join(' | ')}`);++ }if (problems.length) fail(`${label}: ${problems.join('; ')}`); else pass(label);}cdp.close();
@@ -16,7 +16,7 @@ npm run seed # Seed sample data
@@ -16,7 +16,7 @@ npm run seed # Seed sample data
node --check <file.js> # Syntax check (no linter configured)
node --check <file.js> # Syntax check (no linter configured)
npm test # All unit/security/migration/deploy tests + size budgets (test/run.js)
npm test # All unit/security/migration/deploy tests + size budgets (test/run.js)
node test/<file>.test.js # One test
node test/<file>.test.js # One test
-BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server + Chrome)
+BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server + Chrome); add -- --a11y for axe (WCAG 2.1 AA)
```
```
**No build step.** Frontend is plain JS served directly — no bundler, no transpiler. Asset URLs are content-hashed at serve time (`server/web/assets.js`) — **never add `?v=` by hand**.
**No build step.** Frontend is plain JS served directly — no bundler, no transpiler. Asset URLs are content-hashed at serve time (`server/web/assets.js`) — **never add `?v=` by hand**.
@@ -1161,7 +1161,7 @@ WantedBy=multi-user.target</code>
@@ -1161,7 +1161,7 @@ WantedBy=multi-user.target</code>
<div class="chat-input-row">
<div class="chat-input-row">
<textarea id="bc-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
<textarea id="bc-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
- <button class="chat-send-btn" onclick="sendChat()">
+ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message">
<i class="fa-solid fa-paper-plane"></i>
<i class="fa-solid fa-paper-plane"></i>
</button>
</button>
</div>
</div>
@@ -182,7 +182,7 @@
@@ -182,7 +182,7 @@
<div class="chat-input-row">
<div class="chat-input-row">
<textarea id="chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
<textarea id="chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
- <button class="chat-send-btn" onclick="sendChat()">
+ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message">
<i class="fa-solid fa-paper-plane"></i>
<i class="fa-solid fa-paper-plane"></i>
</button>
</button>
</div>
</div>
@@ -304,7 +304,7 @@
@@ -304,7 +304,7 @@
<div class="chat-input-row">
<div class="chat-input-row">
<textarea id="offline-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
<textarea id="offline-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
- <button class="chat-send-btn" onclick="sendChat()">
+ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message">
<i class="fa-solid fa-paper-plane"></i>
<i class="fa-solid fa-paper-plane"></i>
</button>
</button>
</div>
</div>
@@ -101,7 +101,7 @@
@@ -101,7 +101,7 @@
<p class="muted" id="chat-mode-subtitle">Live messages from all streams and the lobby</p>
<p class="muted" id="chat-mode-subtitle">Live messages from all streams and the lobby</p>
</div>
</div>
<div class="chat-header-controls">
<div class="chat-header-controls">
- <select id="chat-mode-select" class="form-input form-input-sm chat-mode-select" onchange="onChatModeChange(this.value)">
+ <select id="chat-mode-select" class="form-input form-input-sm chat-mode-select" aria-label="Chat mode" onchange="onChatModeChange(this.value)">
<option value="global">Global</option>
<option value="global">Global</option>
<option value="voice" id="chat-mode-voice-opt" disabled>Voice Call</option>
<option value="voice" id="chat-mode-voice-opt" disabled>Voice Call</option>
</select>
</select>
@@ -143,7 +143,7 @@
@@ -143,7 +143,7 @@
<div class="chat-input-row">
<div class="chat-input-row">
<textarea id="global-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
<textarea id="global-chat-input" class="chat-textarea" placeholder="Send a message..." maxlength="500"
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
rows="1" autocomplete="off" spellcheck="true" enterkeyhint="send"></textarea>
- <button class="chat-send-btn" onclick="sendChat()">
+ <button class="chat-send-btn" onclick="sendChat()" aria-label="Send message">
<i class="fa-solid fa-paper-plane"></i>
<i class="fa-solid fa-paper-plane"></i>
</button>
</button>
</div>
</div>
@@ -272,7 +272,7 @@
@@ -272,7 +272,7 @@
<div class="hero-egg-body">
<div class="hero-egg-body">
<div class="hero-egg-inner">
<div class="hero-egg-inner">
<p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
<p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
- <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>
+ <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div>
<ol class="hero-egg-clues" id="hero-egg-clues"></ol>
<ol class="hero-egg-clues" id="hero-egg-clues"></ol>
<div class="hero-egg-pad" id="hero-egg-pad">
<div class="hero-egg-pad" id="hero-egg-pad">
<div class="hero-egg-pad-arrows">
<div class="hero-egg-pad-arrows">
@@ -721,6 +721,7 @@ function renderHeroCollage(media) {
@@ -721,6 +721,7 @@ function renderHeroCollage(media) {
const cards = [];
const cards = [];
for (let i = 0; i < count; i++) {
for (let i = 0; i < count; i++) {
const el = document.createElement('a');
const el = document.createElement('a');
+ el.tabIndex = -1; // decorative (the collage is aria-hidden): the same items are reachable in the page
// Depth: z in [-260, 120]. Far cards (negative z) are smaller, dimmer, softly blurred;
// Depth: z in [-260, 120]. Far cards (negative z) are smaller, dimmer, softly blurred;
// near cards are bigger, brighter, crisp — with mouse-parallax on the whole scene this
// near cards are bigger, brighter, crisp — with mouse-parallax on the whole scene this
// reads as real 3D depth.
// reads as real 3D depth.
@@ -33,6 +33,18 @@ const ROUTES = [
@@ -33,6 +33,18 @@ const ROUTES = [
['/no-such-page', 'page-not-found'],
['/no-such-page', 'page-not-found'],
];
];
const ONLY_SIGNED = process.argv.includes('--signed-in-only');
const ONLY_SIGNED = process.argv.includes('--signed-in-only');
+// --a11y (or A11Y=1): axe-core on every route at the widest width; serious and critical WCAG 2.1 A/AA
+// violations fail (roadmap WS-T task 3). axe is fetched once from jsDelivr at this pinned version.
+const A11Y = process.argv.includes('--a11y') || process.env.A11Y === '1';
+const AXE_URL = 'https://cdn.jsdelivr.net/npm/[email protected]/axe.min.js';
+let axeSource = null;
+async function axeViolations(cdp) {
+ if (!axeSource) { const r = await fetch(AXE_URL); if (!r.ok) throw new Error(`axe-core: ${r.status}`); axeSource = await r.text(); }
+ await cdp.evaluate(`${axeSource};0`);
+ const json = await cdp.evaluate(`axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, resultTypes: ['violations'] })
+ .then((r) => JSON.stringify(r.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical').map((v) => \`\${v.id} (\${v.impact}) \${v.nodes.slice(0, 2).map((n) => n.target.join(' ')).join(', ')}\`)))`);
+ return JSON.parse(json || '[]');
+}
const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];
const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];
// Counters installed before any page script runs.
// Counters installed before any page script runs.
@@ -106,6 +118,10 @@ const pageState = `(() => ({
@@ -106,6 +118,10 @@ const pageState = `(() => ({
if (errors.length) problems.push(`errors: ${errors.slice(0, 2).join(' | ')}`);
if (errors.length) problems.push(`errors: ${errors.slice(0, 2).join(' | ')}`);
if (st.hscroll) problems.push(`horizontal overflow (${st.overflowers.join(', ')})`);
if (st.hscroll) problems.push(`horizontal overflow (${st.overflowers.join(', ')})`);
if (st.skeletons) problems.push(`${st.skeletons} skeleton(s) still showing`);
if (st.skeletons) problems.push(`${st.skeletons} skeleton(s) still showing`);
+ if (A11Y && width === Math.max(...WIDTHS)) {
+ const v = await axeViolations(cdp);
+ if (v.length) problems.push(`accessibility: ${v.join(' | ')}`);
+ }
if (problems.length) fail(`${label}: ${problems.join('; ')}`); else pass(label);
if (problems.length) fail(`${label}: ${problems.join('; ')}`); else pass(label);
}
}
cdp.close();
cdp.close();