openvibers/openvibe.shared Commits
4.95.0: a box that scrolls can be scrolled from a keyboard (#254)
Changes
@@@ -4,6 +4,16 @@ 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.95.0 — 2026-11-02++++- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a++ height, with nothing focusable inside it, could not be reached with Tab, so its hidden part was out of reach without++ a mouse. axe (scrollable-region-focusable) flagged such pages on six sites in the 11-02 sweep. Once the page settles,++ and again a little later and after a resize, the navbar gives each such box `tabindex="0"` (and++ `data-ov-scroll-focus`, with a focus ring). A box with a link or a button inside, a page's own tabindex, and++ `overflow: hidden` are left alone. `OpenVibeNavbar.focusScrollers()` runs it on demand for a page that draws one++ later. `test/keyboard-scrollers.test.js` checks it in a real Chrome.++## 4.94.0 — 2026-11-02- **A labelled thing's poster may be named by username** (`data-ov-labels-owner="@name"`). The owner rule (their own@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.94.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.95.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.94.0",++ "version": "4.95.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -54,6 +54,7 @@:where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; }.openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }.openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ [data-ov-scroll-focus]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }.openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 30px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; }.openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }@@@ -543,6 +544,43 @@});labelWatch.observe(document.body, { childList: true, subtree: true });}++ // A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be++ // scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each++ // such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is++ // reachable already and is left alone; a page's own tabindex always wins. Run when the page settles, again a little++ // later for content that loads after it, and after a resize changes what overflows.++ const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';++ function focusScrollers() {++ if (!document.body || !document.body.getElementsByTagName) return 0;++ let n = 0;++ const all = document.body.getElementsByTagName('*');++ for (let i = 0; i < all.length; i++) {++ const el = all[i];++ // Cheap first: only a box whose content is bigger than it is, and that has no tabindex of its own.++ if (el.scrollWidth <= el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) continue;++ if (el.hasAttribute('tabindex') || el.closest('#openvibe-navbar, .openvibe-navbar, .ovf, footer')) continue;++ const cs = getComputedStyle(el);++ const sx = /(auto|scroll)/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 1;++ const sy = /(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1;++ if (!sx && !sy) continue;++ if (el.querySelector(SCROLL_FOCUSABLE)) continue;++ el.setAttribute('tabindex', '0');++ el.setAttribute('data-ov-scroll-focus', '');++ n++;++ }++ return n;++ }++ let scrollWatch = false;++ function watchScrollers() {++ if (scrollWatch || typeof window === 'undefined' || !document.body) return;++ scrollWatch = true;++ const idle = (fn, t) => (typeof root.requestIdleCallback === 'function' ? root.requestIdleCallback(fn, { timeout: t }) : setTimeout(fn, Math.min(t, 1500)));++ const run = () => { try { if (typeof getComputedStyle === 'function') focusScrollers(); } catch { /* never a page's problem */ } };++ idle(run, 3000);++ setTimeout(() => idle(run, 2000), 4000);++ let soon = 0;++ if (typeof window.addEventListener === 'function') window.addEventListener('resize', () => { clearTimeout(soon); soon = setTimeout(run, 400); }, { passive: true });++ }function sibling(name) {const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;@@@ -1716,7 +1754,7 @@}// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).-- if (_config.glyphs !== false) watchGlyphs(); watchLabels();++ if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers();nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">@@@ -2209,6 +2247,10 @@/** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },++ /** Give every box that scrolls with nothing focusable inside it tabindex="0" now (the bar does it when the page++ * settles; a page that draws such a box later may call this). Returns how many it marked. */++ focusScrollers() { try { return focusScrollers(); } catch { return 0; } },++/** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.94.0",++ "version": "4.95.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++// Every box that scrolls with nothing focusable inside it can be scrolled from a keyboard (WCAG 2.1.1; axe++// scrollable-region-focusable, flagged on six sites' pages on 2026-11-02). The navbar gives each such box tabindex="0"++// once the page settles, in a real headless Chrome (layout decides what overflows). Skipped (exit 0) without Chrome or++// with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('keyboard-scrollers: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const long = 'const x = 1; '.repeat(80);++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Scrollers</title><link rel="icon" href="data:,">++<script src="/shared/navbar.js" defer></script>++<style>body{margin:0;font:16px system-ui} .box{width:300px;overflow:auto} .tall{height:60px;overflow-y:auto} .clip{width:300px;overflow:hidden}</style></head>++<body><div id="navbar-mount"></div><main>++<pre id="wide" class="box">${long}</pre>++<pre id="short" class="box">ok</pre>++<div id="with-link" class="box"><p style="width:900px">A wide row <a href="/x">with a link</a></p></div>++<dl id="tall" class="tall"><dt>a</dt><dd>1</dd><dt>b</dt><dd>2</dd><dt>c</dt><dd>3</dd><dt>d</dd><dd>4</dd></dl>++<pre id="own" class="box" tabindex="-1">${long}</pre>++<div id="clipped" class="clip"><p style="width:900px">Clipped, not scrollable</p></div>++</main>++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }] }); });</script>++</body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }++ res.writeHead(404); return res.end();++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-scrollers-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 1024, block: ['*openvibe.network*', '*openvibe.tools*', '*jsdelivr*'] });++ await p.goto(`${base}/`, { maxMs: 5000 });++ // The bar runs it when the page settles (idle, at most 3 s); wait for the first mark, or 6 s.++ await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (document.querySelector('[data-ov-scroll-focus]') || Date.now() - t0 > 6000) { clearInterval(t); r(); } }, 100); })`);++ const state = await p.evaluate(`JSON.stringify(Object.fromEntries(['wide','short','with-link','tall','own','clipped'].map((id) => { const e = document.getElementById(id); return [id, e.getAttribute('tabindex')]; })))`);++ const s = JSON.parse(state);++ assert.strictEqual(s.wide, '0', 'a wide code block a keyboard could not reach gets tabindex 0');++ assert.strictEqual(s.tall, '0', 'so does a list held to a height');++ assert.strictEqual(s.short, null, 'a box that does not overflow is left alone');++ assert.strictEqual(s['with-link'], null, 'a box with a link inside is reachable already');++ assert.strictEqual(s.own, '-1', 'a page\'s own tabindex wins');++ assert.strictEqual(s.clipped, null, 'overflow hidden is not a scroller');++ assert.strictEqual(await p.evaluate('typeof OpenVibeNavbar.focusScrollers'), 'function');++ assert.strictEqual(await p.evaluate('OpenVibeNavbar.focusScrollers()'), 0, 'running it again changes nothing');++ console.log('keyboard-scrollers: all passed');++ } finally {++ await browser.close();++ server.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,16 @@ 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.95.0 — 2026-11-02
+
+- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a
+ height, with nothing focusable inside it, could not be reached with Tab, so its hidden part was out of reach without
+ a mouse. axe (scrollable-region-focusable) flagged such pages on six sites in the 11-02 sweep. Once the page settles,
+ and again a little later and after a resize, the navbar gives each such box `tabindex="0"` (and
+ `data-ov-scroll-focus`, with a focus ring). A box with a link or a button inside, a page's own tabindex, and
+ `overflow: hidden` are left alone. `OpenVibeNavbar.focusScrollers()` runs it on demand for a page that draws one
+ later. `test/keyboard-scrollers.test.js` checks it in a real Chrome.
+
## 4.94.0 — 2026-11-02
## 4.94.0 — 2026-11-02
- **A labelled thing's poster may be named by username** (`data-ov-labels-owner="@name"`). The owner rule (their own
- **A labelled thing's poster may be named by username** (`data-ov-labels-owner="@name"`). The owner rule (their own
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.94.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.95.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.94.0",
+ "version": "4.95.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -54,6 +54,7 @@
@@ -54,6 +54,7 @@
:where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; }
:where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; }
.openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }
.openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }
.openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ [data-ov-scroll-focus]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
.openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 30px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; }
.openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 30px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; }
.openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
.openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
@@ -543,6 +544,43 @@
@@ -543,6 +544,43 @@
});
});
labelWatch.observe(document.body, { childList: true, subtree: true });
labelWatch.observe(document.body, { childList: true, subtree: true });
}
}
+ // A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be
+ // scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each
+ // such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is
+ // reachable already and is left alone; a page's own tabindex always wins. Run when the page settles, again a little
+ // later for content that loads after it, and after a resize changes what overflows.
+ const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';
+ function focusScrollers() {
+ if (!document.body || !document.body.getElementsByTagName) return 0;
+ let n = 0;
+ const all = document.body.getElementsByTagName('*');
+ for (let i = 0; i < all.length; i++) {
+ const el = all[i];
+ // Cheap first: only a box whose content is bigger than it is, and that has no tabindex of its own.
+ if (el.scrollWidth <= el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) continue;
+ if (el.hasAttribute('tabindex') || el.closest('#openvibe-navbar, .openvibe-navbar, .ovf, footer')) continue;
+ const cs = getComputedStyle(el);
+ const sx = /(auto|scroll)/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 1;
+ const sy = /(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1;
+ if (!sx && !sy) continue;
+ if (el.querySelector(SCROLL_FOCUSABLE)) continue;
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('data-ov-scroll-focus', '');
+ n++;
+ }
+ return n;
+ }
+ let scrollWatch = false;
+ function watchScrollers() {
+ if (scrollWatch || typeof window === 'undefined' || !document.body) return;
+ scrollWatch = true;
+ const idle = (fn, t) => (typeof root.requestIdleCallback === 'function' ? root.requestIdleCallback(fn, { timeout: t }) : setTimeout(fn, Math.min(t, 1500)));
+ const run = () => { try { if (typeof getComputedStyle === 'function') focusScrollers(); } catch { /* never a page's problem */ } };
+ idle(run, 3000);
+ setTimeout(() => idle(run, 2000), 4000);
+ let soon = 0;
+ if (typeof window.addEventListener === 'function') window.addEventListener('resize', () => { clearTimeout(soon); soon = setTimeout(run, 400); }, { passive: true });
+ }
function sibling(name) {
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@ -1716,7 +1754,7 @@
@@ -1716,7 +1754,7 @@
}
}
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
- if (_config.glyphs !== false) watchGlyphs(); watchLabels();
+ if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers();
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
@@ -2209,6 +2247,10 @@
@@ -2209,6 +2247,10 @@
/** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */
/** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */
get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },
get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },
+ /** Give every box that scrolls with nothing focusable inside it tabindex="0" now (the bar does it when the page
+ * settles; a page that draws such a box later may call this). Returns how many it marked. */
+ focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
+
/** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */
/** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */
openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.94.0",
+ "version": "4.95.0",
"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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+// Every box that scrolls with nothing focusable inside it can be scrolled from a keyboard (WCAG 2.1.1; axe
+// scrollable-region-focusable, flagged on six sites' pages on 2026-11-02). The navbar gives each such box tabindex="0"
+// once the page settles, in a real headless Chrome (layout decides what overflows). Skipped (exit 0) without Chrome or
+// with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('keyboard-scrollers: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const long = 'const x = 1; '.repeat(80);
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Scrollers</title><link rel="icon" href="data:,">
+<script src="/shared/navbar.js" defer></script>
+<style>body{margin:0;font:16px system-ui} .box{width:300px;overflow:auto} .tall{height:60px;overflow-y:auto} .clip{width:300px;overflow:hidden}</style></head>
+<body><div id="navbar-mount"></div><main>
+<pre id="wide" class="box">${long}</pre>
+<pre id="short" class="box">ok</pre>
+<div id="with-link" class="box"><p style="width:900px">A wide row <a href="/x">with a link</a></p></div>
+<dl id="tall" class="tall"><dt>a</dt><dd>1</dd><dt>b</dt><dd>2</dd><dt>c</dt><dd>3</dd><dt>d</dd><dd>4</dd></dl>
+<pre id="own" class="box" tabindex="-1">${long}</pre>
+<div id="clipped" class="clip"><p style="width:900px">Clipped, not scrollable</p></div>
+</main>
+<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }] }); });</script>
+</body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
+ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
+ res.writeHead(404); return res.end();
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-scrollers-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 1024, block: ['*openvibe.network*', '*openvibe.tools*', '*jsdelivr*'] });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ // The bar runs it when the page settles (idle, at most 3 s); wait for the first mark, or 6 s.
+ await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (document.querySelector('[data-ov-scroll-focus]') || Date.now() - t0 > 6000) { clearInterval(t); r(); } }, 100); })`);
+ const state = await p.evaluate(`JSON.stringify(Object.fromEntries(['wide','short','with-link','tall','own','clipped'].map((id) => { const e = document.getElementById(id); return [id, e.getAttribute('tabindex')]; })))`);
+ const s = JSON.parse(state);
+ assert.strictEqual(s.wide, '0', 'a wide code block a keyboard could not reach gets tabindex 0');
+ assert.strictEqual(s.tall, '0', 'so does a list held to a height');
+ assert.strictEqual(s.short, null, 'a box that does not overflow is left alone');
+ assert.strictEqual(s['with-link'], null, 'a box with a link inside is reachable already');
+ assert.strictEqual(s.own, '-1', 'a page\'s own tabindex wins');
+ assert.strictEqual(s.clipped, null, 'overflow hidden is not a scroller');
+ assert.strictEqual(await p.evaluate('typeof OpenVibeNavbar.focusScrollers'), 'function');
+ assert.strictEqual(await p.evaluate('OpenVibeNavbar.focusScrollers()'), 0, 'running it again changes nothing');
+ console.log('keyboard-scrollers: all passed');
+ } finally {
+ await browser.close();
+ server.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });