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)
Changes
@@@ -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; }@@@ -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>@@@ -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>@@@ -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></body></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(`// Nodecrypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex')# Pythonhmac.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">← ${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 oconversation: { 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">← ${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">← ${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) => {@@@ -849,9 +849,9 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="postbody: `<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>nnew file mode 100644@@@ -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"');
@@ -303,6 +303,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -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-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 { 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 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" */
.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 li form.ib-try { margin-left: 0; margin-right: auto; } /* under its description, left; Remove stays right */
.oc-members + .oc-form { margin-top: 14px; }
.oc-members + .oc-form { margin-top: 14px; }
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
*/
*/
const { esc } = require('./layout');
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) {
function body(base) {
return `<h1>Build with OpenVibe Chat</h1>
return `<h1>Build with OpenVibe Chat</h1>
@@ -72,7 +72,7 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, c
@@ -72,7 +72,7 @@ function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, c
}
}
function keyPanel(feed, key) {
function keyPanel(feed, key) {
return `${notice('ok', 'Here is the feed\'s key. Copy it now: it is shown only this once.')}
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 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>`;
<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
@@ -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>
<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
<h2>Publish from Claude Code</h2>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<h2>Where it shows and what viewers see</h2>
<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">
<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>
<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
@@ -210,13 +210,13 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
@@ -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)) : ''}
${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>
<h2>Add it to your site</h2>
<p>Paste this before <code></body></code> on every page that should show the chat bubble:</p>
<p>Paste this before <code></body></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>
<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>
${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>
${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>
<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')
crypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex')
# Python
# Python
hmac.new(SECRET.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
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
@@ -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).
// 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',
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">← ${esc(a.inbox.name)} settings</a></p><h1>Webhook added</h1>
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">← ${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>` });
<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')}`); }
} 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
@@ -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);
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',
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">← ${esc(a.inbox.name)} settings</a></p><h1>Action added</h1>
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">← ${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>
<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>` });
<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')}`); }
} 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
@@ -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.
// 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',
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">← ${esc(a.inbox.name)} settings</a></p><h1>Action test</h1>
body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">← ${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')}`); }
} 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) => {
router.post('/inbox/:id/actions/:xid/delete', formLimit, sameSite, async (req, res) => {
@@ -849,9 +849,9 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
@@ -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}
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>
${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>
<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>
<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">
${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>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>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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"');