openvibers/openvibe.services Commits

Services: code blocks are keyboard-focusable (axe scrollable-region-focusable) (#72)

OP OpenVibers committed 29 days ago · 2b18a9eed2 · 6 files, +19 −17

Code blocks that scroll sideways on a phone could not be reached by keyboard. An axe scan (WCAG 2.1 AA) of inner pages at 390 px flagged the code on `/docs` (`scrollable-region-focusable`, serious).

Every `<pre>` in the docs, the Markdown renderer, the tools, the portal and the pages now has `tabindex="0"`. `test/docs.test.js` checks `/docs`.

Changes

ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 20c6639..1a1f939 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -65,7 +65,7 @@ function createDocsRoutes(ctx) {
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
</ul>
<h2>Install</h2>
--<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}
++<pre tabindex="0"><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}
npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/${docs.contractsTag}</code></pre>
<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
});
@@@ -103,7 +103,7 @@ ${table(['Contract', 'Version', 'Owner', 'Visibility', 'Status', 'Compatibility'
const c = docs.contract(req.params.id);
if (!c) return next();
const major = c.version.split('.')[0];
-- const fixture = (f, ok) => html`<details${ok ? raw(' open') : ''}><summary>${ok ? 'Valid' : 'Rejected'}: ${f.name}</summary><pre><code>${JSON.stringify(f.body, null, 2)}</code></pre></details>`;
++ const fixture = (f, ok) => html`<details${ok ? raw(' open') : ''}><summary>${ok ? 'Valid' : 'Rejected'}: ${f.name}</summary><pre tabindex="0"><code>${JSON.stringify(f.body, null, 2)}</code></pre></details>`;
page(req, res, {
title: `${c.id}@${major}`,
description: c.description.slice(0, 200),
@@@ -127,7 +127,7 @@ ${table(['Field', 'Type', 'Required', 'Description', 'Constraints'], c.fields.ma
${c.fixtures.valid.map((f) => fixture(f, true))}${c.fixtures.invalid.map((f) => fixture(f, false))}
${!c.fixtures.valid.length && !c.fixtures.invalid.length ? html`<p class="muted">No fixtures ship with this contract.</p>` : ''}
<h2>Validate</h2>
--<pre><code>const contracts = require('openvibe-contracts');
++<pre tabindex="0"><code>const contracts = require('openvibe-contracts');
contracts.validate('${c.id}@${major}', value); // { valid, errors: [{ path, message }] }</code></pre>`,
});
});
@@@ -276,25 +276,25 @@ ${(doc['x-openvibe-other-bindings'] || []).length ? html`<h2>Other bindings</h2>
<h2>Where entries come from</h2>
<p>OpenVibe.Blog watches every running service's release: the <code>release</code> in its <code>/release.json</code>, as OpenVibe.Network's registry reports it. When a release changes, GitHub's compare of the old and new commit gives the commits that shipped. Each one becomes an entry, with the commit message as its text, and batches of them are published as "Patch notes" posts. A service appears once it serves <code>/release.json</code> and its registry entry names its repository.</p>
<h2>The feed</h2>
--<pre><code>GET https://openvibe.network/api/v1/changelog?service=&lt;id&gt;&amp;limit=&lt;1-100&gt;&amp;before=&lt;cursor&gt;
++<pre tabindex="0"><code>GET https://openvibe.network/api/v1/changelog?service=&lt;id&gt;&amp;limit=&lt;1-100&gt;&amp;before=&lt;cursor&gt;
→ { service, entries: [{ service, sha, short, subject, author, committed_at, deployed_at, major, url, post_id }],
latest_post: { id, title, url, published_at, entries } | null, posts, next }</code></pre>
<p>It is public, CORS <code>*</code> and cached for 60 s (stale while the blog is down). Leave <code>service</code> out for the whole network.</p>
${sample ? html`<details open><summary>Live sample: the last ${String((sample.entries || []).length)} entries for this site</summary><ul>${(sample.entries || []).map((e) => html`<li><a href="${e.url}"><code>${e.short}</code></a> ${e.subject} ${e.deployed_at ? html`<span class="muted small">(${time(e.deployed_at)})</span>` : ''}</li>`)}</ul></details>`
: html`<p class="muted">The feed could not be read just now (${failed}); the shape above is what it answers.</p>`}
<h2>On a server-rendered site</h2>
--<pre><code>const frame = require('openvibe-shared/frame');
++<pre tabindex="0"><code>const frame = require('openvibe-shared/frame');
frame.shipped({ service: 'myservice', title: 'Recently shipped on MySite' }) // home: pill + recent list
frame.updatesBody({ service: 'myservice', siteName: 'MySite' }) // the body of your /updates page
frame.shippedScript() // the &lt;script&gt; that fills them</code></pre>
<p>The footer from <code>openvibe-shared/footer</code> already carries the "shipped X ago" line and an Updates link.</p>
<h2>The markup, if you render it yourself</h2>
--<pre><code>&lt;a data-ov-shipped="latest" data-service="myservice" href="/updates" hidden&gt;&lt;/a&gt;
++<pre tabindex="0"><code>&lt;a data-ov-shipped="latest" data-service="myservice" href="/updates" hidden&gt;&lt;/a&gt;
&lt;div data-ov-shipped="list" data-service="myservice" data-limit="5" data-more="/updates" data-title="Recently shipped" hidden&gt;&lt;/div&gt;
&lt;div data-ov-shipped="log" data-service="myservice" data-limit="50"&gt;&lt;/div&gt;</code></pre>
<p><code>shipped.js</code> (served by every site from its pinned openvibe-shared) mounts every <code>[data-ov-shipped]</code> element: <code>latest</code> is the pill, <code>list</code> the recent changes, and <code>log</code> the full log with days, "Load more" and the Patch notes posts. Elements stay hidden until there is something to show.</p>
<h2>In a browser app outside the network</h2>
--<pre><code>import { mountFrame } from 'openvibe-sdk/frame';
++<pre tabindex="0"><code>import { mountFrame } from 'openvibe-sdk/frame';
await mountFrame({ service: 'myapp' }); // navbar and footer, with the shipped line
await mountFrame({ service: 'myapp', shipped: false }); // without it</code></pre>
<p>Live's <a href="https://openvibe.live/updates">/updates</a>, <a href="/updates">this site's</a> and <a href="https://openvibe.network/updates">the whole network's</a> are the same component.</p>`,
@@@ -516,7 +516,7 @@ ${table(['Module', 'Where', 'Declarations', 'Types file'], docs.sdk.map((m) => [
body: html`<h1><code>${m.name}</code></h1>${versions()}
<p class="muted">From <code>${m.typesFile}</code> (${m.browser}). Declarations are shown verbatim.</p>
<nav class="toc"><ul class="plain">${m.declarations.map((d, i) => html`<li><a href="#d${i}">${d.kind} ${d.name}</a></li>`)}</ul></nav>
--${m.declarations.map((d, i) => html`<section id="d${i}" class="decl"><h2><small>${d.kind}</small> ${d.name}</h2>${d.doc ? html`<p>${d.doc}</p>` : ''}<pre><code>${d.text}</code></pre></section>`)}`,
++${m.declarations.map((d, i) => html`<section id="d${i}" class="decl"><h2><small>${d.kind}</small> ${d.name}</h2>${d.doc ? html`<p>${d.doc}</p>` : ''}<pre tabindex="0"><code>${d.text}</code></pre></section>`)}`,
});
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d15cacd..73fd65d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -81,7 +81,7 @@ const services = await ov.registry.services();`;
lede: 'One console for everything programmable on OpenVibe: projects, scoped credentials, capability grants, usage and releases, over every service. The reference is generated from the exact contract and SDK versions production runs.',
actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Read the docs', href: '/docs' }],
note: 'Open source. Network owns projects, apps, credentials, grants and quotas; this console shows its API with your own sign-in and never stores a secret.',
-- aside: { html: `<pre class="services-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
++ aside: { html: `<pre tabindex="0" class="services-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
}))}
<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>.</div>
${raw(showcase.features({
@@@ -262,7 +262,7 @@ ${req.query.done ? notice(`Release ${req.query.done}.`, 'ok') : ''}
${rel.deprecated_at ? html`<dt>Deprecated</dt><dd>${time(rel.deprecated_at)}: ${rel.deprecation_reason}${rel.replacement ? html` — use <a href="/releases/${rel.replacement}">${rel.replacement}</a>` : ''}</dd>` : ''}
${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revocation_reason}</dd>` : ''}</dl>
${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}
--<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
++<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre tabindex="0"><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
${signedIn ? releaseActions(req, rel) : ''}
<h2>History</h2>${table(['When', 'Action', 'By'], (await releases.log(rel.id)).map((l) => [time(l.at), l.action, l.actor]))}`,
});
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 24e0f06..7ced038 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -677,7 +677,7 @@ ${admin ? html`<h2>Set a secret</h2>
<div class="notice warn"><strong>You won't see this secret again.</strong> Copy it into your server's secret store now. Services does not keep it and OpenVibe.Network stores only a hash (credential <code>${credential.id}</code>, ending <code>…${credential.hint}</code>). If you lose it, rotate the credential. Never put it in a browser or a repository.</div>
${rotated && previous.length ? html`<p>The previous secret${previous.length > 1 ? 's stay' : ' stays'} valid until ${previous.map((p) => html`${time(p.expires_at)} `)} so you can deploy the new one first.</p>` : ''}
<h2>Use it</h2>
--<pre><code>const sdk = require('openvibe-sdk');
++<pre tabindex="0"><code>const sdk = require('openvibe-sdk');
const client = sdk.createClient({
tokenProvider: sdk.auth.createServiceTokenClient({ clientId: '${app.id}', clientSecret: process.env.OV_CLIENT_SECRET }),
});</code></pre>
@@@ -909,7 +909,7 @@ ${table(['When', 'Playground', 'Credential', 'Outcome', 'Stage', 'HTTP', 'Code',
function runResult(r) {
return html`<div class="result ${r.outcome === 'ok' ? 'ok' : 'bad'}" role="status"><p><strong>${r.outcome === 'ok' ? 'Done.' : (r.outcome === 'refused' ? 'Refused.' : 'Failed.')}</strong> ${r.detail}</p>
<p class="muted small">stage ${r.stage}${r.http_status ? ` · HTTP ${r.http_status}` : ''}${r.code ? html` · <code>${r.code}</code>` : ''}${r.runId ? html` · run <code>${r.runId}</code>` : ''}</p>
--${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
++${r.result ? html`<pre tabindex="0"><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
}
const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
ddiff --git a/server/http/tools.js b/server/http/tools.js
iindex 50b50cd..3ee4496 100644
---- a/server/http/tools.js
++++ b/server/http/tools.js
@@@ -79,7 +79,7 @@ ${table(['Parameter', 'Value'], Object.entries(got).filter(([, v]) => v !== null
${got.error ? notice(html`Network returned <code>${got.error}</code>${got.error_description ? html`: ${got.error_description}` : ''}. <code>access_denied</code> means the person declined; <code>interaction_required</code> means <code>prompt=none</code> was sent (apps must not).`, 'warn') : ''}
${got.code ? html`<h2>Exchange it (on your server)</h2>
<p>Send <code>client_secret</code> only for a confidential app, and only from a server — never from a browser.</p>
--<pre><code>curl -X POST ${config.network.url}/oauth/token \\
++<pre tabindex="0"><code>curl -X POST ${config.network.url}/oauth/token \\
-d grant_type=authorization_code \\
-d client_id="$CLIENT_ID" \\
-d client_secret="$CLIENT_SECRET" \\
@@@ -117,8 +117,8 @@ ${got.code ? html`<h2>Exchange it (on your server)</h2>
<button type="submit">Generate</button></form>
${sample ? html`<div class="result"><p>A delivery exactly as Events would send it (event types from <code>openvibe-contracts v${docs.contractsVersion}</code>). The envelope ${sample.envelopeValid ? 'validates' : 'does NOT validate'} as events.event-envelope@1. The payload is empty: contracts define no payload schema for this event type yet, and Services does not invent one.</p>
${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [code(k), code(v)]))}
--<p>Body (byte for byte):</p><pre><code>${sample.body}</code></pre>
--<p>Replay it against your own endpoint (Services never sends requests to your URLs). The v2 timestamp is the time you pressed Generate, so a receiver refuses the replay once it is more than ${webhooks.V2_WINDOW_SEC} seconds old: generate a fresh one, or use it to check that your endpoint does refuse a stale delivery.</p><pre><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
++<p>Body (byte for byte):</p><pre tabindex="0"><code>${sample.body}</code></pre>
++<p>Replay it against your own endpoint (Services never sends requests to your URLs). The v2 timestamp is the time you pressed Generate, so a receiver refuses the replay once it is more than ${webhooks.V2_WINDOW_SEC} seconds old: generate a fresh one, or use it to check that your endpoint does refuse a stale delivery.</p><pre tabindex="0"><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
});
function verifyResult(v) {
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
iindex 076977b..0b58a74 100644
---- a/server/render/markdown.js
++++ b/server/render/markdown.js
@@@ -30,7 +30,7 @@ function markdown(src, { headingOffset = 1 } = {}) {
const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
for (const line of lines) {
if (fence) {
-- if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
++ if (/^```/.test(line)) { out.push(`<pre tabindex="0"><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
continue;
}
if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
@@@ -54,7 +54,7 @@ function markdown(src, { headingOffset = 1 } = {}) {
flushList();
para.push(line.trim());
}
-- if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
++ if (fence) out.push(`<pre tabindex="0"><code>${esc(fence.join('\n'))}</code></pre>`);
flushPara(); flushList();
return out.join('\n');
}
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 19d7457..7187eb1 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -120,6 +120,8 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(list.status, 200);
for (const s of index) assert.ok(list.text.includes(`href="/docs/api/${s.service}"`), `${s.service} is listed`);
assert.ok((await t.get('/docs')).text.includes('href="/docs/api"'), 'the docs index links it');
++ // Code blocks scroll sideways on a phone, so each is a keyboard stop (axe scrollable-region-focusable).
++ assert.ok(!/<pre(?![^>]*tabindex)[\s>]/.test((await t.get('/docs')).text), 'every <pre> on /docs is focusable');
const raw = await t.get('/docs/api/tools.json');
assert.strictEqual(raw.status, 200);
assert.match(raw.headers.get('content-type'), /application\/vnd\.oai\.openapi\+json/);