diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index aa8bccc..eefd404 100644
--- a/playground/voyage-playground.html
+++ b/playground/voyage-playground.html
@@ -2644,6 +2644,67 @@ playground first-run shows a complete round-trip-able artifact.
// handler, popstate restoration, and ?project= URL deep-link hint.
wireDashboardNavigation();
maybeShowProjectURLHint();
+
+ // Step 20 (v4.3) — J/K annotation nav + Esc clear + ] toggle sidebar
+ wireKeyboardNav();
+ }
+
+ // v4.3 Step 20 — document-level keyboard navigation.
+ // J = next annotation, K = prev, ] = toggle sidebar, Escape = clear
+ // active anchor. Aria-live region announces position + target.
+ // Skip when user is typing in an input/textarea/contenteditable so the
+ // playground never steals keystrokes from form fields.
+ function wireKeyboardNav() {
+ document.addEventListener('keydown', function (e) {
+ var t = e.target;
+ if (t && t.matches && t.matches('input, textarea, select, [contenteditable], [contenteditable="true"]')) return;
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
+
+ if (e.key === ']') {
+ e.preventDefault();
+ toggleSidebar();
+ return;
+ }
+ if (e.key === 'Escape') {
+ var actives = document.querySelectorAll('.voyage-anchor-active');
+ if (actives.length === 0) return;
+ for (var i = 0; i < actives.length; i++) actives[i].classList.remove('voyage-anchor-active');
+ var listActives = document.querySelectorAll('#voyage-jumplist li[data-active="true"]');
+ for (var j = 0; j < listActives.length; j++) listActives[j].removeAttribute('data-active');
+ announce('Annotering avbrutt.');
+ return;
+ }
+ if (e.key === 'j' || e.key === 'k') {
+ e.preventDefault();
+ var direction = e.key === 'j' ? 1 : -1;
+ var ta = $('voyage-paste-input');
+ var fm = quickParseFrontmatter(ta ? ta.value : '');
+ var key = deriveStorageKey(fm);
+ var drafts = loadDrafts(key);
+ if (!Array.isArray(drafts) || drafts.length === 0) return;
+ var sorted = drafts.slice().sort(function (a, b) {
+ return (Number(a.line) || 0) - (Number(b.line) || 0);
+ });
+ var allBadges = document.querySelectorAll('.voyage-anchor-badge[data-anchor-id]');
+ var currentId = null;
+ for (var k = 0; k < allBadges.length; k++) {
+ if (allBadges[k].parentElement && allBadges[k].parentElement.classList.contains('voyage-anchor-active')) {
+ currentId = allBadges[k].getAttribute('data-anchor-id');
+ break;
+ }
+ }
+ var curIdx = currentId ? sorted.findIndex(function (d) { return d.id === currentId; }) : -1;
+ var nextIdx = curIdx === -1
+ ? (direction === 1 ? 0 : sorted.length - 1)
+ : (curIdx + direction + sorted.length) % sorted.length;
+ var nextDraft = sorted[nextIdx];
+ if (!nextDraft) return;
+ setActiveAnchor(nextDraft.id);
+ var snippet = nextDraft.snippet ? (' — ' + String(nextDraft.snippet).slice(0, 60)) : '';
+ announce('Annotering ' + (nextIdx + 1) + ' av ' + sorted.length + ': ' + (nextDraft.target_anchor || 'page') + snippet);
+ return;
+ }
+ });
}
function setThemeLabel(theme) {
diff --git a/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index 55a9581..dbb8a78 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/tests/playground/voyage-playground.test.mjs
@@ -279,3 +279,23 @@ test('voyage-playground.html declares renderAnnotationList JS function (v4.3 Ste
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+renderAnnotationList\s*\(\s*\)/, 'renderAnnotationList() function required');
});
+
+test('voyage-playground.html declares wireKeyboardNav with j/k/]/Escape (v4.3 Step 20)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ assert.match(text, /function\s+wireKeyboardNav\s*\(\s*\)/, 'wireKeyboardNav() function required');
+ assert.match(text, /e\.key === 'j'/, "'j' key handler required");
+ assert.match(text, /e\.key === 'k'/, "'k' key handler required");
+ assert.match(text, /e\.key === '\]'/, "']' key (toggle-sidebar) required");
+ assert.match(text, /e\.key === 'Escape'/, "'Escape' key handler required");
+});
+
+test('voyage-playground.html keyboard nav skips inputs/textareas (v4.3 Step 20)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ assert.match(text, /matches\([^)]*input[^)]*textarea/, 'input/textarea matches() guard required');
+});
+
+test('voyage-playground.html keyboard nav announces via aria-live region (v4.3 Step 20)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ // The wireKeyboardNav body contains announce(... ' av ' ...) for nav-position announce
+ assert.match(text, /announce\('Annotering '/, 'aria-live announce on annotation navigation required');
+});