openvibers/openvibe.shared Commits
Shared 4.2.1: a showcase table that scrolls is a focusable, named region (#80)
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.2.1 — 2026-10-15++++- **A showcase table that scrolls is a focusable region.** `compare()` and `limits()` wrap their table in++ `.sc-table-wrap`, which scrolls sideways on a phone. The wrapper is now `tabindex="0" role="region"`, labelled by the++ section's heading. An axe scan (WCAG 2.1 AA) of every OpenVibe home at 390 px flagged it on ai.openvibe.services++ (`scrollable-region-focusable`, serious). Code samples already were.++## 4.2.0 — 2026-10-15- **"Ask Actor" in the account menu.** Signed in, the menu's You section has Ask Actor after Notifications. It opens@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.2.0",++ "version": "4.2.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.2.0",++ "version": "4.2.1","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.2.0",++ "version": "4.2.1","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -88,11 +88,15 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));++// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name++// (axe scrollable-region-focusable, WCAG 2.1.1): focusable, and labelled by the section's heading.++const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;++/** How the product compares, feature by feature. The first column is this product. */function compare({ id: sid, title, lede, columns, rows } = {}) {if (!has(rows) || !has(columns)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;}/** Plans side by side. A price of 0 reads "Free". */@@@ -109,7 +113,7 @@ function pricing({ id: sid, title, lede, tiers, note } = {}) {function limits({ id: sid, title, lede, columns, rows, source } = {}) {if (!has(rows) || !has(columns)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;}/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */@@@ -26,6 +26,9 @@ const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'massert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');++// A table wider than a phone scrolls in its wrapper: the wrapper is a focusable region named by the section's heading.++assert.ok(c.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-compare">') && c.includes('id="sc-compare"'), 'compare table region');++assert.ok(l.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-limits">'), 'limits table region');const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });// A code sample scrolls sideways, so it is a focus stop with a name (axe scrollable-region-focusable).assert.ok(code.includes('<pre tabindex="0" aria-label="Node">'), 'code samples are keyboard-focusable and named');
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.2.1 — 2026-10-15
+
+- **A showcase table that scrolls is a focusable region.** `compare()` and `limits()` wrap their table in
+ `.sc-table-wrap`, which scrolls sideways on a phone. The wrapper is now `tabindex="0" role="region"`, labelled by the
+ section's heading. An axe scan (WCAG 2.1 AA) of every OpenVibe home at 390 px flagged it on ai.openvibe.services
+ (`scrollable-region-focusable`, serious). Code samples already were.
+
## 4.2.0 — 2026-10-15
## 4.2.0 — 2026-10-15
- **"Ask Actor" in the account menu.** Signed in, the menu's You section has Ask Actor after Notifications. It opens
- **"Ask Actor" in the account menu.** Signed in, the menu's You section has Ask Actor after Notifications. It opens
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.2.0",
+ "version": "4.2.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.2.0",
+ "version": "4.2.1",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.2.0",
+ "version": "4.2.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -88,11 +88,15 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {
@@ -88,11 +88,15 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
+// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
+// (axe scrollable-region-focusable, WCAG 2.1.1): focusable, and labelled by the section's heading.
+const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;
+
/** How the product compares, feature by feature. The first column is this product. */
/** How the product compares, feature by feature. The first column is this product. */
function compare({ id: sid, title, lede, columns, rows } = {}) {
function compare({ id: sid, title, lede, columns, rows } = {}) {
if (!has(rows) || !has(columns)) return '';
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
}
}
/** Plans side by side. A price of 0 reads "Free". */
/** Plans side by side. A price of 0 reads "Free". */
@@ -109,7 +113,7 @@ function pricing({ id: sid, title, lede, tiers, note } = {}) {
@@ -109,7 +113,7 @@ function pricing({ id: sid, title, lede, tiers, note } = {}) {
function limits({ id: sid, title, lede, columns, rows, source } = {}) {
function limits({ id: sid, title, lede, columns, rows, source } = {}) {
if (!has(rows) || !has(columns)) return '';
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
}
}
/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
@@ -26,6 +26,9 @@ const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'm
@@ -26,6 +26,9 @@ const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'm
assert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));
assert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));
const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });
const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });
assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');
assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');
+// A table wider than a phone scrolls in its wrapper: the wrapper is a focusable region named by the section's heading.
+assert.ok(c.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-compare">') && c.includes('id="sc-compare"'), 'compare table region');
+assert.ok(l.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-limits">'), 'limits table region');
const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });
const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });
// A code sample scrolls sideways, so it is a focus stop with a name (axe scrollable-region-focusable).
// A code sample scrolls sideways, so it is a focus stop with a name (axe scrollable-region-focusable).
assert.ok(code.includes('<pre tabindex="0" aria-label="Node">'), 'code samples are keyboard-focusable and named');
assert.ok(code.includes('<pre tabindex="0" aria-label="Node">'), 'code samples are keyboard-focusable and named');