diff --git a/examples/keyboard-guard.md b/examples/keyboard-guard.md
new file mode 100644
index 0000000..5823cc6
--- /dev/null
+++ b/examples/keyboard-guard.md
@@ -0,0 +1,91 @@
+# Keyboard shortcuts that respect typing
+
+Character shortcuts can interrupt typing or trigger actions during dictation.
+This example keeps native buttons as the primary controls and lets the person
+turn number shortcuts on or off. Shortcuts start off.
+
+[Open the example](keyboard-guard/index.html) from a local web server:
+
+```sh
+python3 -m http.server 5017 --bind 127.0.0.1
+```
+
+Visit `http://127.0.0.1:5017/examples/keyboard-guard/`. No build or runtime
+package is needed. The example and helper use the repository's MIT license,
+credited to Luke Steuber. The typing and scene-ownership pattern comes from
+Luke's StoryBlocks player; this example adds explicit application context and
+checks for composition, shadow DOM and unavailable controls.
+
+## Integrate the guard
+
+```js
+import { shouldHandleShortcut, canActivateShortcutButton } from './keyboard-guard/guard.mjs';
+
+document.addEventListener('keydown', (event) => {
+ if (
+ !shouldHandleShortcut(event, {
+ enabled: preferences.numberShortcuts,
+ ownsFocus: activeScene && !dialogOpen && !busy,
+ })
+ )
+ return;
+
+ const button = choiceButtons.find((candidate) => candidate.dataset.key === event.key);
+ if (!canActivateShortcutButton(button)) return;
+ event.preventDefault();
+ button.click();
+});
+```
+
+The handler runs during bubbling, so a widget can consume its own key event
+first. It prevents the default only after finding an eligible native button.
+Clicks and shortcuts use the same action path; native Enter and Space continue
+to work when character shortcuts are off.
+
+`shouldHandleShortcut` defaults to false unless both `enabled` and `ownsFocus`
+enable handling. It rejects previously handled, repeated, composing and
+modified events, native editing controls, inherited editable content, common
+ARIA text widgets and elements marked `data-shortcuts-ignore`. It checks the
+composed event path so an open shadow editor remains detectable when the browser
+exposes its host as the event target.
+
+`canActivateShortcutButton` checks a connected native button, disabled fieldset
+inheritance, hidden/inert/ARIA-disabled ancestors and rendered visibility.
+The helper neither changes focus nor installs listeners.
+
+## Application responsibilities
+
+- Keep a clearly labeled off switch or another qualifying mechanism. This
+ example removes `aria-keyshortcuts` when shortcuts are off.
+- Pass the current modal, scene, busy and focus ownership state for each event.
+ The helper cannot infer application state or see inside a closed shadow root.
+ A closed editor should consume the event, mark its host with
+ `data-shortcuts-ignore`, or provide explicit ownership state.
+- Preserve native buttons and their enabled state. Do not create actions that
+ are available only through shortcuts.
+- Keep visible focus and restore focus when closing a dialog.
+- If preference persistence is added, restore it and its exposed state together.
+ This demonstration deliberately resets to off when reloaded.
+
+The off switch addresses the character-shortcut requirement in
+[WCAG 2.2 SC 2.1.4](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html).
+Default-off is a design choice. Filtering modifiers alone does not establish
+conformance; Shift-only printable shortcuts are still character shortcuts.
+
+## Verify
+
+With Playwright resolvable through Node, run:
+
+```sh
+KEYBOARD_EVIDENCE_DIR=/absolute/output/path node examples/keyboard-guard/check.cjs
+```
+
+Optionally set `KEYBOARD_CHROMIUM_PATH` to an existing Chrome/Chromium executable.
+The script checks actual typing and shortcut behavior plus synthetic composition,
+repeat and shadow-DOM events, disabled controls, dialog focus restoration and
+390px layout overflow. It saves screenshots and a JSON evidence file outside
+the source tree.
+
+Screen-reader output, speech input, closed shadow editors, other browsers and
+complete application flows still need their own checks. Passing these browser
+checks is not a claim of overall accessibility conformance.
diff --git a/examples/keyboard-guard/check.cjs b/examples/keyboard-guard/check.cjs
new file mode 100644
index 0000000..9bbd116
--- /dev/null
+++ b/examples/keyboard-guard/check.cjs
@@ -0,0 +1,185 @@
+// Browser regression check. Requires Playwright through NODE_PATH.
+const { chromium } = require('playwright');
+const assert = require('node:assert/strict');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const http = require('node:http');
+(async () => {
+ const out = process.env.KEYBOARD_EVIDENCE_DIR;
+ if (!out) throw Error('Set KEYBOARD_EVIDENCE_DIR');
+ await fs.mkdir(out, { recursive: true });
+ const server = http.createServer(async (req, res) => {
+ const file = req.url === '/' ? 'index.html' : req.url === '/guard.mjs' ? 'guard.mjs' : null;
+ if (!file) {
+ res.writeHead(404).end();
+ return;
+ }
+ res.setHeader('Content-Type', file.endsWith('.mjs') ? 'text/javascript' : 'text/html');
+ res.end(await fs.readFile(path.join(__dirname, file)));
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ let browser;
+ const checks = [];
+ try {
+ browser = await chromium.launch({
+ headless: true,
+ ...(process.env.KEYBOARD_CHROMIUM_PATH
+ ? { executablePath: process.env.KEYBOARD_CHROMIUM_PATH }
+ : {}),
+ });
+ const page = await browser.newPage({ viewport: { width: 1000, height: 1000 } });
+ await page.goto(`http://127.0.0.1:${server.address().port}/`);
+ const status = () => page.locator('#status').innerText();
+ async function unchanged(label, action) {
+ const before = await status();
+ await action();
+ assert.equal(await status(), before, label);
+ checks.push(label);
+ }
+ await unchanged('shortcuts begin off', () => page.keyboard.press('1'));
+ await page.locator('#route').focus();
+ await page.keyboard.press('Enter');
+ assert.match(await status(), /Action 1/);
+ checks.push('native Enter works while off');
+ await page.locator('#enabled').check();
+ assert.equal(await page.locator('#route').getAttribute('aria-keyshortcuts'), '1');
+ await page.locator('#route').focus();
+ await page.keyboard.press('2');
+ assert.match(await status(), /Save route/);
+ checks.push('enabled number shortcut works');
+ await unchanged('input typing', async () => {
+ await page.locator('#notes').fill('');
+ await page.locator('#notes').pressSequentially('123');
+ });
+ assert.equal(await page.locator('#notes').inputValue(), '123');
+ await unchanged('nested editable typing', async () => {
+ await page.locator('#editor').focus();
+ await page.keyboard.press('1');
+ });
+ await page.locator('#route').focus();
+ for (const key of ['Control+1', 'Meta+1', 'Alt+1', 'Shift+1', '3']) {
+ await unchanged(`ignored key ${key}`, () => page.keyboard.press(key));
+ }
+ await unchanged('modal pauses background', async () => {
+ await page.locator('#open-dialog').click();
+ await page.keyboard.press('1');
+ });
+ await page.keyboard.press('Escape');
+ assert.equal(
+ await page.locator('#open-dialog').evaluate((el) => el === document.activeElement),
+ true,
+ );
+ checks.push('dialog restores trigger focus');
+ const boundaries = await page.evaluate(async () => {
+ const { shouldHandleShortcut, canActivateShortcutButton } = await import('/guard.mjs');
+ const ctx = { enabled: true, ownsFocus: true };
+ const checkEvent = (target, options = {}, context = ctx, prehandled = false) => {
+ let result;
+ target.addEventListener(
+ 'keydown',
+ (e) => {
+ if (prehandled) e.preventDefault();
+ result = shouldHandleShortcut(e, context);
+ },
+ { once: true },
+ );
+ target.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: '1',
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ ...options,
+ }),
+ );
+ return result;
+ };
+ const area = document.createElement('div');
+ document.body.append(area);
+ const results = {};
+ results['composition'] = !checkEvent(area, { isComposing: true });
+ results['repeat'] = !checkEvent(area, { repeat: true });
+ results['handled event'] = !checkEvent(area, {}, ctx, true);
+ results['ownership required'] = !checkEvent(area, {}, { enabled: true });
+ results['enablement required'] = !checkEvent(area, {}, { ownsFocus: true });
+ results['editable descendant'] = !checkEvent(document.querySelector('#editor span'));
+ for (const role of ['textbox', 'searchbox', 'combobox', 'spinbutton']) {
+ area.setAttribute('role', role);
+ results[`role ${role}`] = !checkEvent(area);
+ }
+ area.removeAttribute('role');
+ area.setAttribute('data-shortcuts-ignore', '');
+ results['custom editor opt-out'] = !checkEvent(area);
+ area.removeAttribute('data-shortcuts-ignore');
+ const host = document.createElement('div');
+ document.body.append(host);
+ const shadow = host.attachShadow({ mode: 'open' });
+ shadow.innerHTML = '';
+ let allowed;
+ host.addEventListener(
+ 'keydown',
+ (e) => {
+ allowed = shouldHandleShortcut(e, ctx);
+ },
+ { once: true },
+ );
+ shadow
+ .querySelector('input')
+ .dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true, composed: true }));
+ results['shadow retargeted editor'] = !allowed;
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ fieldset.innerHTML = '';
+ area.append(fieldset);
+ results['disabled fieldset'] = !canActivateShortcutButton(fieldset.firstChild);
+ const button = document.createElement('button');
+ button.textContent = 'Test';
+ area.append(button);
+ for (const attr of ['hidden', 'inert', 'aria-hidden', 'aria-disabled']) {
+ area.setAttribute(attr, 'true');
+ results[`ancestor ${attr}`] = !canActivateShortcutButton(button);
+ area.removeAttribute(attr);
+ }
+ button.style.display = 'none';
+ results['CSS hidden button'] = !canActivateShortcutButton(button);
+ button.style.display = '';
+ button.style.visibility = 'hidden';
+ results['CSS invisible button'] = !canActivateShortcutButton(button);
+ area.remove();
+ host.remove();
+ results['detached button'] = !canActivateShortcutButton(button);
+ return results;
+ });
+ for (const [name, passed] of Object.entries(boundaries)) {
+ assert.equal(passed, true, name);
+ checks.push(name);
+ }
+ await page.locator('#enabled').uncheck();
+ assert.equal(await page.locator('#route').getAttribute('aria-keyshortcuts'), null);
+ await unchanged('off switch takes effect', () => page.keyboard.press('1'));
+ await page.locator('#route').focus();
+ await page.screenshot({ path: path.join(out, 'desktop.png'), fullPage: true });
+ await page.setViewportSize({ width: 390, height: 844 });
+ assert.equal(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ true,
+ );
+ checks.push('390px layout has no horizontal overflow');
+ await page.screenshot({ path: path.join(out, 'mobile.png'), fullPage: true });
+ const evidence = {
+ browser: browser.version(),
+ passed: checks.length,
+ checks,
+ screenReader: 'not tested',
+ speechInput: 'not tested',
+ };
+ await fs.writeFile(path.join(out, 'evidence.json'), JSON.stringify(evidence, null, 2));
+ console.log(JSON.stringify(evidence, null, 2));
+ } finally {
+ await browser?.close();
+ await new Promise((resolve) => server.close(resolve));
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/examples/keyboard-guard/guard.mjs b/examples/keyboard-guard/guard.mjs
new file mode 100644
index 0000000..112f2ff
--- /dev/null
+++ b/examples/keyboard-guard/guard.mjs
@@ -0,0 +1,54 @@
+// Copyright (c) 2026 Luke Steuber. MIT; see ../../LICENSE.
+// Adapted from the typing and scene-ownership guards in StoryBlocks.
+const editingSelector =
+ 'input, textarea, select, [role="textbox"], [role="searchbox"], ' +
+ '[role="combobox"], [role="spinbutton"], [data-shortcuts-ignore]';
+
+function ancestry(element) {
+ const result = [];
+ for (let node = element; node;) {
+ result.push(node);
+ node = node.assignedSlot || node.parentElement || node.getRootNode?.().host;
+ }
+ return result;
+}
+
+/** Check the current event and explicit application context without consuming it. */
+export function shouldHandleShortcut(event, { enabled = false, ownsFocus = false } = {}) {
+ if (!enabled || !ownsFocus || event.defaultPrevented || event.repeat || event.isComposing) {
+ return false;
+ }
+ if (
+ event.ctrlKey ||
+ event.metaKey ||
+ event.altKey ||
+ event.shiftKey ||
+ event.getModifierState?.('AltGraph') ||
+ event.keyCode === 229 ||
+ ['Dead', 'Process', 'Unidentified'].includes(event.key)
+ )
+ return false;
+ const path = event.composedPath?.() || [];
+ const nodes = path.length ? path : ancestry(event.target);
+ return !nodes.some((node) => node.isContentEditable || node.matches?.(editingSelector));
+}
+
+/** Only the native, visible action button may receive a shortcut click. */
+export function canActivateShortcutButton(button) {
+ if (!button?.matches?.('button') || !button.isConnected || button.matches(':disabled')) {
+ return false;
+ }
+ if (
+ ancestry(button).some((node) =>
+ node.matches?.('[hidden], [inert], [aria-hidden="true"], [aria-disabled="true"]'),
+ )
+ ) {
+ return false;
+ }
+ const style = button.ownerDocument.defaultView.getComputedStyle(button);
+ return (
+ button.getClientRects().length > 0 &&
+ style.visibility !== 'hidden' &&
+ style.visibility !== 'collapse'
+ );
+}
diff --git a/examples/keyboard-guard/index.html b/examples/keyboard-guard/index.html
new file mode 100644
index 0000000..b2758d6
--- /dev/null
+++ b/examples/keyboard-guard/index.html
@@ -0,0 +1,185 @@
+
+
+
+
+
+ Keyboard shortcuts that respect typing
+
+
+
+
+ Accessibility Devkit ยท Browser example
+ Shortcuts without interrupting typing
+
+ Turn on number shortcuts, then try typing in the fields below. The buttons always work, even
+ when shortcuts are off.
+
+
+
+ Choose an action
+ When enabled: 1 selects a route; 2 saves it. Shortcut 3 is unavailable.
+
+
+
+
+
+ No action yet.
+
+
+
+ Give a dialog control of the keyboard
+ Background shortcuts pause while the dialog is open.
+
+
+ Integration guide
+
+
+
+
+