openvibers/openvibe.community Commits
Pinned and locked are words, not aria-labels on icons (WCAG AA) (#223)
Changes
@@@ -230,7 +230,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe<header class="thread-head">${space.votes === false ? '' : f.voteForm(base, thread, viewer)}<div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}<p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p></div>@@@ -95,7 +95,7 @@ ${list}`;// ── /s/:space ────────────────────────────────────────────────function threadRow(t, space) {-- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;++ const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}<div class="thread-main">@@@ -275,7 +275,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<header class="thread-head">${space.votes === false ? '' : voteForm(base, thread, viewer)}<div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p><p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// WCAG 2.1 AA (aria-prohibited-attr): an aria-label on an <i> with no role is prohibited. The pin and lock++// icons are decorative; the words sit in sr-only text inside the heading, so screen readers still say them.++// node test/board-pin-icon.test.js++const assert = require('assert');++const board = require('../server/render/board');++++const html = String(board.forumTopicPage({++ space: { slug: 'general', name: 'General', visibility: 'public', votes: false, statuses: [] },++ thread: { slug: 'welcome', title: 'Welcome', pinned: true, locked: true, reply_count: 0, views: 0, created_at: '2026-01-01T00:00:00Z' },++ posts: [], page: 1, pages: 1, viewer: {}, user: null,++}));++const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);++assert.ok(h1.includes('<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');++assert.ok(h1.includes('<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');++assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');++console.log('board pin: the icons are hidden, the words read');@@@ -115,8 +115,11 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringconst r = await t.get(url, as(samJwt));has(r.text, 'This thread is locked.');assert.ok(!r.text.includes(`action="${url}/reply"`));++ has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');++ assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');const list = await t.get('/s/general');-- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');++ has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');++ assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');const admin = await t.get(url, as(adminJwt));has(admin.text, 'Unpin');has(admin.text, 'Unlock');
@@ -230,7 +230,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -230,7 +230,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
<div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Sticky" aria-label="Sticky"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
+ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
<p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
<p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
</div>
</div>
@@ -95,7 +95,7 @@ ${list}`;
@@ -95,7 +95,7 @@ ${list}`;
// ── /s/:space ────────────────────────────────────────────────
// ── /s/:space ────────────────────────────────────────────────
function threadRow(t, space) {
function threadRow(t, space) {
- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}`;
+ const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}">
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
<div class="thread-main">
@@ -275,7 +275,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -275,7 +275,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
+ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
<p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a></p>
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// WCAG 2.1 AA (aria-prohibited-attr): an aria-label on an <i> with no role is prohibited. The pin and lock
+// icons are decorative; the words sit in sr-only text inside the heading, so screen readers still say them.
+// node test/board-pin-icon.test.js
+const assert = require('assert');
+const board = require('../server/render/board');
+
+const html = String(board.forumTopicPage({
+ space: { slug: 'general', name: 'General', visibility: 'public', votes: false, statuses: [] },
+ thread: { slug: 'welcome', title: 'Welcome', pinned: true, locked: true, reply_count: 0, views: 0, created_at: '2026-01-01T00:00:00Z' },
+ posts: [], page: 1, pages: 1, viewer: {}, user: null,
+}));
+const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);
+assert.ok(h1.includes('<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');
+assert.ok(h1.includes('<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');
+assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');
+console.log('board pin: the icons are hidden, the words read');
@@ -115,8 +115,11 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -115,8 +115,11 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const r = await t.get(url, as(samJwt));
const r = await t.get(url, as(samJwt));
has(r.text, 'This thread is locked.');
has(r.text, 'This thread is locked.');
assert.ok(!r.text.includes(`action="${url}/reply"`));
assert.ok(!r.text.includes(`action="${url}/reply"`));
+ has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
+ assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
const list = await t.get('/s/general');
const list = await t.get('/s/general');
- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i>');
+ has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
+ assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
const admin = await t.get(url, as(adminJwt));
const admin = await t.get(url, as(adminJwt));
has(admin.text, 'Unpin');
has(admin.text, 'Unpin');
has(admin.text, 'Unlock');
has(admin.text, 'Unlock');