openvibers/openvibe.chat Commits

Code blocks are reachable from the keyboard (tabindex=0 and a focus ring): /developers failed axe's scrollable-region-focusable, which paged the owner (#173)

OP OpenVibers committed 20 days ago · af09381798 · 6 files, +33 −14

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 143a450..49899a4 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -303,6 +303,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.ib-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }
.ib-snippet { padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); overflow-x: auto; }
.ib-snippet code { font: .85rem/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; }
++.ib-snippet:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; } /* tabindex="0": a code block that scrolls sideways is reachable from the keyboard (axe scrollable-region-focusable) */
.ib-try input { width: 10em; padding: 4px 8px; margin-right: 6px; } /* an action's test values, beside "Try it" */
.oc-members li form.ib-try { margin-left: 0; margin-right: auto; } /* under its description, left; Remove stays right */
.oc-members + .oc-form { margin-top: 14px; }
ddiff --git a/server/web/developer-pages.js b/server/web/developer-pages.js
iindex 1b649b2..2cdada9 100644
---- a/server/web/developer-pages.js
++++ b/server/web/developer-pages.js
@@@ -6,7 +6,7 @@
*/
const { esc } = require('./layout');
--const code = (s) => `<pre class="ib-snippet"><code>${esc(s)}</code></pre>`;
++const code = (s) => `<pre class="ib-snippet" tabindex="0"><code>${esc(s)}</code></pre>`;
function body(base) {
return `<h1>Build with OpenVibe Chat</h1>
ddiff --git a/server/web/feed-pages.js b/server/web/feed-pages.js
iindex cf79da5..3940310 100644
---- a/server/web/feed-pages.js
++++ b/server/web/feed-pages.js
@@@ -72,7 +72,7 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, c
}
function keyPanel(feed, key) {
return `${notice('ok', 'Here is the feed\'s key. Copy it now: it is shown only this once.')}
--<pre class="ib-snippet fd-key"><code>${esc(key)}</code></pre>
++<pre class="ib-snippet fd-key" tabindex="0"><code>${esc(key)}</code></pre>
<p class="oc-muted">Put it where your tools read it, for example <code>export OPENVIBE_FEED_KEY=${esc(key.slice(0, 8))}…</code> in your shell profile. Anyone with the key can post to this feed; make a new one any time.</p>
<p><a class="oc-button" href="/feeds/${esc(feed.id)}">Set up publishing</a></p>`;
}
@@@ -126,14 +126,14 @@ ${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('erro
<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
<h2>Publish from Claude Code</h2>
<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>
--<pre class="ib-snippet"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>
<h2>Publish from anything else</h2>
<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>
--<pre class="ib-snippet"><code>${esc(curlSnippet(base, feed.id))}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(curlSnippet(base, feed.id))}</code></pre>
<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>
<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>
<h2>Show it on your site</h2>
--<pre class="ib-snippet"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>
<h2>Where it shows and what viewers see</h2>
<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">
<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 822ed46..d8d41bd 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -210,13 +210,13 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
${req.query.made ? notice('ok', 'Your inbox is ready. Paste the snippet into your site, before </body>.') : ''}${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
<h2>Add it to your site</h2>
<p>Paste this before <code>&lt;/body&gt;</code> on every page that should show the chat bubble:</p>
--<pre class="ib-snippet"><code>${esc(embed.script)}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(embed.script)}</code></pre>
<p class="oc-muted">Your page can open it with <code>OpenVibeChat.open()</code>. Try it: <a href="${esc(embed.panel)}" target="_blank" rel="noopener">open the chat panel</a>.</p>
${owner ? `<h2>Verified visitors</h2>
${shape.verified_visitors ? `<p>On. Your site signs who its signed-in visitor is, and Chat trusts the name and email that come with it; their conversation follows them to every device. Keep this secret on your server only:</p>
--<pre class="ib-snippet"><code>${esc(inbox.identity_secret)}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(inbox.identity_secret)}</code></pre>
<p>For a signed-in visitor, add <code>data-user-id</code>, <code>data-user-name</code>, <code>data-user-email</code> and <code>data-user-hash</code> to the tag (or call <code>OpenVibeChat.identify({ id, name, email, hash })</code>), where the hash is the HMAC-SHA256 of the id with the secret, in hex:</p>
--<pre class="ib-snippet"><code>${esc(`// Node
++<pre class="ib-snippet" tabindex="0"><code>${esc(`// Node
crypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex')
# Python
hmac.new(SECRET.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
@@@ -326,7 +326,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
// The secret is shown on this answer only (never in a URL: addresses end up in histories and logs).
await page(req, res, 201, { actor: a.actor, title: `${a.inbox.name}: webhook`, path: `/inbox/${a.inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">&larr; ${esc(a.inbox.name)} settings</a></p><h1>Webhook added</h1>
--${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this once.')}<pre class="ib-snippet"><code>${esc(secret)}</code></pre>
++${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this once.')}<pre class="ib-snippet" tabindex="0"><code>${esc(secret)}</code></pre>
<p class="oc-muted">Check each delivery with it: <code>parseDelivery(rawBody, headers, secret, { requireV2: true })</code> from <code>openvibe-sdk/events</code> (X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2).</p>` });
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
});
@@@ -361,9 +361,9 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this o
conversation: { id: 'cv_…', page_url: '…', verified: true, visitor_id: 'your id for them', visitor_email: '…' } } } }, null, 2);
await page(req, res, 201, { actor: a.actor, title: `${a.inbox.name}: action`, path: `/inbox/${a.inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">&larr; ${esc(a.inbox.name)} settings</a></p><h1>Action added</h1>
--${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this once.')}<pre class="ib-snippet"><code>${esc(secret)}</code></pre>
++${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this once.')}<pre class="ib-snippet" tabindex="0"><code>${esc(secret)}</code></pre>
<p>Each time Vibe uses <b>${esc(action.name)}</b>, your address gets a POST like this:</p>
--<pre class="ib-snippet"><code>${esc(sample)}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(sample)}</code></pre>
<p class="oc-muted">Check it with <code>parseDelivery(rawBody, headers, secret, { requireV2: true })</code> from <code>openvibe-sdk/events</code>. <code>visitor_id</code> is set only for a visitor your site verified: look up their own orders with it, never with what they typed alone. Answer within 8 seconds with <code>{ "text": "Order 1042 ships Tuesday." }</code> or plain text (4 KB at most).</p>` });
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
});
@@@ -375,7 +375,7 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this o
// The answer is shown on this page, never put in a URL.
await page(req, res, 200, { actor: a.actor, title: `${a.inbox.name}: action test`, path: `/inbox/${a.inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">&larr; ${esc(a.inbox.name)} settings</a></p><h1>Action test</h1>
--${r.ok ? `${notice('ok', `Your address answered ${r.status}. Vibe would read:`)}<pre class="ib-snippet"><code>${esc(r.result || '(nothing)')}</code></pre>` : notice('error', `It did not work: ${r.error}`)}` });
++${r.ok ? `${notice('ok', `Your address answered ${r.status}. Vibe would read:`)}<pre class="ib-snippet" tabindex="0"><code>${esc(r.result || '(nothing)')}</code></pre>` : notice('error', `It did not work: ${r.error}`)}` });
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
});
router.post('/inbox/:id/actions/:xid/delete', formLimit, sameSite, async (req, res) => {
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 46b6fee..2ecafd9 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -849,9 +849,9 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
${can.manage && await require('./room-embed-pages').embeddable(room) ? `<h2>Put this room on your site</h2>
<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
--<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre>
++<pre class="ib-snippet" tabindex="0"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre>
<p>Or in a part of your page (an element with id="chat"):</p>
--<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre>` : ''}
++<pre class="ib-snippet" tabindex="0"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre>` : ''}
${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
ddiff --git a/test/code-blocks-focusable.test.js b/test/code-blocks-focusable.test.js
nnew file mode 100644
iindex 0000000..c02b680
---- /dev/null
++++ b/test/code-blocks-focusable.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++/**
++ * Every code block Chat's pages render can be reached from the keyboard: a <pre> scrolls sideways when its line is long
++ * (a snippet, a secret), and axe's scrollable-region-focusable (WCAG 2.1, serious) failed /developers on 10-25 until each
++ * one carried tabindex="0". This keeps a new one from coming without it.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const dir = path.join(__dirname, '..', 'server', 'web');
++const missing = [];
++fs.readdirSync(dir).filter((f) => f.endsWith('.js')).forEach((f) => {
++ const src = fs.readFileSync(path.join(dir, f), 'utf8');
++ (src.match(/<pre\b[^>]*>/g) || []).filter((tag) => !/tabindex="0"/.test(tag)).forEach((tag) => missing.push(`${f}: ${tag}`));
++});
++assert.deepStrictEqual(missing, [], `code blocks without tabindex="0":\n${missing.join('\n')}`);
++console.log('code blocks focusable: every <pre> in server/web has tabindex="0"');