detached ef38ad0

fix(web): accessibility of the command palette, live swaps and shortcuts (#59)

commit
ef38ad003e0ebee3afecd42d576ac4228405050e
author
brice · · 2026-10-18 06:39 UTC
committer
bnema · · 2026-10-18 06:39 UTC
parent
79d6b04
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.21.0
45 files · +1393 -451
M internal/adapters/web/diff.templ +1 -1
@@ -54,7 +54,7 @@ templ Commit(l Layout, p CommitPage) {
5454 }
5555 </dl>
5656 </header>
57- <div class="diff-tools" role="toolbar" aria-label="Diff view">
57+ <div class="diff-tools" role="group" aria-label="Diff view">
5858 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
5959 <span class="grow"></span>
6060 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
M internal/adapters/web/diff_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/jstest/a11y.test.mjs +319 -0
@@ -0,0 +1,319 @@
1+// Tests of the keyboard accessibility of static/js/ermite.js and files.js on
2+// the small DOM of dom.mjs: the "go to" palette as a combobox, Escape in the
3+// file finder, the focus kept through a live swap, and the preference that
4+// turns the single-key shortcuts off. Run with
5+// node --test 'internal/adapters/web/jstest/*.test.mjs'
6+import assert from 'node:assert/strict';
7+import { test } from 'node:test';
8+import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
9+
10+// The few things ermite.js uses that dom.mjs does not have.
11+El.prototype.replaceWith = function (n) {
12+ const p = this.parent;
13+ const i = p.nodes.indexOf(this);
14+ const [added] = p.adopt([n]);
15+ p.nodes.splice(i, 1, added);
16+ this.parent = null;
17+};
18+El.prototype.contains = function (e) { for (let n = e; n; n = n.parent) if (n === this) return true; return false; };
19+Object.defineProperty(El.prototype, 'isConnected', { get() { let n = this; while (n.parent) n = n.parent; return n === dom.body; } });
20+El.prototype.toggleAttribute = function (k, on) { if (on) this.setAttribute(k, ''); else this.removeAttribute(k); return on; };
21+El.prototype.dispatchEvent = function (ev) { (this.events ||= []).push(ev.type); };
22+// The focusable controls of a fragment, as the selector of ermiteLive says.
23+const nativeAll = El.prototype.querySelectorAll;
24+El.prototype.querySelectorAll = function (sel) {
25+ if (sel === '[data-rows] > [data-cursor]') return this.querySelectorAll('li[data-cursor]'); // no cursor in these tests
26+ if (sel === '[data-rows] > *') return [];
27+ if (!sel.startsWith('a[href], button')) return nativeAll.call(this, sel);
28+ const out = [];
29+ const walk = (e) => e.children.forEach((c) => {
30+ if ((c.tagName === 'A' && c.hasAttribute('href')) || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(c.tagName) || c.hasAttribute('tabindex')) out.push(c);
31+ walk(c);
32+ });
33+ walk(this);
34+ return out;
35+};
36+
37+let active = null;
38+// As in a browser, an element inside a hidden one does not take the focus.
39+El.prototype.focus = function (opts) {
40+ dom.focused.push({ el: this, opts });
41+ if (!this.closest('[hidden]')) active = this;
42+};
43+const doc = installDom({ parseHTML: (text) => parsed[text] });
44+const parsed = {};
45+Object.defineProperty(doc, 'activeElement', { get: () => active ?? dom.body });
46+doc.documentElement = { classList: { add() {} } };
47+const query = { all: doc.querySelectorAll, one: doc.querySelector };
48+doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.js
49+doc.querySelector = () => null;
50+
51+const store = new Map();
52+globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
53+const listeners = {};
54+globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55+globalThis.scrollTo = () => {};
56+globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
57+
58+await import('../static/js/files.js');
59+await import('../static/js/ermite.js');
60+doc.querySelectorAll = query.all;
61+doc.querySelector = query.one;
62+const F = window.ermiteFinder;
63+const K = window.ermiteKeys;
64+
65+// keydown runs an element handler, then the window ones unless stopped, as a
66+// browser does.
67+function keydown(target, key, elementHandler, extra = {}) {
68+ const e = {
69+ key, target, isComposing: false, prevented: false, stopped: false, blurred: false,
70+ preventDefault() { this.prevented = true; },
71+ stopPropagation() { this.stopped = true; },
72+ ...extra,
73+ };
74+ target.closest ||= () => null;
75+ elementHandler?.(e);
76+ if (!e.stopped) for (const f of listeners.keydown) f(e);
77+ return e;
78+}
79+
80+// ---- the palette --------------------------------------------------------
81+
82+function palette() {
83+ const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));
84+ const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
85+ items[0].setAttribute('data-cursor', '');
86+ const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
87+ const list = el('ul', { id: 'cmd-list' }, ...items);
88+ dom.body.replaceChildren(input, list);
89+ return { input, list, items };
90+}
91+
92+test('the palette: options, selected option and aria-activedescendant follow the cursor', () => {
93+ const p = palette();
94+ window.ermiteCmd.sync();
95+ assert.deepEqual(p.items.map((i) => i.getAttribute('role')), ['option', 'option', 'option']);
96+ assert.equal(new Set(p.items.map((i) => i.id)).size, 3, 'unique ids');
97+ assert.equal(p.input.getAttribute('aria-expanded'), 'true');
98+ assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
99+ assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['true', 'false', 'false']);
100+
101+ window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
102+ assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[1].id);
103+ assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['false', 'true', 'false']);
104+ assert.ok(p.items[1].hasAttribute('data-cursor') && !p.items[0].hasAttribute('data-cursor'));
105+
106+ window.ermite.cmdKey({ key: 'ArrowUp', preventDefault() {} });
107+ assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
108+});
109+
110+test('the palette: a filter moves the active option, no match empties it and collapses the popup', () => {
111+ const p = palette();
112+ p.input.value = 'branch';
113+ window.ermite.cmdFilter(p.input);
114+ assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
115+ assert.equal(p.items[2].getAttribute('aria-selected'), 'true');
116+ assert.equal(p.items[0].getAttribute('aria-selected'), 'false');
117+ p.input.value = 'zzz';
118+ window.ermite.cmdFilter(p.input);
119+ assert.equal(p.input.getAttribute('aria-expanded'), 'false');
120+ assert.equal(p.input.getAttribute('aria-activedescendant'), null);
121+});
122+
123+test('the palette: the file hits that arrive later get ids, roles and the active option', async () => {
124+ const p = palette();
125+ dom.body.setAttribute('data-paths', '/paths.json');
126+ dom.body.setAttribute('data-blob', '/blob/main');
127+ p.input.value = 'main';
128+ window.ermite.cmdFilter(p.input); // the branch "main" is the active option
129+ assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
130+ F.palette(p.input);
131+ flushFrames();
132+ await settle();
133+ const hits = p.list.children.filter((li) => li.className === 'finder-hit');
134+ assert.ok(hits.length > 0, 'a file hit came');
135+ for (const h of hits) {
136+ assert.equal(h.getAttribute('role'), 'option');
137+ assert.ok(h.id);
138+ }
139+ assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
140+ // down to the hit: the field points at it
141+ window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
142+ assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
143+ assert.equal(hits[0].getAttribute('aria-selected'), 'true');
144+ dom.body.removeAttribute('data-paths');
145+});
146+
147+// ---- the file finder ----------------------------------------------------
148+
149+function finder() {
150+ const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
151+ const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
152+ rows[0].setAttribute('data-cursor', '');
153+ const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
154+ const list = el('ul', { id: 'finder-list' });
155+ dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
156+ dom.body.setAttribute('data-paths', '/paths.json');
157+ dom.body.setAttribute('data-blob', '/blob/main');
158+ return { q, list };
159+}
160+
161+test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
162+ const f = finder();
163+ f.q.value = 'main';
164+ F.input(f.q);
165+ flushFrames();
166+ await settle();
167+ active = f.q;
168+ let blurred = false;
169+ f.q.blur = () => { blurred = true; };
170+ f.q.closest = () => f.q; // the global handler sees a field being typed in
171+ const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
172+ assert.equal(f.q.value, '');
173+ assert.equal(e.stopped, true);
174+ assert.equal(e.prevented, true);
175+ assert.equal(blurred, false, 'the field keeps the focus');
176+ assert.equal(active, f.q);
177+ // Escape again, with nothing typed: the field is left (global handler)
178+ const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
179+ assert.equal(e2.stopped, false);
180+ assert.equal(blurred, true);
181+});
182+
183+test('the finder follows the cursor moved from outside (j / k)', async () => {
184+ const f = finder();
185+ f.q.value = 'm';
186+ F.input(f.q);
187+ flushFrames();
188+ await settle();
189+ const [first, second] = f.list.children;
190+ assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
191+ // ermite.move: the cursor goes to the next option
192+ first.removeAttribute('data-cursor');
193+ second.setAttribute('data-cursor', '');
194+ F.sync();
195+ assert.equal(second.getAttribute('aria-selected'), 'true');
196+ assert.equal(first.getAttribute('aria-selected'), 'false');
197+ assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
198+});
199+
200+// ---- the focus through a live swap --------------------------------------
201+
202+function rows() {
203+ const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
204+ const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
205+ const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
206+ dom.body.replaceChildren(main, list);
207+ return { main, list, mk };
208+}
209+const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
210+
211+test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
212+ const r = rows();
213+ const old = r.list.children[1];
214+ active = old.children[0];
215+ dom.focused = [];
216+ const fresh = r.mk('repo-b', '/b');
217+ swapWith('b1', fresh);
218+ assert.equal(active, fresh.children[0], 'the link with the same href');
219+ assert.equal(dom.focused.at(-1).opts.preventScroll, true);
220+ // the second control
221+ active = fresh.children[1];
222+ const fresh2 = r.mk('repo-b', '/b');
223+ swapWith('b2', fresh2);
224+ assert.equal(active, fresh2.children[1], 'the button with the same id');
225+});
226+
227+test('a swap with the focus elsewhere does not touch it', () => {
228+ const r = rows();
229+ active = r.list.children[0].children[0];
230+ dom.focused = [];
231+ swapWith('b3', r.mk('repo-b', '/b'));
232+ assert.equal(dom.focused.length, 0);
233+ assert.equal(active, r.list.children[0].children[0]);
234+});
235+
236+test('a removed row hands the focus to the next row, else the previous, else the content', () => {
237+ const r = rows();
238+ active = r.list.children[1].children[0];
239+ const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
240+ swapWith('g1', gone('repo-b'));
241+ assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
242+ assert.equal(r.list.children[1].id, 'repo-c');
243+ active = r.list.children[1].children[0];
244+ swapWith('g2', gone('repo-c'));
245+ assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
246+ swapWith('g3', gone('repo-a'));
247+ // focus was on repo-a's link? it was handed there: now it is removed too
248+ assert.equal(active, r.main);
249+});
250+
251+test('a removed row skips the rows a filter hides', () => {
252+ const r = rows();
253+ const [a, b, c] = r.list.children;
254+ b.hidden = true; // filtered out
255+ active = a.children[0];
256+ swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
257+ assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
258+ c.hidden = true;
259+ swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
260+ assert.equal(active, r.main, 'no shown row left: the content');
261+});
262+
263+// ---- single-character shortcuts -----------------------------------------
264+
265+test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
266+ const events = () => dom.body.events.splice(0);
267+ dom.body.events = [];
268+ doc.querySelector = () => null;
269+ const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
270+ press('j');
271+ assert.deepEqual(events(), ['k-j'], 'on by default');
272+
273+ K.set(true);
274+ assert.equal(store.get('keys-off'), '1', 'persisted');
275+ for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
276+ press('g'); press('h');
277+ assert.deepEqual(events(), [], 'nothing fires');
278+ press('k', { ctrlKey: true });
279+ press('Escape');
280+ assert.deepEqual(events(), ['k-cmd', 'k-esc']);
281+
282+ K.set(false);
283+ assert.equal(store.get('keys-off'), '', 'persisted off');
284+ press('j');
285+ assert.deepEqual(events(), ['k-j']);
286+});
287+
288+test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
289+ const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
290+ const note = el('p', { id: 'keys-note' });
291+ const lbl = el('span', { cls: 'lbl' });
292+ const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
293+ dom.body.replaceChildren(box, note, list);
294+ const find = doc.querySelector;
295+ doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
296+ K.set(true);
297+ assert.equal(box.checked, true);
298+ assert.match(note.textContent, /are off/);
299+ assert.match(lbl.textContent, /on$/);
300+ K.set(false);
301+ assert.equal(box.checked, false);
302+ assert.match(note.textContent, /are on/);
303+ assert.match(lbl.textContent, /off$/);
304+ doc.querySelector = find;
305+});
306+
307+test('the preference is read from storage when the page loads', async () => {
308+ store.set('keys-off', '1');
309+ listeners.keydown = [];
310+ const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
311+ doc.querySelectorAll = () => [];
312+ doc.querySelector = () => null;
313+ new Function(src)(); // a second page load
314+ assert.equal(window.ermiteKeys.off(), true);
315+ dom.body.events = [];
316+ keydown({ closest: () => null }, 'j');
317+ assert.deepEqual(dom.body.events, []);
318+ store.set('keys-off', '');
319+});
M internal/adapters/web/jstest/finder.test.mjs +1 -1
@@ -49,7 +49,7 @@ function page() {
4949 }
5050
5151 const key = (k, extra = {}) => {
52- const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, ...extra };
52+ const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, stopPropagation() {}, ...extra };
5353 return e;
5454 };
5555 const status = () => document.getElementById('finder-status').textContent;
M internal/adapters/web/jstest/tree.test.mjs +12 -0
@@ -276,6 +276,18 @@ test('init: one tab stop, the row under the cursor; name links are out of the ta
276276 assert.equal(body.children[1].getAttribute('tabindex'), '-1');
277277 });
278278
279+test('l or h before any focus in the tree brings the focus to the cursor row', async () => {
280+ pending.clear();
281+ const body = table('P/', 'f');
282+ assert.equal(dom.active, null, 'the page has the focus after a load');
283+ T.key(true); // opens P/, the cursor stays on it
284+ assert.equal(dom.active, body.children[0]);
285+ await answer('/rows/P/', 'a');
286+ dom.active = null;
287+ T.key(false); // closes P/
288+ assert.equal(dom.active, body.children[0]);
289+});
290+
279291 test('l and h move the focus with the cursor, and the tab stop with them', async () => {
280292 pending.clear();
281293 const body = table('P/', 'f');
M internal/adapters/web/keys.templ +2 -1
@@ -36,6 +36,7 @@ templ Keys() {
3636 k-permalink from:body -> ermite.permalink();
3737 k-merge from:body -> window.ermiteMR?.openMerge();
3838 k-read from:body -> window.ermiteMR?.markRead();
39- k-gf from:body -> window.ermiteMR?.focusFiles()"
39+ k-gf from:body -> window.ermiteMR?.focusFiles();
40+ k-keys from:body -> ermite.toggleKeys()"
4041 ></div>
4142 }
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.templ +2 -2
@@ -358,7 +358,7 @@ templ mrRail(p MRPage) {
358358 // request, the merge menu and the other actions. Each menu is a <details>: it
359359 // works without scripts; with them, M and the keys of the menu drive it.
360360 templ mrBar(p MRPage) {
361- <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge">
361+ <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
362362 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
363363 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
364364 @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
@@ -488,7 +488,7 @@ templ Compare(l Layout, p ComparePage) {
488488 </form>
489489 }
490490 if p.HasDiff {
491- <div class="diff-tools" role="toolbar" aria-label="Diff view">
491+ <div class="diff-tools" role="group" aria-label="Diff view">
492492 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
493493 <span class="grow"></span>
494494 @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
M internal/adapters/web/mr_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/overlays.templ +15 -3
@@ -2,13 +2,20 @@
22
33 package web
44
5-import "strings"
5+import (
6+ "strconv"
7+ "strings"
8+)
69
710 // OverlayHelp is the keyboard map panel; the keys of the merge request page
811 // show on that page only.
912 templ OverlayHelp(mr bool) {
1013 <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
1114 <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
15+ <div class="keys-pref">
16+ <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>
17+ <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
18+ </div>
1219 <dl class="keys">
1320 <div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div>
1421 <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>
@@ -104,16 +111,21 @@ templ OverlayCmd(items []CmdItem) {
104111 placeholder="Go to a section, branch, tag, file or repository…"
105112 autocomplete="off"
106113 spellcheck="false"
114+ role="combobox"
115+ aria-autocomplete="list"
116+ aria-expanded="true"
107117 aria-controls="cmd-list"
118+ aria-activedescendant="cmd-o0"
108119 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
109120 hx-on:keydown="ermite.cmdKey(event)"
110121 />
111122 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
112123 </div>
113- <ul id="cmd-list" class="cmd-list" aria-label="Targets">
124+ <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
114125 for i, c := range items {
115- <li data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
126+ <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
116127 }
128+ <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"><a href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
117129 </ul>
118130 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
119131 </dialog>
M internal/adapters/web/overlays_templ.go +72 -30
@@ -10,7 +10,10 @@ package web
1010 import "github.com/a-h/templ"
1111 import templruntime "github.com/a-h/templ/runtime"
1212
13-import "strings"
13+import (
14+ "strconv"
15+ "strings"
16+)
1417
1518 // OverlayHelp is the keyboard map panel; the keys of the merge request page
1619 // show on that page only.
@@ -35,14 +38,14 @@ func OverlayHelp(mr bool) templ.Component {
3538 templ_7745c5c3_Var1 = templ.NopComponent
3639 }
3740 ctx = templ.ClearChildren(ctx)
38- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor; on Files, a directory opens or closes in place</dd></div><div><dt><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> <kbd>home</kbd> <kbd>end</kbd></dt><dd>in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file</dd></div><div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div><div><dt><kbd>b</kbd></dt><dd>")
41+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><div class=\"keys-pref\"><label><input type=\"checkbox\" id=\"keys-off\" aria-describedby=\"keys-note\" hx-on:change=\"ermite.setKeysOff(this.checked)\"> Turn off single-key shortcuts</label><p id=\"keys-note\" class=\"dim\">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor; on Files, a directory opens or closes in place</dd></div><div><dt><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> <kbd>home</kbd> <kbd>end</kbd></dt><dd>in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file</dd></div><div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div><div><dt><kbd>b</kbd></dt><dd>")
3942 if templ_7745c5c3_Err != nil {
4043 return templ_7745c5c3_Err
4144 }
4245 var templ_7745c5c3_Var2 string
4346 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("switch")
4447 if templ_7745c5c3_Err != nil {
45- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 21, Col: 43}
48+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 28, Col: 43}
4649 }
4750 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
4851 if templ_7745c5c3_Err != nil {
@@ -96,7 +99,7 @@ func OverlayRefs(l Layout) templ.Component {
9699 var templ_7745c5c3_Var4 string
97100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
98101 if templ_7745c5c3_Err != nil {
99- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 56, Col: 58}
102+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 63, Col: 58}
100103 }
101104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
102105 if templ_7745c5c3_Err != nil {
@@ -119,7 +122,7 @@ func OverlayRefs(l Layout) templ.Component {
119122 var templ_7745c5c3_Var5 string
120123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
121124 if templ_7745c5c3_Err != nil {
122- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 53}
125+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 71, Col: 53}
123126 }
124127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
125128 if templ_7745c5c3_Err != nil {
@@ -172,7 +175,7 @@ func refItem(r RefItem) templ.Component {
172175 var templ_7745c5c3_Var7 templ.SafeURL
173176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
174177 if templ_7745c5c3_Err != nil {
175- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 79, Col: 31}
178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 86, Col: 31}
176179 }
177180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
178181 if templ_7745c5c3_Err != nil {
@@ -210,7 +213,7 @@ func refItem(r RefItem) templ.Component {
210213 var templ_7745c5c3_Var8 string
211214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
212215 if templ_7745c5c3_Err != nil {
213- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 90, Col: 18}
216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 97, Col: 18}
214217 }
215218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
216219 if templ_7745c5c3_Err != nil {
@@ -247,79 +250,118 @@ func OverlayCmd(items []CmdItem) templ.Component {
247250 templ_7745c5c3_Var9 = templ.NopComponent
248251 }
249252 ctx = templ.ClearChildren(ctx)
250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
251254 if templ_7745c5c3_Err != nil {
252255 return templ_7745c5c3_Err
253256 }
254257 for i, c := range items {
255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li data-k=\"")
258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li id=\"")
256259 if templ_7745c5c3_Err != nil {
257260 return templ_7745c5c3_Err
258261 }
259262 var templ_7745c5c3_Var10 string
260- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
263+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
261264 if templ_7745c5c3_Err != nil {
262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 115, Col: 56}
265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 30}
263266 }
264267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
265268 if templ_7745c5c3_Err != nil {
266269 return templ_7745c5c3_Err
267270 }
268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"")
271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" role=\"option\" aria-selected=\"")
272+ if templ_7745c5c3_Err != nil {
273+ return templ_7745c5c3_Err
274+ }
275+ var templ_7745c5c3_Var11 string
276+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
277+ if templ_7745c5c3_Err != nil {
278+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 89}
279+ }
280+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
281+ if templ_7745c5c3_Err != nil {
282+ return templ_7745c5c3_Err
283+ }
284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-k=\"")
285+ if templ_7745c5c3_Err != nil {
286+ return templ_7745c5c3_Err
287+ }
288+ var templ_7745c5c3_Var12 string
289+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
290+ if templ_7745c5c3_Err != nil {
291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 140}
292+ }
293+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
294+ if templ_7745c5c3_Err != nil {
295+ return templ_7745c5c3_Err
296+ }
297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
269298 if templ_7745c5c3_Err != nil {
270299 return templ_7745c5c3_Err
271300 }
272301 if i == 0 {
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " data-cursor")
302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-cursor")
274303 if templ_7745c5c3_Err != nil {
275304 return templ_7745c5c3_Err
276305 }
277306 }
278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "><a href=\"")
307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a href=\"")
279308 if templ_7745c5c3_Err != nil {
280309 return templ_7745c5c3_Err
281310 }
282- var templ_7745c5c3_Var11 templ.SafeURL
283- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
311+ var templ_7745c5c3_Var13 templ.SafeURL
312+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
284313 if templ_7745c5c3_Err != nil {
285- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 115, Col: 114}
314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 198}
286315 }
287- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
316+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
288317 if templ_7745c5c3_Err != nil {
289318 return templ_7745c5c3_Err
290319 }
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" tabindex=\"-1\"><span class=\"lbl\">")
320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" tabindex=\"-1\"><span class=\"lbl\">")
292321 if templ_7745c5c3_Err != nil {
293322 return templ_7745c5c3_Err
294323 }
295- var templ_7745c5c3_Var12 string
296- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
324+ var templ_7745c5c3_Var14 string
325+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
297326 if templ_7745c5c3_Err != nil {
298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 115, Col: 158}
327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 242}
299328 }
300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
329+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
301330 if templ_7745c5c3_Err != nil {
302331 return templ_7745c5c3_Err
303332 }
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><span class=\"k\">")
333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><span class=\"k\">")
305334 if templ_7745c5c3_Err != nil {
306335 return templ_7745c5c3_Err
307336 }
308- var templ_7745c5c3_Var13 string
309- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
337+ var templ_7745c5c3_Var15 string
338+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
310339 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 115, Col: 191}
340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 275}
312341 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
342+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
314343 if templ_7745c5c3_Err != nil {
315344 return templ_7745c5c3_Err
316345 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span></a></li>")
346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></a></li>")
318347 if templ_7745c5c3_Err != nil {
319348 return templ_7745c5c3_Err
320349 }
321350 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<li id=\"")
352+ if templ_7745c5c3_Err != nil {
353+ return templ_7745c5c3_Err
354+ }
355+ var templ_7745c5c3_Var16 string
356+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
357+ if templ_7745c5c3_Err != nil {
358+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 128, Col: 38}
359+ }
360+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
323365 if templ_7745c5c3_Err != nil {
324366 return templ_7745c5c3_Err
325367 }
M internal/adapters/web/static/css/25-panel.css +11 -4
@@ -21,7 +21,6 @@
2121 & h2 { font-size: var(--fs); flex: 1; }
2222 & input { flex: 1; min-width: 0; height: 32px; font-size: 16px; }
2323 & input::placeholder { color: var(--ink-mute); opacity: 1; }
24- & input:focus-visible { outline: none; }
2524 }
2625 .panel-x { color: var(--ink-mute); font-size: var(--fs-s); display: flex; gap: var(--s2); align-items: center; }
2726 .panel-x:hover { color: var(--ink-hi); }
@@ -60,10 +59,18 @@
6059 }
6160
6261 .keys {
63- display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); column-gap: var(--s5); row-gap: var(--s1);
62+ display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); column-gap: var(--s5); row-gap: var(--s1);
6463 padding: var(--s3) var(--s4) var(--s4) !important;
6564 & > div { display: grid; grid-template-columns: 130px 1fr; gap: var(--s3); align-items: baseline; padding: var(--s1) 0; border-bottom: 1px solid var(--border); }
66- & dt { white-space: nowrap; display: flex; gap: var(--s1); }
67- & dd { color: var(--ink-dim); font-size: var(--fs-s); }
65+ & dt { display: flex; flex-wrap: wrap; gap: var(--s1); min-width: 0; }
66+ & dd { color: var(--ink-dim); font-size: var(--fs-s); min-width: 0; overflow-wrap: anywhere; }
67+ @media (max-width: 480px) { & > div { grid-template-columns: minmax(0, 1fr); gap: var(--s1); } }
68+ }
69+ /* the preference "single-key shortcuts" of the help dialog */
70+ .keys-pref {
71+ flex: none; padding: var(--s3) var(--s4) 0;
72+ & label { display: flex; align-items: center; gap: var(--s2); cursor: pointer; }
73+ & input[type=checkbox] { width: 16px; height: 16px; flex: none; accent-color: var(--teal); }
74+ & p { font-size: var(--fs-s); margin-top: var(--s1); }
6875 }
6976 }
M internal/adapters/web/static/js/ermite.js +184 -24
@@ -64,6 +64,7 @@
6464 setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
6565 rows[j].scrollIntoView({ block: 'nearest' });
6666 window.ermiteTree?.sync(rows[j]);
67+ window.ermiteFinder?.sync(); // the rows may be the file finder's options
6768 const mark = j + 1 + '/' + rows.length;
6869 body().dataset.nav = mark;
6970 setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
@@ -76,6 +77,7 @@
7677 setCursor(row, $$('[data-rows] > [data-cursor]'));
7778 row.scrollIntoView({ block: 'nearest' });
7879 window.ermiteTree?.sync(row);
80+ window.ermiteFinder?.sync();
7981 },
8082 open() {
8183 if ($('dialog[open]') || window.ermiteTree?.open()) return;
@@ -108,6 +110,18 @@
108110 $(`.tabs [data-key=${key}]`)?.click();
109111 },
110112
113+ // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the
114+ // help dialog and the action of the palette.
115+ setKeysOff(off) {
116+ window.ermiteKeys.set(off);
117+ },
118+ toggleKeys() {
119+ const off = !window.ermiteKeys.off();
120+ window.ermiteKeys.set(off);
121+ ermite.closeDialogs();
122+ flash(off ? 'single-key shortcuts off · ctrl k and esc still work' : 'single-key shortcuts on');
123+ },
124+
111125 // Dialogs.
112126 closeDialogs() {
113127 for (const d of $$('dialog[open]')) d.close();
@@ -117,6 +131,7 @@
117131 if (!d) return;
118132 ermite.closeDialogs();
119133 d.showModal();
134+ if (id === 'panel-cmd') window.ermiteCmd?.sync();
120135 if (focusId) document.getElementById(focusId)?.focus();
121136 },
122137
@@ -146,31 +161,12 @@
146161 }
147162 return rows;
148163 },
149- // The "go to" palette.
164+ // The "go to" palette (ermiteCmd below).
150165 cmdFilter(input) {
151- const t = input.value.toLowerCase().trim();
152- // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
153- // the finder owns them.
154- const items = $$('#cmd-list > li[data-k]');
155- for (const li of items) li.hidden = !li.dataset.k.includes(t);
156- const first = items.find((li) => !li.hidden);
157- if (first) setCursor(first, $$('#cmd-list > li'));
166+ window.ermiteCmd.filter(input);
158167 },
159168 cmdKey(e) {
160- const vis = $$('#cmd-list > li:not([hidden])');
161- const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
162- const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
163- if (step) {
164- e.preventDefault();
165- const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
166- if (n) {
167- setCursor(n, $$('#cmd-list > li'));
168- n.scrollIntoView({ block: 'nearest' });
169- }
170- } else if (e.key === 'Enter') {
171- e.preventDefault();
172- vis[Math.max(i, 0)]?.querySelector('a')?.click();
173- }
169+ window.ermiteCmd.key(e);
174170 },
175171
176172 // Diff.
@@ -253,6 +249,69 @@
253249 };
254250 window.ermite = ermite;
255251
252+ // The "go to" palette is an editable combobox with a listbox popup: the
253+ // focus stays in #cmd-q, the options are the <li> of #cmd-list, and the one
254+ // with data-cursor is the active one (aria-selected on it, aria-activedescendant
255+ // on the field). sync keeps the two in step; it runs on every change of the
256+ // cursor or of the list, including the file hits files.js adds later.
257+ // Self-contained: jstest/palette.test.mjs evaluates this block alone.
258+ window.ermiteCmd = (() => {
259+ const list = () => document.getElementById('cmd-list');
260+ const field = () => document.getElementById('cmd-q');
261+ const options = () => [...list().querySelectorAll(':scope > li')];
262+ let seq = 0;
263+ const moveCursor = (li) => {
264+ for (const o of options()) if (o !== li) o.removeAttribute('data-cursor');
265+ li.setAttribute('data-cursor', '');
266+ };
267+ function sync() {
268+ const input = field();
269+ if (!input || !list()) return;
270+ const all = options();
271+ const shown = all.filter((li) => !li.hidden);
272+ const cur = shown.find((li) => li.hasAttribute('data-cursor'));
273+ for (const li of all) {
274+ li.id ||= 'cmd-h' + seq++;
275+ li.setAttribute('role', 'option');
276+ li.setAttribute('aria-selected', li === cur ? 'true' : 'false');
277+ }
278+ input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');
279+ if (cur) input.setAttribute('aria-activedescendant', cur.id);
280+ else input.removeAttribute('aria-activedescendant');
281+ }
282+ return {
283+ sync,
284+ // filter hides the entries that do not contain the text of the field.
285+ // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
286+ // the finder owns them.
287+ filter(input) {
288+ const t = input.value.toLowerCase().trim();
289+ const items = options().filter((li) => li.hasAttribute('data-k'));
290+ for (const li of items) li.hidden = !li.getAttribute('data-k').includes(t);
291+ const first = items.find((li) => !li.hidden);
292+ if (first) moveCursor(first);
293+ sync();
294+ },
295+ key(e) {
296+ const vis = options().filter((li) => !li.hidden);
297+ const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
298+ const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
299+ if (step) {
300+ e.preventDefault();
301+ const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
302+ if (n) {
303+ moveCursor(n);
304+ n.scrollIntoView({ block: 'nearest' });
305+ }
306+ sync();
307+ } else if (e.key === 'Enter') {
308+ e.preventDefault();
309+ vis[Math.max(i, 0)]?.querySelector('a')?.click();
310+ }
311+ },
312+ };
313+ })();
314+
256315 // The side panel (aside.rail) by keyboard: l enters it on its main action
257316 // (the primary button) or its first control, j / k step between its shown
258317 // controls (not hidden, not in a closed <details>), h leaves it for <main>.
@@ -458,6 +517,50 @@
458517 return { load, file, current };
459518 })();
460519
520+ // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys
521+ // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,
522+ // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows
523+ // keep working. The choice lives in localStorage ("keys-off") and is made in
524+ // the help dialog (a checkbox) or the palette (an action). Self-contained:
525+ // jstest/keys.test.mjs evaluates this block alone.
526+ window.ermiteKeys = (() => {
527+ const KEY = 'keys-off';
528+ let off = false;
529+ try { off = localStorage.getItem(KEY) === '1'; } catch { /* no storage: on, and the choice holds for this page */ }
530+ // apply shows the state: the checkbox and the note of the help dialog,
531+ // and the label of the palette action.
532+ function apply() {
533+ const box = document.getElementById('keys-off');
534+ if (box) box.checked = off;
535+ const note = document.getElementById('keys-note');
536+ if (note) {
537+ note.textContent = off
538+ ? 'Single-key shortcuts are off: only ctrl k and esc, enter and the arrows work. Turn them on again here.'
539+ : 'Single-key shortcuts are on. ctrl k and esc work whatever this says.';
540+ }
541+ const act = document.querySelector('#cmd-list [data-fire="keys"] .lbl');
542+ if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';
543+ }
544+ function set(v) {
545+ off = !!v;
546+ try { localStorage.setItem(KEY, off ? '1' : ''); } catch { /* storage refused: holds for this page */ }
547+ apply();
548+ }
549+ addEventListener('storage', (e) => {
550+ if (e.key !== KEY) return;
551+ off = e.newValue === '1';
552+ apply();
553+ });
554+ apply();
555+ return {
556+ off: () => off,
557+ set,
558+ apply,
559+ // blocks says whether the preference turns this key off.
560+ blocks: (e) => off && e.key.length === 1,
561+ };
562+ })();
563+
461564 // 1. Keys.
462565 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
463566 s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
@@ -480,6 +583,8 @@
480583 if (typing) e.target.blur();
481584 return ermite.fire('esc');
482585 }
586+ // Single-character shortcuts turned off: only the keys above work.
587+ if (window.ermiteKeys.blocks(e)) { pending = 0; return; }
483588 // An open merge menu takes its keys (1-9, D) before the field check: its
484589 // radios and its checkbox hold the focus.
485590 if (mr?.menuKey(e)) return;
@@ -607,6 +712,50 @@ window.ermiteLive = (() => {
607712 // server closing it, a network hiccup) is not worth a message.
608713 const LOST_AFTER = 5000;
609714 let lostTimer = 0;
715+ // The focus survives a swap. focusables are the controls of a fragment in
716+ // document order; where(root) says which one has the focus (its id, else its
717+ // tag and href or name, and its index among the controls) and refocus finds
718+ // it again in the element that replaced root.
719+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]';
720+ // shown: not inside a hidden element (a filtered row, a folded block); the
721+ // browser refuses the focus there.
722+ const shown = (e) => !e.closest('[hidden]') && (!e.checkVisibility || e.checkVisibility());
723+ const focusables = (root) => [...root.querySelectorAll(FOCUSABLE)].filter(shown);
724+ // take focuses e and says whether the focus is there now.
725+ const take = (e) => { e.focus({ preventScroll: true }); return document.activeElement === e; };
726+ const where = (root) => {
727+ const a = document.activeElement;
728+ if (!a || a === document.body || !root.contains(a)) return null;
729+ if (a === root) return { self: true };
730+ const all = focusables(root);
731+ return { id: a.id, tag: a.tagName, href: a.getAttribute('href'), name: a.getAttribute('name'), index: all.indexOf(a) };
732+ };
733+ const refocus = (root, w) => {
734+ if (w.self) return root.focus({ preventScroll: true });
735+ const all = focusables(root);
736+ const same = (e) => e.tagName === w.tag;
737+ const target =
738+ (w.id && all.find((e) => e.id === w.id)) ||
739+ (w.href != null && all.find((e) => same(e) && e.getAttribute('href') === w.href)) ||
740+ (w.name != null && all.find((e) => same(e) && e.getAttribute('name') === w.name)) ||
741+ (all[w.index] && same(all[w.index]) && all[w.index]) ||
742+ all[0];
743+ if (target) target.focus({ preventScroll: true });
744+ else if (root.hasAttribute('tabindex')) root.focus({ preventScroll: true });
745+ };
746+ // lostFocus: the element that had the focus inside is gone. The focus goes to
747+ // the first control of the next shown row, else of the nearest shown row
748+ // before, else to the content.
749+ const lostFocus = (after, before) => {
750+ const tryRow = (e) => {
751+ if (!shown(e)) return false;
752+ const c = focusables(e)[0] || (e.hasAttribute('tabindex') ? e : null);
753+ return !!c && take(c);
754+ };
755+ for (let e = after; e; e = e.nextElementSibling) if (tryRow(e)) return;
756+ for (let e = before; e; e = e.previousElementSibling) if (tryRow(e)) return;
757+ document.getElementById('main')?.focus({ preventScroll: true });
758+ };
610759 const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
611760 return {
612761 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
@@ -628,16 +777,27 @@ window.ermiteLive = (() => {
628777 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
629778 for (const el of [...t.content.children]) {
630779 const old = el.id && document.getElementById(el.id);
631- if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
780+ const had = old ? where(old) : null; // the focus is inside what is replaced
781+ if (el.hasAttribute('data-gone')) {
782+ const after = old?.nextElementSibling, before = old?.previousElementSibling;
783+ old?.remove();
784+ if (had) lostFocus(after, before);
785+ continue;
786+ }
632787 if (!old) {
633788 const rows = document.getElementById('repo-rows');
634789 if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
635790 continue;
636791 }
637- if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
792+ if (old.hasAttribute('data-slot')) {
793+ old.replaceChildren(...el.childNodes);
794+ if (had) refocus(old, had);
795+ continue;
796+ }
638797 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
639798 old.replaceWith(el);
640799 window.htmx?.process(el);
800+ if (had) refocus(el, had);
641801 }
642802 // The rows may have changed under an active filter: apply it again and
643803 // keep the cursor on its row while that row is still shown.
M internal/adapters/web/static/js/files.js +18 -1
@@ -232,6 +232,9 @@ window.ermiteTree = (() => {
232232 if (!this.active()) return false;
233233 const li = cursorRow();
234234 if (!li) return false;
235+ // The focus joins the cursor row first (it may still be on the page, as
236+ // after a load): Enter then acts on the row the key acted on.
237+ if (document.activeElement !== li && !li.contains(document.activeElement)) mark(li, true);
235238 if (into) {
236239 if (isDir(li)) {
237240 if (!isOpen(li)) return toggle(li), true;
@@ -672,6 +675,13 @@ window.ermiteFinder = (() => {
672675 }
673676 }
674677
678+ // sync is syncActive for whoever moves the cursor of the results from
679+ // outside (j / k and gg / G, ermite.js): the options and the field follow it.
680+ function sync() {
681+ const el = $('finder-q');
682+ if (el && $('finder') && !$('finder').hidden) syncActive(el);
683+ }
684+
675685 // showTree: no query, the directory again, with j/k and Enter on its rows.
676686 function showTree(el) {
677687 rendered = null;
@@ -755,6 +765,7 @@ window.ermiteFinder = (() => {
755765 function palette(el) {
756766 if (!document.body.dataset.paths) return;
757767 clearHits();
768+ window.ermiteCmd?.sync(); // the cursor may have been on a hit
758769 if (pFrame) cancelAnimationFrame(pFrame);
759770 pFrame = 0;
760771 if (!el.value.trim()) {
@@ -802,6 +813,7 @@ window.ermiteFinder = (() => {
802813 list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
803814 shown[0].setAttribute('data-cursor', '');
804815 }
816+ window.ermiteCmd?.sync(); // roles, ids and the active option of the new hits
805817 }
806818
807819 // input runs the search of the field, once per frame.
@@ -816,12 +828,17 @@ window.ermiteFinder = (() => {
816828 }
817829
818830 return {
819- index, search, positions, visible, label, load, palette, paletteClose, input,
831+ index, search, positions, visible, label, load, palette, paletteClose, input, sync,
820832 // key moves the cursor of the results, opens one, or clears the field.
821833 key(el, e) {
822834 if (e.isComposing) return; // the Enter or Escape of an input method is its own
823835 const results = !$('finder').hidden;
824836 if (e.key === 'Escape') {
837+ // With a query, Escape only clears it: the field keeps the focus (the
838+ // global handler of ermite.js would blur it). Without one, it leaves the field.
839+ if (!el.value) return;
840+ e.preventDefault();
841+ e.stopPropagation();
825842 el.value = '';
826843 input(el);
827844 return;
M internal/app/testdata/web/blob-binary.html +25 -13
@@ -153,6 +153,11 @@
153153 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
154154 <kbd>esc</kbd> close</button>
155155 </div>
156+<div class="keys-pref">
157+<label>
158+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
159+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
160+</div>
156161 <dl class="keys">
157162 <div>
158163 <dt>
@@ -306,72 +311,78 @@
306311 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
307312 <div class="panel-head">
308313 <label for="cmd-q" class="sr">go to</label>
309-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
314+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
310315 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
311316 <kbd>esc</kbd>
312317 </button>
313318 </div>
314-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
315-<li data-k="go home" data-cursor>
319+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
320+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
316321 <a href="/" tabindex="-1">
317322 <span class="lbl">home</span>
318323 <span class="k">go</span>
319324 </a>
320325 </li>
321-<li data-k="section summary">
326+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
322327 <a href="/files" tabindex="-1">
323328 <span class="lbl">summary</span>
324329 <span class="k">section</span>
325330 </a>
326331 </li>
327-<li data-k="section files">
332+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
328333 <a href="/files/tree/main" tabindex="-1">
329334 <span class="lbl">files</span>
330335 <span class="k">section</span>
331336 </a>
332337 </li>
333-<li data-k="section commits">
338+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
334339 <a href="/files/commits/main" tabindex="-1">
335340 <span class="lbl">commits</span>
336341 <span class="k">section</span>
337342 </a>
338343 </li>
339-<li data-k="section branches &amp; tags">
344+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
340345 <a href="/files/refs" tabindex="-1">
341346 <span class="lbl">branches &amp; tags</span>
342347 <span class="k">section</span>
343348 </a>
344349 </li>
345-<li data-k="section merge requests">
350+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
346351 <a href="/files/merge-requests" tabindex="-1">
347352 <span class="lbl">merge requests</span>
348353 <span class="k">section</span>
349354 </a>
350355 </li>
351-<li data-k="section actions">
356+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
352357 <a href="/files/actions" tabindex="-1">
353358 <span class="lbl">actions</span>
354359 <span class="k">section</span>
355360 </a>
356361 </li>
357-<li data-k="branch feature/x">
362+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
358363 <a href="/files/tree/feature/x" tabindex="-1">
359364 <span class="lbl">feature/x</span>
360365 <span class="k">branch</span>
361366 </a>
362367 </li>
363-<li data-k="branch main">
368+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
364369 <a href="/files/tree/main" tabindex="-1">
365370 <span class="lbl">main</span>
366371 <span class="k">branch</span>
367372 </a>
368373 </li>
369-<li data-k="tag v1.0">
374+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
370375 <a href="/files/tree/v1.0" tabindex="-1">
371376 <span class="lbl">v1.0</span>
372377 <span class="k">tag</span>
373378 </a>
374379 </li>
380+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
381+<a href="#" data-fire="keys" tabindex="-1">
382+<span class="lbl">turn single-key shortcuts off</span>
383+<span class="k">action</span>
384+</a>
385+</li>
375386 </ul>
376387 <p class="panel-foot">
377388 <span>
@@ -410,7 +421,8 @@
410421 k-permalink from:body -> ermite.permalink();
411422 k-merge from:body -> window.ermiteMR?.openMerge();
412423 k-read from:body -> window.ermiteMR?.markRead();
413- k-gf from:body -> window.ermiteMR?.focusFiles()">
424+ k-gf from:body -> window.ermiteMR?.focusFiles();
425+ k-keys from:body -> ermite.toggleKeys()">
414426 </div>
415427 </body>
416428 </html>
M internal/app/testdata/web/blob-code.html +25 -13
@@ -223,6 +223,11 @@
223223 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
224224 <kbd>esc</kbd> close</button>
225225 </div>
226+<div class="keys-pref">
227+<label>
228+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
229+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
230+</div>
226231 <dl class="keys">
227232 <div>
228233 <dt>
@@ -376,72 +381,78 @@
376381 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
377382 <div class="panel-head">
378383 <label for="cmd-q" class="sr">go to</label>
379-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
384+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
380385 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
381386 <kbd>esc</kbd>
382387 </button>
383388 </div>
384-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
385-<li data-k="go home" data-cursor>
389+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
390+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
386391 <a href="/" tabindex="-1">
387392 <span class="lbl">home</span>
388393 <span class="k">go</span>
389394 </a>
390395 </li>
391-<li data-k="section summary">
396+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
392397 <a href="/files" tabindex="-1">
393398 <span class="lbl">summary</span>
394399 <span class="k">section</span>
395400 </a>
396401 </li>
397-<li data-k="section files">
402+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
398403 <a href="/files/tree/main" tabindex="-1">
399404 <span class="lbl">files</span>
400405 <span class="k">section</span>
401406 </a>
402407 </li>
403-<li data-k="section commits">
408+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
404409 <a href="/files/commits/main" tabindex="-1">
405410 <span class="lbl">commits</span>
406411 <span class="k">section</span>
407412 </a>
408413 </li>
409-<li data-k="section branches &amp; tags">
414+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
410415 <a href="/files/refs" tabindex="-1">
411416 <span class="lbl">branches &amp; tags</span>
412417 <span class="k">section</span>
413418 </a>
414419 </li>
415-<li data-k="section merge requests">
420+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
416421 <a href="/files/merge-requests" tabindex="-1">
417422 <span class="lbl">merge requests</span>
418423 <span class="k">section</span>
419424 </a>
420425 </li>
421-<li data-k="section actions">
426+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
422427 <a href="/files/actions" tabindex="-1">
423428 <span class="lbl">actions</span>
424429 <span class="k">section</span>
425430 </a>
426431 </li>
427-<li data-k="branch feature/x">
432+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
428433 <a href="/files/tree/feature/x" tabindex="-1">
429434 <span class="lbl">feature/x</span>
430435 <span class="k">branch</span>
431436 </a>
432437 </li>
433-<li data-k="branch main">
438+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
434439 <a href="/files/tree/main" tabindex="-1">
435440 <span class="lbl">main</span>
436441 <span class="k">branch</span>
437442 </a>
438443 </li>
439-<li data-k="tag v1.0">
444+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
440445 <a href="/files/tree/v1.0" tabindex="-1">
441446 <span class="lbl">v1.0</span>
442447 <span class="k">tag</span>
443448 </a>
444449 </li>
450+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
451+<a href="#" data-fire="keys" tabindex="-1">
452+<span class="lbl">turn single-key shortcuts off</span>
453+<span class="k">action</span>
454+</a>
455+</li>
445456 </ul>
446457 <p class="panel-foot">
447458 <span>
@@ -480,7 +491,8 @@
480491 k-permalink from:body -> ermite.permalink();
481492 k-merge from:body -> window.ermiteMR?.openMerge();
482493 k-read from:body -> window.ermiteMR?.markRead();
483- k-gf from:body -> window.ermiteMR?.focusFiles()">
494+ k-gf from:body -> window.ermiteMR?.focusFiles();
495+ k-keys from:body -> ermite.toggleKeys()">
484496 </div>
485497 </body>
486498 </html>
M internal/app/testdata/web/blob-empty.html +25 -13
@@ -153,6 +153,11 @@
153153 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
154154 <kbd>esc</kbd> close</button>
155155 </div>
156+<div class="keys-pref">
157+<label>
158+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
159+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
160+</div>
156161 <dl class="keys">
157162 <div>
158163 <dt>
@@ -306,72 +311,78 @@
306311 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
307312 <div class="panel-head">
308313 <label for="cmd-q" class="sr">go to</label>
309-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
314+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
310315 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
311316 <kbd>esc</kbd>
312317 </button>
313318 </div>
314-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
315-<li data-k="go home" data-cursor>
319+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
320+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
316321 <a href="/" tabindex="-1">
317322 <span class="lbl">home</span>
318323 <span class="k">go</span>
319324 </a>
320325 </li>
321-<li data-k="section summary">
326+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
322327 <a href="/files" tabindex="-1">
323328 <span class="lbl">summary</span>
324329 <span class="k">section</span>
325330 </a>
326331 </li>
327-<li data-k="section files">
332+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
328333 <a href="/files/tree/main" tabindex="-1">
329334 <span class="lbl">files</span>
330335 <span class="k">section</span>
331336 </a>
332337 </li>
333-<li data-k="section commits">
338+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
334339 <a href="/files/commits/main" tabindex="-1">
335340 <span class="lbl">commits</span>
336341 <span class="k">section</span>
337342 </a>
338343 </li>
339-<li data-k="section branches &amp; tags">
344+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
340345 <a href="/files/refs" tabindex="-1">
341346 <span class="lbl">branches &amp; tags</span>
342347 <span class="k">section</span>
343348 </a>
344349 </li>
345-<li data-k="section merge requests">
350+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
346351 <a href="/files/merge-requests" tabindex="-1">
347352 <span class="lbl">merge requests</span>
348353 <span class="k">section</span>
349354 </a>
350355 </li>
351-<li data-k="section actions">
356+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
352357 <a href="/files/actions" tabindex="-1">
353358 <span class="lbl">actions</span>
354359 <span class="k">section</span>
355360 </a>
356361 </li>
357-<li data-k="branch feature/x">
362+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
358363 <a href="/files/tree/feature/x" tabindex="-1">
359364 <span class="lbl">feature/x</span>
360365 <span class="k">branch</span>
361366 </a>
362367 </li>
363-<li data-k="branch main">
368+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
364369 <a href="/files/tree/main" tabindex="-1">
365370 <span class="lbl">main</span>
366371 <span class="k">branch</span>
367372 </a>
368373 </li>
369-<li data-k="tag v1.0">
374+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
370375 <a href="/files/tree/v1.0" tabindex="-1">
371376 <span class="lbl">v1.0</span>
372377 <span class="k">tag</span>
373378 </a>
374379 </li>
380+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
381+<a href="#" data-fire="keys" tabindex="-1">
382+<span class="lbl">turn single-key shortcuts off</span>
383+<span class="k">action</span>
384+</a>
385+</li>
375386 </ul>
376387 <p class="panel-foot">
377388 <span>
@@ -410,7 +421,8 @@
410421 k-permalink from:body -> ermite.permalink();
411422 k-merge from:body -> window.ermiteMR?.openMerge();
412423 k-read from:body -> window.ermiteMR?.markRead();
413- k-gf from:body -> window.ermiteMR?.focusFiles()">
424+ k-gf from:body -> window.ermiteMR?.focusFiles();
425+ k-keys from:body -> ermite.toggleKeys()">
414426 </div>
415427 </body>
416428 </html>
M internal/app/testdata/web/blob-image.html +25 -13
@@ -155,6 +155,11 @@
155155 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
156156 <kbd>esc</kbd> close</button>
157157 </div>
158+<div class="keys-pref">
159+<label>
160+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
161+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
162+</div>
158163 <dl class="keys">
159164 <div>
160165 <dt>
@@ -308,72 +313,78 @@
308313 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
309314 <div class="panel-head">
310315 <label for="cmd-q" class="sr">go to</label>
311-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
316+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
312317 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
313318 <kbd>esc</kbd>
314319 </button>
315320 </div>
316-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
317-<li data-k="go home" data-cursor>
321+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
322+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
318323 <a href="/" tabindex="-1">
319324 <span class="lbl">home</span>
320325 <span class="k">go</span>
321326 </a>
322327 </li>
323-<li data-k="section summary">
328+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
324329 <a href="/files" tabindex="-1">
325330 <span class="lbl">summary</span>
326331 <span class="k">section</span>
327332 </a>
328333 </li>
329-<li data-k="section files">
334+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
330335 <a href="/files/tree/main" tabindex="-1">
331336 <span class="lbl">files</span>
332337 <span class="k">section</span>
333338 </a>
334339 </li>
335-<li data-k="section commits">
340+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
336341 <a href="/files/commits/main" tabindex="-1">
337342 <span class="lbl">commits</span>
338343 <span class="k">section</span>
339344 </a>
340345 </li>
341-<li data-k="section branches &amp; tags">
346+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
342347 <a href="/files/refs" tabindex="-1">
343348 <span class="lbl">branches &amp; tags</span>
344349 <span class="k">section</span>
345350 </a>
346351 </li>
347-<li data-k="section merge requests">
352+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
348353 <a href="/files/merge-requests" tabindex="-1">
349354 <span class="lbl">merge requests</span>
350355 <span class="k">section</span>
351356 </a>
352357 </li>
353-<li data-k="section actions">
358+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
354359 <a href="/files/actions" tabindex="-1">
355360 <span class="lbl">actions</span>
356361 <span class="k">section</span>
357362 </a>
358363 </li>
359-<li data-k="branch feature/x">
364+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
360365 <a href="/files/tree/feature/x" tabindex="-1">
361366 <span class="lbl">feature/x</span>
362367 <span class="k">branch</span>
363368 </a>
364369 </li>
365-<li data-k="branch main">
370+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
366371 <a href="/files/tree/main" tabindex="-1">
367372 <span class="lbl">main</span>
368373 <span class="k">branch</span>
369374 </a>
370375 </li>
371-<li data-k="tag v1.0">
376+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
372377 <a href="/files/tree/v1.0" tabindex="-1">
373378 <span class="lbl">v1.0</span>
374379 <span class="k">tag</span>
375380 </a>
376381 </li>
382+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
383+<a href="#" data-fire="keys" tabindex="-1">
384+<span class="lbl">turn single-key shortcuts off</span>
385+<span class="k">action</span>
386+</a>
387+</li>
377388 </ul>
378389 <p class="panel-foot">
379390 <span>
@@ -412,7 +423,8 @@
412423 k-permalink from:body -> ermite.permalink();
413424 k-merge from:body -> window.ermiteMR?.openMerge();
414425 k-read from:body -> window.ermiteMR?.markRead();
415- k-gf from:body -> window.ermiteMR?.focusFiles()">
426+ k-gf from:body -> window.ermiteMR?.focusFiles();
427+ k-keys from:body -> ermite.toggleKeys()">
416428 </div>
417429 </body>
418430 </html>
M internal/app/testdata/web/blob-markdown.html +25 -13
@@ -220,6 +220,11 @@
220220 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
221221 <kbd>esc</kbd> close</button>
222222 </div>
223+<div class="keys-pref">
224+<label>
225+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
226+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
227+</div>
223228 <dl class="keys">
224229 <div>
225230 <dt>
@@ -373,72 +378,78 @@
373378 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
374379 <div class="panel-head">
375380 <label for="cmd-q" class="sr">go to</label>
376-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
381+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
377382 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
378383 <kbd>esc</kbd>
379384 </button>
380385 </div>
381-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
382-<li data-k="go home" data-cursor>
386+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
387+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
383388 <a href="/" tabindex="-1">
384389 <span class="lbl">home</span>
385390 <span class="k">go</span>
386391 </a>
387392 </li>
388-<li data-k="section summary">
393+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
389394 <a href="/files" tabindex="-1">
390395 <span class="lbl">summary</span>
391396 <span class="k">section</span>
392397 </a>
393398 </li>
394-<li data-k="section files">
399+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
395400 <a href="/files/tree/main" tabindex="-1">
396401 <span class="lbl">files</span>
397402 <span class="k">section</span>
398403 </a>
399404 </li>
400-<li data-k="section commits">
405+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
401406 <a href="/files/commits/main" tabindex="-1">
402407 <span class="lbl">commits</span>
403408 <span class="k">section</span>
404409 </a>
405410 </li>
406-<li data-k="section branches &amp; tags">
411+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
407412 <a href="/files/refs" tabindex="-1">
408413 <span class="lbl">branches &amp; tags</span>
409414 <span class="k">section</span>
410415 </a>
411416 </li>
412-<li data-k="section merge requests">
417+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
413418 <a href="/files/merge-requests" tabindex="-1">
414419 <span class="lbl">merge requests</span>
415420 <span class="k">section</span>
416421 </a>
417422 </li>
418-<li data-k="section actions">
423+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
419424 <a href="/files/actions" tabindex="-1">
420425 <span class="lbl">actions</span>
421426 <span class="k">section</span>
422427 </a>
423428 </li>
424-<li data-k="branch feature/x">
429+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
425430 <a href="/files/tree/feature/x" tabindex="-1">
426431 <span class="lbl">feature/x</span>
427432 <span class="k">branch</span>
428433 </a>
429434 </li>
430-<li data-k="branch main">
435+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
431436 <a href="/files/tree/main" tabindex="-1">
432437 <span class="lbl">main</span>
433438 <span class="k">branch</span>
434439 </a>
435440 </li>
436-<li data-k="tag v1.0">
441+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
437442 <a href="/files/tree/v1.0" tabindex="-1">
438443 <span class="lbl">v1.0</span>
439444 <span class="k">tag</span>
440445 </a>
441446 </li>
447+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
448+<a href="#" data-fire="keys" tabindex="-1">
449+<span class="lbl">turn single-key shortcuts off</span>
450+<span class="k">action</span>
451+</a>
452+</li>
442453 </ul>
443454 <p class="panel-foot">
444455 <span>
@@ -477,7 +488,8 @@
477488 k-permalink from:body -> ermite.permalink();
478489 k-merge from:body -> window.ermiteMR?.openMerge();
479490 k-read from:body -> window.ermiteMR?.markRead();
480- k-gf from:body -> window.ermiteMR?.focusFiles()">
491+ k-gf from:body -> window.ermiteMR?.focusFiles();
492+ k-keys from:body -> ermite.toggleKeys()">
481493 </div>
482494 </body>
483495 </html>
M internal/app/testdata/web/blob-svg.html +25 -13
@@ -178,6 +178,11 @@
178178 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
179179 <kbd>esc</kbd> close</button>
180180 </div>
181+<div class="keys-pref">
182+<label>
183+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
184+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
185+</div>
181186 <dl class="keys">
182187 <div>
183188 <dt>
@@ -331,72 +336,78 @@
331336 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
332337 <div class="panel-head">
333338 <label for="cmd-q" class="sr">go to</label>
334-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
339+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
335340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
336341 <kbd>esc</kbd>
337342 </button>
338343 </div>
339-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
340-<li data-k="go home" data-cursor>
344+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
345+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
341346 <a href="/" tabindex="-1">
342347 <span class="lbl">home</span>
343348 <span class="k">go</span>
344349 </a>
345350 </li>
346-<li data-k="section summary">
351+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
347352 <a href="/files" tabindex="-1">
348353 <span class="lbl">summary</span>
349354 <span class="k">section</span>
350355 </a>
351356 </li>
352-<li data-k="section files">
357+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
353358 <a href="/files/tree/main" tabindex="-1">
354359 <span class="lbl">files</span>
355360 <span class="k">section</span>
356361 </a>
357362 </li>
358-<li data-k="section commits">
363+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
359364 <a href="/files/commits/main" tabindex="-1">
360365 <span class="lbl">commits</span>
361366 <span class="k">section</span>
362367 </a>
363368 </li>
364-<li data-k="section branches &amp; tags">
369+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
365370 <a href="/files/refs" tabindex="-1">
366371 <span class="lbl">branches &amp; tags</span>
367372 <span class="k">section</span>
368373 </a>
369374 </li>
370-<li data-k="section merge requests">
375+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
371376 <a href="/files/merge-requests" tabindex="-1">
372377 <span class="lbl">merge requests</span>
373378 <span class="k">section</span>
374379 </a>
375380 </li>
376-<li data-k="section actions">
381+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
377382 <a href="/files/actions" tabindex="-1">
378383 <span class="lbl">actions</span>
379384 <span class="k">section</span>
380385 </a>
381386 </li>
382-<li data-k="branch feature/x">
387+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
383388 <a href="/files/tree/feature/x" tabindex="-1">
384389 <span class="lbl">feature/x</span>
385390 <span class="k">branch</span>
386391 </a>
387392 </li>
388-<li data-k="branch main">
393+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
389394 <a href="/files/tree/main" tabindex="-1">
390395 <span class="lbl">main</span>
391396 <span class="k">branch</span>
392397 </a>
393398 </li>
394-<li data-k="tag v1.0">
399+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
395400 <a href="/files/tree/v1.0" tabindex="-1">
396401 <span class="lbl">v1.0</span>
397402 <span class="k">tag</span>
398403 </a>
399404 </li>
405+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
406+<a href="#" data-fire="keys" tabindex="-1">
407+<span class="lbl">turn single-key shortcuts off</span>
408+<span class="k">action</span>
409+</a>
410+</li>
400411 </ul>
401412 <p class="panel-foot">
402413 <span>
@@ -435,7 +446,8 @@
435446 k-permalink from:body -> ermite.permalink();
436447 k-merge from:body -> window.ermiteMR?.openMerge();
437448 k-read from:body -> window.ermiteMR?.markRead();
438- k-gf from:body -> window.ermiteMR?.focusFiles()">
449+ k-gf from:body -> window.ermiteMR?.focusFiles();
450+ k-keys from:body -> ermite.toggleKeys()">
439451 </div>
440452 </body>
441453 </html>
M internal/app/testdata/web/blob-text.html +25 -13
@@ -173,6 +173,11 @@
173173 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
174174 <kbd>esc</kbd> close</button>
175175 </div>
176+<div class="keys-pref">
177+<label>
178+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
179+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
180+</div>
176181 <dl class="keys">
177182 <div>
178183 <dt>
@@ -326,72 +331,78 @@
326331 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
327332 <div class="panel-head">
328333 <label for="cmd-q" class="sr">go to</label>
329-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
334+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
330335 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
331336 <kbd>esc</kbd>
332337 </button>
333338 </div>
334-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
335-<li data-k="go home" data-cursor>
339+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
340+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
336341 <a href="/" tabindex="-1">
337342 <span class="lbl">home</span>
338343 <span class="k">go</span>
339344 </a>
340345 </li>
341-<li data-k="section summary">
346+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
342347 <a href="/files" tabindex="-1">
343348 <span class="lbl">summary</span>
344349 <span class="k">section</span>
345350 </a>
346351 </li>
347-<li data-k="section files">
352+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
348353 <a href="/files/tree/main" tabindex="-1">
349354 <span class="lbl">files</span>
350355 <span class="k">section</span>
351356 </a>
352357 </li>
353-<li data-k="section commits">
358+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
354359 <a href="/files/commits/main" tabindex="-1">
355360 <span class="lbl">commits</span>
356361 <span class="k">section</span>
357362 </a>
358363 </li>
359-<li data-k="section branches &amp; tags">
364+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
360365 <a href="/files/refs" tabindex="-1">
361366 <span class="lbl">branches &amp; tags</span>
362367 <span class="k">section</span>
363368 </a>
364369 </li>
365-<li data-k="section merge requests">
370+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
366371 <a href="/files/merge-requests" tabindex="-1">
367372 <span class="lbl">merge requests</span>
368373 <span class="k">section</span>
369374 </a>
370375 </li>
371-<li data-k="section actions">
376+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
372377 <a href="/files/actions" tabindex="-1">
373378 <span class="lbl">actions</span>
374379 <span class="k">section</span>
375380 </a>
376381 </li>
377-<li data-k="branch feature/x">
382+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
378383 <a href="/files/tree/feature/x" tabindex="-1">
379384 <span class="lbl">feature/x</span>
380385 <span class="k">branch</span>
381386 </a>
382387 </li>
383-<li data-k="branch main">
388+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
384389 <a href="/files/tree/main" tabindex="-1">
385390 <span class="lbl">main</span>
386391 <span class="k">branch</span>
387392 </a>
388393 </li>
389-<li data-k="tag v1.0">
394+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
390395 <a href="/files/tree/v1.0" tabindex="-1">
391396 <span class="lbl">v1.0</span>
392397 <span class="k">tag</span>
393398 </a>
394399 </li>
400+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
401+<a href="#" data-fire="keys" tabindex="-1">
402+<span class="lbl">turn single-key shortcuts off</span>
403+<span class="k">action</span>
404+</a>
405+</li>
395406 </ul>
396407 <p class="panel-foot">
397408 <span>
@@ -430,7 +441,8 @@
430441 k-permalink from:body -> ermite.permalink();
431442 k-merge from:body -> window.ermiteMR?.openMerge();
432443 k-read from:body -> window.ermiteMR?.markRead();
433- k-gf from:body -> window.ermiteMR?.focusFiles()">
444+ k-gf from:body -> window.ermiteMR?.focusFiles();
445+ k-keys from:body -> ermite.toggleKeys()">
434446 </div>
435447 </body>
436448 </html>
M internal/app/testdata/web/blob-too-large.html +25 -13
@@ -153,6 +153,11 @@
153153 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
154154 <kbd>esc</kbd> close</button>
155155 </div>
156+<div class="keys-pref">
157+<label>
158+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
159+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
160+</div>
156161 <dl class="keys">
157162 <div>
158163 <dt>
@@ -306,72 +311,78 @@
306311 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
307312 <div class="panel-head">
308313 <label for="cmd-q" class="sr">go to</label>
309-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
314+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
310315 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
311316 <kbd>esc</kbd>
312317 </button>
313318 </div>
314-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
315-<li data-k="go home" data-cursor>
319+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
320+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
316321 <a href="/" tabindex="-1">
317322 <span class="lbl">home</span>
318323 <span class="k">go</span>
319324 </a>
320325 </li>
321-<li data-k="section summary">
326+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
322327 <a href="/files" tabindex="-1">
323328 <span class="lbl">summary</span>
324329 <span class="k">section</span>
325330 </a>
326331 </li>
327-<li data-k="section files">
332+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
328333 <a href="/files/tree/main" tabindex="-1">
329334 <span class="lbl">files</span>
330335 <span class="k">section</span>
331336 </a>
332337 </li>
333-<li data-k="section commits">
338+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
334339 <a href="/files/commits/main" tabindex="-1">
335340 <span class="lbl">commits</span>
336341 <span class="k">section</span>
337342 </a>
338343 </li>
339-<li data-k="section branches &amp; tags">
344+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
340345 <a href="/files/refs" tabindex="-1">
341346 <span class="lbl">branches &amp; tags</span>
342347 <span class="k">section</span>
343348 </a>
344349 </li>
345-<li data-k="section merge requests">
350+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
346351 <a href="/files/merge-requests" tabindex="-1">
347352 <span class="lbl">merge requests</span>
348353 <span class="k">section</span>
349354 </a>
350355 </li>
351-<li data-k="section actions">
356+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
352357 <a href="/files/actions" tabindex="-1">
353358 <span class="lbl">actions</span>
354359 <span class="k">section</span>
355360 </a>
356361 </li>
357-<li data-k="branch feature/x">
362+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
358363 <a href="/files/tree/feature/x" tabindex="-1">
359364 <span class="lbl">feature/x</span>
360365 <span class="k">branch</span>
361366 </a>
362367 </li>
363-<li data-k="branch main">
368+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
364369 <a href="/files/tree/main" tabindex="-1">
365370 <span class="lbl">main</span>
366371 <span class="k">branch</span>
367372 </a>
368373 </li>
369-<li data-k="tag v1.0">
374+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
370375 <a href="/files/tree/v1.0" tabindex="-1">
371376 <span class="lbl">v1.0</span>
372377 <span class="k">tag</span>
373378 </a>
374379 </li>
380+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
381+<a href="#" data-fire="keys" tabindex="-1">
382+<span class="lbl">turn single-key shortcuts off</span>
383+<span class="k">action</span>
384+</a>
385+</li>
375386 </ul>
376387 <p class="panel-foot">
377388 <span>
@@ -410,7 +421,8 @@
410421 k-permalink from:body -> ermite.permalink();
411422 k-merge from:body -> window.ermiteMR?.openMerge();
412423 k-read from:body -> window.ermiteMR?.markRead();
413- k-gf from:body -> window.ermiteMR?.focusFiles()">
424+ k-gf from:body -> window.ermiteMR?.focusFiles();
425+ k-keys from:body -> ermite.toggleKeys()">
414426 </div>
415427 </body>
416428 </html>
M internal/app/testdata/web/blob-trojan.html +25 -13
@@ -220,6 +220,11 @@
220220 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
221221 <kbd>esc</kbd> close</button>
222222 </div>
223+<div class="keys-pref">
224+<label>
225+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
226+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
227+</div>
223228 <dl class="keys">
224229 <div>
225230 <dt>
@@ -373,72 +378,78 @@
373378 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
374379 <div class="panel-head">
375380 <label for="cmd-q" class="sr">go to</label>
376-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
381+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
377382 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
378383 <kbd>esc</kbd>
379384 </button>
380385 </div>
381-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
382-<li data-k="go home" data-cursor>
386+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
387+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
383388 <a href="/" tabindex="-1">
384389 <span class="lbl">home</span>
385390 <span class="k">go</span>
386391 </a>
387392 </li>
388-<li data-k="section summary">
393+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
389394 <a href="/files" tabindex="-1">
390395 <span class="lbl">summary</span>
391396 <span class="k">section</span>
392397 </a>
393398 </li>
394-<li data-k="section files">
399+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
395400 <a href="/files/tree/main" tabindex="-1">
396401 <span class="lbl">files</span>
397402 <span class="k">section</span>
398403 </a>
399404 </li>
400-<li data-k="section commits">
405+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
401406 <a href="/files/commits/main" tabindex="-1">
402407 <span class="lbl">commits</span>
403408 <span class="k">section</span>
404409 </a>
405410 </li>
406-<li data-k="section branches &amp; tags">
411+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
407412 <a href="/files/refs" tabindex="-1">
408413 <span class="lbl">branches &amp; tags</span>
409414 <span class="k">section</span>
410415 </a>
411416 </li>
412-<li data-k="section merge requests">
417+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
413418 <a href="/files/merge-requests" tabindex="-1">
414419 <span class="lbl">merge requests</span>
415420 <span class="k">section</span>
416421 </a>
417422 </li>
418-<li data-k="section actions">
423+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
419424 <a href="/files/actions" tabindex="-1">
420425 <span class="lbl">actions</span>
421426 <span class="k">section</span>
422427 </a>
423428 </li>
424-<li data-k="branch feature/x">
429+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
425430 <a href="/files/tree/feature/x" tabindex="-1">
426431 <span class="lbl">feature/x</span>
427432 <span class="k">branch</span>
428433 </a>
429434 </li>
430-<li data-k="branch main">
435+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
431436 <a href="/files/tree/main" tabindex="-1">
432437 <span class="lbl">main</span>
433438 <span class="k">branch</span>
434439 </a>
435440 </li>
436-<li data-k="tag v1.0">
441+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
437442 <a href="/files/tree/v1.0" tabindex="-1">
438443 <span class="lbl">v1.0</span>
439444 <span class="k">tag</span>
440445 </a>
441446 </li>
447+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
448+<a href="#" data-fire="keys" tabindex="-1">
449+<span class="lbl">turn single-key shortcuts off</span>
450+<span class="k">action</span>
451+</a>
452+</li>
442453 </ul>
443454 <p class="panel-foot">
444455 <span>
@@ -477,7 +488,8 @@
477488 k-permalink from:body -> ermite.permalink();
478489 k-merge from:body -> window.ermiteMR?.openMerge();
479490 k-read from:body -> window.ermiteMR?.markRead();
480- k-gf from:body -> window.ermiteMR?.focusFiles()">
491+ k-gf from:body -> window.ermiteMR?.focusFiles();
492+ k-keys from:body -> ermite.toggleKeys()">
481493 </div>
482494 </body>
483495 </html>
M internal/app/testdata/web/commit-big.html +26 -14
@@ -117,7 +117,7 @@
117117 </dd>
118118 </dl>
119119 </header>
120-<div class="diff-tools" role="toolbar" aria-label="Diff view">
120+<div class="diff-tools" role="group" aria-label="Diff view">
121121 <span class="dim">1 file · <span class="plus">+2600</span>
122122 <span class="minus">-0</span>
123123 </span>
@@ -191,6 +191,11 @@
191191 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
192192 <kbd>esc</kbd> close</button>
193193 </div>
194+<div class="keys-pref">
195+<label>
196+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
197+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
198+</div>
194199 <dl class="keys">
195200 <div>
196201 <dt>
@@ -344,72 +349,78 @@
344349 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
345350 <div class="panel-head">
346351 <label for="cmd-q" class="sr">go to</label>
347-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
352+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
348353 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
349354 <kbd>esc</kbd>
350355 </button>
351356 </div>
352-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
353-<li data-k="go home" data-cursor>
357+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
358+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
354359 <a href="/" tabindex="-1">
355360 <span class="lbl">home</span>
356361 <span class="k">go</span>
357362 </a>
358363 </li>
359-<li data-k="section summary">
364+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
360365 <a href="/diffs" tabindex="-1">
361366 <span class="lbl">summary</span>
362367 <span class="k">section</span>
363368 </a>
364369 </li>
365-<li data-k="section files">
370+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
366371 <a href="/diffs/tree/main" tabindex="-1">
367372 <span class="lbl">files</span>
368373 <span class="k">section</span>
369374 </a>
370375 </li>
371-<li data-k="section commits">
376+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
372377 <a href="/diffs/commits/main" tabindex="-1">
373378 <span class="lbl">commits</span>
374379 <span class="k">section</span>
375380 </a>
376381 </li>
377-<li data-k="section branches &amp; tags">
382+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
378383 <a href="/diffs/refs" tabindex="-1">
379384 <span class="lbl">branches &amp; tags</span>
380385 <span class="k">section</span>
381386 </a>
382387 </li>
383-<li data-k="section merge requests">
388+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
384389 <a href="/diffs/merge-requests" tabindex="-1">
385390 <span class="lbl">merge requests</span>
386391 <span class="k">section</span>
387392 </a>
388393 </li>
389-<li data-k="section actions">
394+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
390395 <a href="/diffs/actions" tabindex="-1">
391396 <span class="lbl">actions</span>
392397 <span class="k">section</span>
393398 </a>
394399 </li>
395-<li data-k="branch main">
400+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
396401 <a href="/diffs/tree/main" tabindex="-1">
397402 <span class="lbl">main</span>
398403 <span class="k">branch</span>
399404 </a>
400405 </li>
401-<li data-k="branch side">
406+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
402407 <a href="/diffs/tree/side" tabindex="-1">
403408 <span class="lbl">side</span>
404409 <span class="k">branch</span>
405410 </a>
406411 </li>
407-<li data-k="tag v1.0.0">
412+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
408413 <a href="/diffs/tree/v1.0.0" tabindex="-1">
409414 <span class="lbl">v1.0.0</span>
410415 <span class="k">tag</span>
411416 </a>
412417 </li>
418+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
419+<a href="#" data-fire="keys" tabindex="-1">
420+<span class="lbl">turn single-key shortcuts off</span>
421+<span class="k">action</span>
422+</a>
423+</li>
413424 </ul>
414425 <p class="panel-foot">
415426 <span>
@@ -448,7 +459,8 @@
448459 k-permalink from:body -> ermite.permalink();
449460 k-merge from:body -> window.ermiteMR?.openMerge();
450461 k-read from:body -> window.ermiteMR?.markRead();
451- k-gf from:body -> window.ermiteMR?.focusFiles()">
462+ k-gf from:body -> window.ermiteMR?.focusFiles();
463+ k-keys from:body -> ermite.toggleKeys()">
452464 </div>
453465 </body>
454466 </html>
M internal/app/testdata/web/commit-binary.html +26 -14
@@ -113,7 +113,7 @@
113113 </dd>
114114 </dl>
115115 </header>
116-<div class="diff-tools" role="toolbar" aria-label="Diff view">
116+<div class="diff-tools" role="group" aria-label="Diff view">
117117 <span class="dim">1 file · <span class="plus">+0</span>
118118 <span class="minus">-0</span>
119119 </span>
@@ -183,6 +183,11 @@
183183 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
184184 <kbd>esc</kbd> close</button>
185185 </div>
186+<div class="keys-pref">
187+<label>
188+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
189+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
190+</div>
186191 <dl class="keys">
187192 <div>
188193 <dt>
@@ -336,72 +341,78 @@
336341 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
337342 <div class="panel-head">
338343 <label for="cmd-q" class="sr">go to</label>
339-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
344+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
340345 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
341346 <kbd>esc</kbd>
342347 </button>
343348 </div>
344-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
345-<li data-k="go home" data-cursor>
349+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
350+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
346351 <a href="/" tabindex="-1">
347352 <span class="lbl">home</span>
348353 <span class="k">go</span>
349354 </a>
350355 </li>
351-<li data-k="section summary">
356+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
352357 <a href="/diffs" tabindex="-1">
353358 <span class="lbl">summary</span>
354359 <span class="k">section</span>
355360 </a>
356361 </li>
357-<li data-k="section files">
362+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
358363 <a href="/diffs/tree/main" tabindex="-1">
359364 <span class="lbl">files</span>
360365 <span class="k">section</span>
361366 </a>
362367 </li>
363-<li data-k="section commits">
368+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
364369 <a href="/diffs/commits/main" tabindex="-1">
365370 <span class="lbl">commits</span>
366371 <span class="k">section</span>
367372 </a>
368373 </li>
369-<li data-k="section branches &amp; tags">
374+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
370375 <a href="/diffs/refs" tabindex="-1">
371376 <span class="lbl">branches &amp; tags</span>
372377 <span class="k">section</span>
373378 </a>
374379 </li>
375-<li data-k="section merge requests">
380+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
376381 <a href="/diffs/merge-requests" tabindex="-1">
377382 <span class="lbl">merge requests</span>
378383 <span class="k">section</span>
379384 </a>
380385 </li>
381-<li data-k="section actions">
386+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
382387 <a href="/diffs/actions" tabindex="-1">
383388 <span class="lbl">actions</span>
384389 <span class="k">section</span>
385390 </a>
386391 </li>
387-<li data-k="branch main">
392+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
388393 <a href="/diffs/tree/main" tabindex="-1">
389394 <span class="lbl">main</span>
390395 <span class="k">branch</span>
391396 </a>
392397 </li>
393-<li data-k="branch side">
398+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
394399 <a href="/diffs/tree/side" tabindex="-1">
395400 <span class="lbl">side</span>
396401 <span class="k">branch</span>
397402 </a>
398403 </li>
399-<li data-k="tag v1.0.0">
404+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
400405 <a href="/diffs/tree/v1.0.0" tabindex="-1">
401406 <span class="lbl">v1.0.0</span>
402407 <span class="k">tag</span>
403408 </a>
404409 </li>
410+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
411+<a href="#" data-fire="keys" tabindex="-1">
412+<span class="lbl">turn single-key shortcuts off</span>
413+<span class="k">action</span>
414+</a>
415+</li>
405416 </ul>
406417 <p class="panel-foot">
407418 <span>
@@ -440,7 +451,8 @@
440451 k-permalink from:body -> ermite.permalink();
441452 k-merge from:body -> window.ermiteMR?.openMerge();
442453 k-read from:body -> window.ermiteMR?.markRead();
443- k-gf from:body -> window.ermiteMR?.focusFiles()">
454+ k-gf from:body -> window.ermiteMR?.focusFiles();
455+ k-keys from:body -> ermite.toggleKeys()">
444456 </div>
445457 </body>
446458 </html>
M internal/app/testdata/web/commit-merge.html +26 -14
@@ -112,7 +112,7 @@
112112 <a href="/diffs/commit/SHA40">SHA7</a> + <a href="/diffs/commit/SHA40">SHA7</a> · diff against the first parent</dd>
113113 </dl>
114114 </header>
115-<div class="diff-tools" role="toolbar" aria-label="Diff view">
115+<div class="diff-tools" role="group" aria-label="Diff view">
116116 <span class="dim">1 file · <span class="plus">+1</span>
117117 <span class="minus">-0</span>
118118 </span>
@@ -225,6 +225,11 @@
225225 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
226226 <kbd>esc</kbd> close</button>
227227 </div>
228+<div class="keys-pref">
229+<label>
230+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
231+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
232+</div>
228233 <dl class="keys">
229234 <div>
230235 <dt>
@@ -378,72 +383,78 @@
378383 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
379384 <div class="panel-head">
380385 <label for="cmd-q" class="sr">go to</label>
381-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
386+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
382387 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
383388 <kbd>esc</kbd>
384389 </button>
385390 </div>
386-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
387-<li data-k="go home" data-cursor>
391+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
392+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
388393 <a href="/" tabindex="-1">
389394 <span class="lbl">home</span>
390395 <span class="k">go</span>
391396 </a>
392397 </li>
393-<li data-k="section summary">
398+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
394399 <a href="/diffs" tabindex="-1">
395400 <span class="lbl">summary</span>
396401 <span class="k">section</span>
397402 </a>
398403 </li>
399-<li data-k="section files">
404+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
400405 <a href="/diffs/tree/main" tabindex="-1">
401406 <span class="lbl">files</span>
402407 <span class="k">section</span>
403408 </a>
404409 </li>
405-<li data-k="section commits">
410+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
406411 <a href="/diffs/commits/main" tabindex="-1">
407412 <span class="lbl">commits</span>
408413 <span class="k">section</span>
409414 </a>
410415 </li>
411-<li data-k="section branches &amp; tags">
416+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
412417 <a href="/diffs/refs" tabindex="-1">
413418 <span class="lbl">branches &amp; tags</span>
414419 <span class="k">section</span>
415420 </a>
416421 </li>
417-<li data-k="section merge requests">
422+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
418423 <a href="/diffs/merge-requests" tabindex="-1">
419424 <span class="lbl">merge requests</span>
420425 <span class="k">section</span>
421426 </a>
422427 </li>
423-<li data-k="section actions">
428+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
424429 <a href="/diffs/actions" tabindex="-1">
425430 <span class="lbl">actions</span>
426431 <span class="k">section</span>
427432 </a>
428433 </li>
429-<li data-k="branch main">
434+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
430435 <a href="/diffs/tree/main" tabindex="-1">
431436 <span class="lbl">main</span>
432437 <span class="k">branch</span>
433438 </a>
434439 </li>
435-<li data-k="branch side">
440+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
436441 <a href="/diffs/tree/side" tabindex="-1">
437442 <span class="lbl">side</span>
438443 <span class="k">branch</span>
439444 </a>
440445 </li>
441-<li data-k="tag v1.0.0">
446+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
442447 <a href="/diffs/tree/v1.0.0" tabindex="-1">
443448 <span class="lbl">v1.0.0</span>
444449 <span class="k">tag</span>
445450 </a>
446451 </li>
452+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
453+<a href="#" data-fire="keys" tabindex="-1">
454+<span class="lbl">turn single-key shortcuts off</span>
455+<span class="k">action</span>
456+</a>
457+</li>
447458 </ul>
448459 <p class="panel-foot">
449460 <span>
@@ -482,7 +493,8 @@
482493 k-permalink from:body -> ermite.permalink();
483494 k-merge from:body -> window.ermiteMR?.openMerge();
484495 k-read from:body -> window.ermiteMR?.markRead();
485- k-gf from:body -> window.ermiteMR?.focusFiles()">
496+ k-gf from:body -> window.ermiteMR?.focusFiles();
497+ k-keys from:body -> ermite.toggleKeys()">
486498 </div>
487499 </body>
488500 </html>
M internal/app/testdata/web/commit-modify-split.html +26 -14
@@ -119,7 +119,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
119119 </dd>
120120 </dl>
121121 </header>
122-<div class="diff-tools" role="toolbar" aria-label="Diff view">
122+<div class="diff-tools" role="group" aria-label="Diff view">
123123 <span class="dim">2 files · <span class="plus">+2</span>
124124 <span class="minus">-1</span>
125125 </span>
@@ -348,6 +348,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348348 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
349349 <kbd>esc</kbd> close</button>
350350 </div>
351+<div class="keys-pref">
352+<label>
353+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
354+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
355+</div>
351356 <dl class="keys">
352357 <div>
353358 <dt>
@@ -501,72 +506,78 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
501506 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
502507 <div class="panel-head">
503508 <label for="cmd-q" class="sr">go to</label>
504-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
509+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
505510 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
506511 <kbd>esc</kbd>
507512 </button>
508513 </div>
509-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
510-<li data-k="go home" data-cursor>
514+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
515+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
511516 <a href="/" tabindex="-1">
512517 <span class="lbl">home</span>
513518 <span class="k">go</span>
514519 </a>
515520 </li>
516-<li data-k="section summary">
521+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
517522 <a href="/diffs" tabindex="-1">
518523 <span class="lbl">summary</span>
519524 <span class="k">section</span>
520525 </a>
521526 </li>
522-<li data-k="section files">
527+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
523528 <a href="/diffs/tree/main" tabindex="-1">
524529 <span class="lbl">files</span>
525530 <span class="k">section</span>
526531 </a>
527532 </li>
528-<li data-k="section commits">
533+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
529534 <a href="/diffs/commits/main" tabindex="-1">
530535 <span class="lbl">commits</span>
531536 <span class="k">section</span>
532537 </a>
533538 </li>
534-<li data-k="section branches &amp; tags">
539+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
535540 <a href="/diffs/refs" tabindex="-1">
536541 <span class="lbl">branches &amp; tags</span>
537542 <span class="k">section</span>
538543 </a>
539544 </li>
540-<li data-k="section merge requests">
545+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
541546 <a href="/diffs/merge-requests" tabindex="-1">
542547 <span class="lbl">merge requests</span>
543548 <span class="k">section</span>
544549 </a>
545550 </li>
546-<li data-k="section actions">
551+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
547552 <a href="/diffs/actions" tabindex="-1">
548553 <span class="lbl">actions</span>
549554 <span class="k">section</span>
550555 </a>
551556 </li>
552-<li data-k="branch main">
557+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
553558 <a href="/diffs/tree/main" tabindex="-1">
554559 <span class="lbl">main</span>
555560 <span class="k">branch</span>
556561 </a>
557562 </li>
558-<li data-k="branch side">
563+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
559564 <a href="/diffs/tree/side" tabindex="-1">
560565 <span class="lbl">side</span>
561566 <span class="k">branch</span>
562567 </a>
563568 </li>
564-<li data-k="tag v1.0.0">
569+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
565570 <a href="/diffs/tree/v1.0.0" tabindex="-1">
566571 <span class="lbl">v1.0.0</span>
567572 <span class="k">tag</span>
568573 </a>
569574 </li>
575+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
576+<a href="#" data-fire="keys" tabindex="-1">
577+<span class="lbl">turn single-key shortcuts off</span>
578+<span class="k">action</span>
579+</a>
580+</li>
570581 </ul>
571582 <p class="panel-foot">
572583 <span>
@@ -605,7 +616,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
605616 k-permalink from:body -> ermite.permalink();
606617 k-merge from:body -> window.ermiteMR?.openMerge();
607618 k-read from:body -> window.ermiteMR?.markRead();
608- k-gf from:body -> window.ermiteMR?.focusFiles()">
619+ k-gf from:body -> window.ermiteMR?.focusFiles();
620+ k-keys from:body -> ermite.toggleKeys()">
609621 </div>
610622 </body>
611623 </html>
M internal/app/testdata/web/commit-modify.html +26 -14
@@ -119,7 +119,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
119119 </dd>
120120 </dl>
121121 </header>
122-<div class="diff-tools" role="toolbar" aria-label="Diff view">
122+<div class="diff-tools" role="group" aria-label="Diff view">
123123 <span class="dim">2 files · <span class="plus">+2</span>
124124 <span class="minus">-1</span>
125125 </span>
@@ -348,6 +348,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348348 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
349349 <kbd>esc</kbd> close</button>
350350 </div>
351+<div class="keys-pref">
352+<label>
353+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
354+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
355+</div>
351356 <dl class="keys">
352357 <div>
353358 <dt>
@@ -501,72 +506,78 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
501506 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
502507 <div class="panel-head">
503508 <label for="cmd-q" class="sr">go to</label>
504-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
509+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
505510 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
506511 <kbd>esc</kbd>
507512 </button>
508513 </div>
509-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
510-<li data-k="go home" data-cursor>
514+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
515+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
511516 <a href="/" tabindex="-1">
512517 <span class="lbl">home</span>
513518 <span class="k">go</span>
514519 </a>
515520 </li>
516-<li data-k="section summary">
521+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
517522 <a href="/diffs" tabindex="-1">
518523 <span class="lbl">summary</span>
519524 <span class="k">section</span>
520525 </a>
521526 </li>
522-<li data-k="section files">
527+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
523528 <a href="/diffs/tree/main" tabindex="-1">
524529 <span class="lbl">files</span>
525530 <span class="k">section</span>
526531 </a>
527532 </li>
528-<li data-k="section commits">
533+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
529534 <a href="/diffs/commits/main" tabindex="-1">
530535 <span class="lbl">commits</span>
531536 <span class="k">section</span>
532537 </a>
533538 </li>
534-<li data-k="section branches &amp; tags">
539+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
535540 <a href="/diffs/refs" tabindex="-1">
536541 <span class="lbl">branches &amp; tags</span>
537542 <span class="k">section</span>
538543 </a>
539544 </li>
540-<li data-k="section merge requests">
545+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
541546 <a href="/diffs/merge-requests" tabindex="-1">
542547 <span class="lbl">merge requests</span>
543548 <span class="k">section</span>
544549 </a>
545550 </li>
546-<li data-k="section actions">
551+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
547552 <a href="/diffs/actions" tabindex="-1">
548553 <span class="lbl">actions</span>
549554 <span class="k">section</span>
550555 </a>
551556 </li>
552-<li data-k="branch main">
557+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
553558 <a href="/diffs/tree/main" tabindex="-1">
554559 <span class="lbl">main</span>
555560 <span class="k">branch</span>
556561 </a>
557562 </li>
558-<li data-k="branch side">
563+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
559564 <a href="/diffs/tree/side" tabindex="-1">
560565 <span class="lbl">side</span>
561566 <span class="k">branch</span>
562567 </a>
563568 </li>
564-<li data-k="tag v1.0.0">
569+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
565570 <a href="/diffs/tree/v1.0.0" tabindex="-1">
566571 <span class="lbl">v1.0.0</span>
567572 <span class="k">tag</span>
568573 </a>
569574 </li>
575+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
576+<a href="#" data-fire="keys" tabindex="-1">
577+<span class="lbl">turn single-key shortcuts off</span>
578+<span class="k">action</span>
579+</a>
580+</li>
570581 </ul>
571582 <p class="panel-foot">
572583 <span>
@@ -605,7 +616,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
605616 k-permalink from:body -> ermite.permalink();
606617 k-merge from:body -> window.ermiteMR?.openMerge();
607618 k-read from:body -> window.ermiteMR?.markRead();
608- k-gf from:body -> window.ermiteMR?.focusFiles()">
619+ k-gf from:body -> window.ermiteMR?.focusFiles();
620+ k-keys from:body -> ermite.toggleKeys()">
609621 </div>
610622 </body>
611623 </html>
M internal/app/testdata/web/commit-rename.html +26 -14
@@ -113,7 +113,7 @@
113113 </dd>
114114 </dl>
115115 </header>
116-<div class="diff-tools" role="toolbar" aria-label="Diff view">
116+<div class="diff-tools" role="group" aria-label="Diff view">
117117 <span class="dim">2 files · <span class="plus">+0</span>
118118 <span class="minus">-1</span>
119119 </span>
@@ -252,6 +252,11 @@
252252 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
253253 <kbd>esc</kbd> close</button>
254254 </div>
255+<div class="keys-pref">
256+<label>
257+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
258+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
259+</div>
255260 <dl class="keys">
256261 <div>
257262 <dt>
@@ -405,72 +410,78 @@
405410 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
406411 <div class="panel-head">
407412 <label for="cmd-q" class="sr">go to</label>
408-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
413+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
409414 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
410415 <kbd>esc</kbd>
411416 </button>
412417 </div>
413-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
414-<li data-k="go home" data-cursor>
418+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
419+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
415420 <a href="/" tabindex="-1">
416421 <span class="lbl">home</span>
417422 <span class="k">go</span>
418423 </a>
419424 </li>
420-<li data-k="section summary">
425+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
421426 <a href="/diffs" tabindex="-1">
422427 <span class="lbl">summary</span>
423428 <span class="k">section</span>
424429 </a>
425430 </li>
426-<li data-k="section files">
431+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
427432 <a href="/diffs/tree/main" tabindex="-1">
428433 <span class="lbl">files</span>
429434 <span class="k">section</span>
430435 </a>
431436 </li>
432-<li data-k="section commits">
437+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
433438 <a href="/diffs/commits/main" tabindex="-1">
434439 <span class="lbl">commits</span>
435440 <span class="k">section</span>
436441 </a>
437442 </li>
438-<li data-k="section branches &amp; tags">
443+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
439444 <a href="/diffs/refs" tabindex="-1">
440445 <span class="lbl">branches &amp; tags</span>
441446 <span class="k">section</span>
442447 </a>
443448 </li>
444-<li data-k="section merge requests">
449+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
445450 <a href="/diffs/merge-requests" tabindex="-1">
446451 <span class="lbl">merge requests</span>
447452 <span class="k">section</span>
448453 </a>
449454 </li>
450-<li data-k="section actions">
455+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
451456 <a href="/diffs/actions" tabindex="-1">
452457 <span class="lbl">actions</span>
453458 <span class="k">section</span>
454459 </a>
455460 </li>
456-<li data-k="branch main">
461+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
457462 <a href="/diffs/tree/main" tabindex="-1">
458463 <span class="lbl">main</span>
459464 <span class="k">branch</span>
460465 </a>
461466 </li>
462-<li data-k="branch side">
467+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
463468 <a href="/diffs/tree/side" tabindex="-1">
464469 <span class="lbl">side</span>
465470 <span class="k">branch</span>
466471 </a>
467472 </li>
468-<li data-k="tag v1.0.0">
473+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
469474 <a href="/diffs/tree/v1.0.0" tabindex="-1">
470475 <span class="lbl">v1.0.0</span>
471476 <span class="k">tag</span>
472477 </a>
473478 </li>
479+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
480+<a href="#" data-fire="keys" tabindex="-1">
481+<span class="lbl">turn single-key shortcuts off</span>
482+<span class="k">action</span>
483+</a>
484+</li>
474485 </ul>
475486 <p class="panel-foot">
476487 <span>
@@ -509,7 +520,8 @@
509520 k-permalink from:body -> ermite.permalink();
510521 k-merge from:body -> window.ermiteMR?.openMerge();
511522 k-read from:body -> window.ermiteMR?.markRead();
512- k-gf from:body -> window.ermiteMR?.focusFiles()">
523+ k-gf from:body -> window.ermiteMR?.focusFiles();
524+ k-keys from:body -> ermite.toggleKeys()">
513525 </div>
514526 </body>
515527 </html>
M internal/app/testdata/web/commit-root.html +26 -14
@@ -111,7 +111,7 @@
111111 <dd>none (root commit) </dd>
112112 </dl>
113113 </header>
114-<div class="diff-tools" role="toolbar" aria-label="Diff view">
114+<div class="diff-tools" role="group" aria-label="Diff view">
115115 <span class="dim">5 files · <span class="plus">+11</span>
116116 <span class="minus">-0</span>
117117 </span>
@@ -571,6 +571,11 @@
571571 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
572572 <kbd>esc</kbd> close</button>
573573 </div>
574+<div class="keys-pref">
575+<label>
576+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
577+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
578+</div>
574579 <dl class="keys">
575580 <div>
576581 <dt>
@@ -724,72 +729,78 @@
724729 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
725730 <div class="panel-head">
726731 <label for="cmd-q" class="sr">go to</label>
727-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
732+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
728733 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
729734 <kbd>esc</kbd>
730735 </button>
731736 </div>
732-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
733-<li data-k="go home" data-cursor>
737+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
738+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
734739 <a href="/" tabindex="-1">
735740 <span class="lbl">home</span>
736741 <span class="k">go</span>
737742 </a>
738743 </li>
739-<li data-k="section summary">
744+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
740745 <a href="/diffs" tabindex="-1">
741746 <span class="lbl">summary</span>
742747 <span class="k">section</span>
743748 </a>
744749 </li>
745-<li data-k="section files">
750+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
746751 <a href="/diffs/tree/main" tabindex="-1">
747752 <span class="lbl">files</span>
748753 <span class="k">section</span>
749754 </a>
750755 </li>
751-<li data-k="section commits">
756+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
752757 <a href="/diffs/commits/main" tabindex="-1">
753758 <span class="lbl">commits</span>
754759 <span class="k">section</span>
755760 </a>
756761 </li>
757-<li data-k="section branches &amp; tags">
762+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
758763 <a href="/diffs/refs" tabindex="-1">
759764 <span class="lbl">branches &amp; tags</span>
760765 <span class="k">section</span>
761766 </a>
762767 </li>
763-<li data-k="section merge requests">
768+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
764769 <a href="/diffs/merge-requests" tabindex="-1">
765770 <span class="lbl">merge requests</span>
766771 <span class="k">section</span>
767772 </a>
768773 </li>
769-<li data-k="section actions">
774+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
770775 <a href="/diffs/actions" tabindex="-1">
771776 <span class="lbl">actions</span>
772777 <span class="k">section</span>
773778 </a>
774779 </li>
775-<li data-k="branch main">
780+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
776781 <a href="/diffs/tree/main" tabindex="-1">
777782 <span class="lbl">main</span>
778783 <span class="k">branch</span>
779784 </a>
780785 </li>
781-<li data-k="branch side">
786+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
782787 <a href="/diffs/tree/side" tabindex="-1">
783788 <span class="lbl">side</span>
784789 <span class="k">branch</span>
785790 </a>
786791 </li>
787-<li data-k="tag v1.0.0">
792+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
788793 <a href="/diffs/tree/v1.0.0" tabindex="-1">
789794 <span class="lbl">v1.0.0</span>
790795 <span class="k">tag</span>
791796 </a>
792797 </li>
798+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
799+<a href="#" data-fire="keys" tabindex="-1">
800+<span class="lbl">turn single-key shortcuts off</span>
801+<span class="k">action</span>
802+</a>
803+</li>
793804 </ul>
794805 <p class="panel-foot">
795806 <span>
@@ -828,7 +839,8 @@
828839 k-permalink from:body -> ermite.permalink();
829840 k-merge from:body -> window.ermiteMR?.openMerge();
830841 k-read from:body -> window.ermiteMR?.markRead();
831- k-gf from:body -> window.ermiteMR?.focusFiles()">
842+ k-gf from:body -> window.ermiteMR?.focusFiles();
843+ k-keys from:body -> ermite.toggleKeys()">
832844 </div>
833845 </body>
834846 </html>
M internal/app/testdata/web/commit-trojan.html +26 -14
@@ -114,7 +114,7 @@
114114 </dd>
115115 </dl>
116116 </header>
117-<div class="diff-tools" role="toolbar" aria-label="Diff view">
117+<div class="diff-tools" role="group" aria-label="Diff view">
118118 <span class="dim">1 file · <span class="plus">+8</span>
119119 <span class="minus">-0</span>
120120 </span>
@@ -360,6 +360,11 @@
360360 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
361361 <kbd>esc</kbd> close</button>
362362 </div>
363+<div class="keys-pref">
364+<label>
365+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
366+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
367+</div>
363368 <dl class="keys">
364369 <div>
365370 <dt>
@@ -513,72 +518,78 @@
513518 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
514519 <div class="panel-head">
515520 <label for="cmd-q" class="sr">go to</label>
516-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
521+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
517522 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
518523 <kbd>esc</kbd>
519524 </button>
520525 </div>
521-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
522-<li data-k="go home" data-cursor>
526+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
527+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
523528 <a href="/" tabindex="-1">
524529 <span class="lbl">home</span>
525530 <span class="k">go</span>
526531 </a>
527532 </li>
528-<li data-k="section summary">
533+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
529534 <a href="/diffs" tabindex="-1">
530535 <span class="lbl">summary</span>
531536 <span class="k">section</span>
532537 </a>
533538 </li>
534-<li data-k="section files">
539+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
535540 <a href="/diffs/tree/main" tabindex="-1">
536541 <span class="lbl">files</span>
537542 <span class="k">section</span>
538543 </a>
539544 </li>
540-<li data-k="section commits">
545+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
541546 <a href="/diffs/commits/main" tabindex="-1">
542547 <span class="lbl">commits</span>
543548 <span class="k">section</span>
544549 </a>
545550 </li>
546-<li data-k="section branches &amp; tags">
551+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
547552 <a href="/diffs/refs" tabindex="-1">
548553 <span class="lbl">branches &amp; tags</span>
549554 <span class="k">section</span>
550555 </a>
551556 </li>
552-<li data-k="section merge requests">
557+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
553558 <a href="/diffs/merge-requests" tabindex="-1">
554559 <span class="lbl">merge requests</span>
555560 <span class="k">section</span>
556561 </a>
557562 </li>
558-<li data-k="section actions">
563+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
559564 <a href="/diffs/actions" tabindex="-1">
560565 <span class="lbl">actions</span>
561566 <span class="k">section</span>
562567 </a>
563568 </li>
564-<li data-k="branch main">
569+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
565570 <a href="/diffs/tree/main" tabindex="-1">
566571 <span class="lbl">main</span>
567572 <span class="k">branch</span>
568573 </a>
569574 </li>
570-<li data-k="branch side">
575+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
571576 <a href="/diffs/tree/side" tabindex="-1">
572577 <span class="lbl">side</span>
573578 <span class="k">branch</span>
574579 </a>
575580 </li>
576-<li data-k="tag v1.0.0">
581+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
577582 <a href="/diffs/tree/v1.0.0" tabindex="-1">
578583 <span class="lbl">v1.0.0</span>
579584 <span class="k">tag</span>
580585 </a>
581586 </li>
587+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
588+<a href="#" data-fire="keys" tabindex="-1">
589+<span class="lbl">turn single-key shortcuts off</span>
590+<span class="k">action</span>
591+</a>
592+</li>
582593 </ul>
583594 <p class="panel-foot">
584595 <span>
@@ -617,7 +628,8 @@
617628 k-permalink from:body -> ermite.permalink();
618629 k-merge from:body -> window.ermiteMR?.openMerge();
619630 k-read from:body -> window.ermiteMR?.markRead();
620- k-gf from:body -> window.ermiteMR?.focusFiles()">
631+ k-gf from:body -> window.ermiteMR?.focusFiles();
632+ k-keys from:body -> ermite.toggleKeys()">
621633 </div>
622634 </body>
623635 </html>
M internal/app/testdata/web/commits-page2.html +24 -12
@@ -184,6 +184,11 @@
184184 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
185185 <kbd>esc</kbd> close</button>
186186 </div>
187+<div class="keys-pref">
188+<label>
189+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
190+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
191+</div>
187192 <dl class="keys">
188193 <div>
189194 <dt>
@@ -329,66 +334,72 @@
329334 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
330335 <div class="panel-head">
331336 <label for="cmd-q" class="sr">go to</label>
332-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
337+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
333338 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
334339 <kbd>esc</kbd>
335340 </button>
336341 </div>
337-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
338-<li data-k="go home" data-cursor>
342+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
343+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
339344 <a href="/" tabindex="-1">
340345 <span class="lbl">home</span>
341346 <span class="k">go</span>
342347 </a>
343348 </li>
344-<li data-k="section summary">
349+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
345350 <a href="/long" tabindex="-1">
346351 <span class="lbl">summary</span>
347352 <span class="k">section</span>
348353 </a>
349354 </li>
350-<li data-k="section files">
355+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
351356 <a href="/long/tree/main" tabindex="-1">
352357 <span class="lbl">files</span>
353358 <span class="k">section</span>
354359 </a>
355360 </li>
356-<li data-k="section commits">
361+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
357362 <a href="/long/commits/main" tabindex="-1">
358363 <span class="lbl">commits</span>
359364 <span class="k">section</span>
360365 </a>
361366 </li>
362-<li data-k="section branches &amp; tags">
367+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
363368 <a href="/long/refs" tabindex="-1">
364369 <span class="lbl">branches &amp; tags</span>
365370 <span class="k">section</span>
366371 </a>
367372 </li>
368-<li data-k="section merge requests">
373+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
369374 <a href="/long/merge-requests" tabindex="-1">
370375 <span class="lbl">merge requests</span>
371376 <span class="k">section</span>
372377 </a>
373378 </li>
374-<li data-k="section actions">
379+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
375380 <a href="/long/actions" tabindex="-1">
376381 <span class="lbl">actions</span>
377382 <span class="k">section</span>
378383 </a>
379384 </li>
380-<li data-k="branch main">
385+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
381386 <a href="/long/tree/main" tabindex="-1">
382387 <span class="lbl">main</span>
383388 <span class="k">branch</span>
384389 </a>
385390 </li>
386-<li data-k="repo diffs">
391+<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
387392 <a href="/diffs" tabindex="-1">
388393 <span class="lbl">diffs</span>
389394 <span class="k">repo</span>
390395 </a>
391396 </li>
397+<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
398+<a href="#" data-fire="keys" tabindex="-1">
399+<span class="lbl">turn single-key shortcuts off</span>
400+<span class="k">action</span>
401+</a>
402+</li>
392403 </ul>
393404 <p class="panel-foot">
394405 <span>
@@ -427,7 +438,8 @@
427438 k-permalink from:body -> ermite.permalink();
428439 k-merge from:body -> window.ermiteMR?.openMerge();
429440 k-read from:body -> window.ermiteMR?.markRead();
430- k-gf from:body -> window.ermiteMR?.focusFiles()">
441+ k-gf from:body -> window.ermiteMR?.focusFiles();
442+ k-keys from:body -> ermite.toggleKeys()">
431443 </div>
432444 </body>
433445 </html>
M internal/app/testdata/web/commits-path.html +24 -12
@@ -199,6 +199,11 @@
199199 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
200200 <kbd>esc</kbd> close</button>
201201 </div>
202+<div class="keys-pref">
203+<label>
204+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
205+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
206+</div>
202207 <dl class="keys">
203208 <div>
204209 <dt>
@@ -344,66 +349,72 @@
344349 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
345350 <div class="panel-head">
346351 <label for="cmd-q" class="sr">go to</label>
347-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
352+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
348353 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
349354 <kbd>esc</kbd>
350355 </button>
351356 </div>
352-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
353-<li data-k="go home" data-cursor>
357+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
358+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
354359 <a href="/" tabindex="-1">
355360 <span class="lbl">home</span>
356361 <span class="k">go</span>
357362 </a>
358363 </li>
359-<li data-k="section summary">
364+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
360365 <a href="/long" tabindex="-1">
361366 <span class="lbl">summary</span>
362367 <span class="k">section</span>
363368 </a>
364369 </li>
365-<li data-k="section files">
370+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
366371 <a href="/long/tree/main" tabindex="-1">
367372 <span class="lbl">files</span>
368373 <span class="k">section</span>
369374 </a>
370375 </li>
371-<li data-k="section commits">
376+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
372377 <a href="/long/commits/main" tabindex="-1">
373378 <span class="lbl">commits</span>
374379 <span class="k">section</span>
375380 </a>
376381 </li>
377-<li data-k="section branches &amp; tags">
382+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
378383 <a href="/long/refs" tabindex="-1">
379384 <span class="lbl">branches &amp; tags</span>
380385 <span class="k">section</span>
381386 </a>
382387 </li>
383-<li data-k="section merge requests">
388+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
384389 <a href="/long/merge-requests" tabindex="-1">
385390 <span class="lbl">merge requests</span>
386391 <span class="k">section</span>
387392 </a>
388393 </li>
389-<li data-k="section actions">
394+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
390395 <a href="/long/actions" tabindex="-1">
391396 <span class="lbl">actions</span>
392397 <span class="k">section</span>
393398 </a>
394399 </li>
395-<li data-k="branch main">
400+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
396401 <a href="/long/tree/main" tabindex="-1">
397402 <span class="lbl">main</span>
398403 <span class="k">branch</span>
399404 </a>
400405 </li>
401-<li data-k="repo diffs">
406+<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
402407 <a href="/diffs" tabindex="-1">
403408 <span class="lbl">diffs</span>
404409 <span class="k">repo</span>
405410 </a>
406411 </li>
412+<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
413+<a href="#" data-fire="keys" tabindex="-1">
414+<span class="lbl">turn single-key shortcuts off</span>
415+<span class="k">action</span>
416+</a>
417+</li>
407418 </ul>
408419 <p class="panel-foot">
409420 <span>
@@ -442,7 +453,8 @@
442453 k-permalink from:body -> ermite.permalink();
443454 k-merge from:body -> window.ermiteMR?.openMerge();
444455 k-read from:body -> window.ermiteMR?.markRead();
445- k-gf from:body -> window.ermiteMR?.focusFiles()">
456+ k-gf from:body -> window.ermiteMR?.focusFiles();
457+ k-keys from:body -> ermite.toggleKeys()">
446458 </div>
447459 </body>
448460 </html>
M internal/app/testdata/web/commits-root.html +24 -12
@@ -1084,6 +1084,11 @@
10841084 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10851085 <kbd>esc</kbd> close</button>
10861086 </div>
1087+<div class="keys-pref">
1088+<label>
1089+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
1090+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
1091+</div>
10871092 <dl class="keys">
10881093 <div>
10891094 <dt>
@@ -1229,66 +1234,72 @@
12291234 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
12301235 <div class="panel-head">
12311236 <label for="cmd-q" class="sr">go to</label>
1232-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
1237+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
12331238 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
12341239 <kbd>esc</kbd>
12351240 </button>
12361241 </div>
1237-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
1238-<li data-k="go home" data-cursor>
1242+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
1243+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
12391244 <a href="/" tabindex="-1">
12401245 <span class="lbl">home</span>
12411246 <span class="k">go</span>
12421247 </a>
12431248 </li>
1244-<li data-k="section summary">
1249+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
12451250 <a href="/long" tabindex="-1">
12461251 <span class="lbl">summary</span>
12471252 <span class="k">section</span>
12481253 </a>
12491254 </li>
1250-<li data-k="section files">
1255+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
12511256 <a href="/long/tree/main" tabindex="-1">
12521257 <span class="lbl">files</span>
12531258 <span class="k">section</span>
12541259 </a>
12551260 </li>
1256-<li data-k="section commits">
1261+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
12571262 <a href="/long/commits/main" tabindex="-1">
12581263 <span class="lbl">commits</span>
12591264 <span class="k">section</span>
12601265 </a>
12611266 </li>
1262-<li data-k="section branches &amp; tags">
1267+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
12631268 <a href="/long/refs" tabindex="-1">
12641269 <span class="lbl">branches &amp; tags</span>
12651270 <span class="k">section</span>
12661271 </a>
12671272 </li>
1268-<li data-k="section merge requests">
1273+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
12691274 <a href="/long/merge-requests" tabindex="-1">
12701275 <span class="lbl">merge requests</span>
12711276 <span class="k">section</span>
12721277 </a>
12731278 </li>
1274-<li data-k="section actions">
1279+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
12751280 <a href="/long/actions" tabindex="-1">
12761281 <span class="lbl">actions</span>
12771282 <span class="k">section</span>
12781283 </a>
12791284 </li>
1280-<li data-k="branch main">
1285+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
12811286 <a href="/long/tree/main" tabindex="-1">
12821287 <span class="lbl">main</span>
12831288 <span class="k">branch</span>
12841289 </a>
12851290 </li>
1286-<li data-k="repo diffs">
1291+<li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs">
12871292 <a href="/diffs" tabindex="-1">
12881293 <span class="lbl">diffs</span>
12891294 <span class="k">repo</span>
12901295 </a>
12911296 </li>
1297+<li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
1298+<a href="#" data-fire="keys" tabindex="-1">
1299+<span class="lbl">turn single-key shortcuts off</span>
1300+<span class="k">action</span>
1301+</a>
1302+</li>
12921303 </ul>
12931304 <p class="panel-foot">
12941305 <span>
@@ -1327,7 +1338,8 @@
13271338 k-permalink from:body -> ermite.permalink();
13281339 k-merge from:body -> window.ermiteMR?.openMerge();
13291340 k-read from:body -> window.ermiteMR?.markRead();
1330- k-gf from:body -> window.ermiteMR?.focusFiles()">
1341+ k-gf from:body -> window.ermiteMR?.focusFiles();
1342+ k-keys from:body -> ermite.toggleKeys()">
13311343 </div>
13321344 </body>
13331345 </html>
M internal/app/testdata/web/commits-small.html +26 -14
@@ -345,6 +345,11 @@
345345 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
346346 <kbd>esc</kbd> close</button>
347347 </div>
348+<div class="keys-pref">
349+<label>
350+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
351+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
352+</div>
348353 <dl class="keys">
349354 <div>
350355 <dt>
@@ -498,78 +503,84 @@
498503 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
499504 <div class="panel-head">
500505 <label for="cmd-q" class="sr">go to</label>
501-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
506+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
502507 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
503508 <kbd>esc</kbd>
504509 </button>
505510 </div>
506-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
507-<li data-k="go home" data-cursor>
511+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
512+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
508513 <a href="/" tabindex="-1">
509514 <span class="lbl">home</span>
510515 <span class="k">go</span>
511516 </a>
512517 </li>
513-<li data-k="section summary">
518+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
514519 <a href="/diffs" tabindex="-1">
515520 <span class="lbl">summary</span>
516521 <span class="k">section</span>
517522 </a>
518523 </li>
519-<li data-k="section files">
524+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
520525 <a href="/diffs/tree/main" tabindex="-1">
521526 <span class="lbl">files</span>
522527 <span class="k">section</span>
523528 </a>
524529 </li>
525-<li data-k="section commits">
530+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
526531 <a href="/diffs/commits/main" tabindex="-1">
527532 <span class="lbl">commits</span>
528533 <span class="k">section</span>
529534 </a>
530535 </li>
531-<li data-k="section branches &amp; tags">
536+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
532537 <a href="/diffs/refs" tabindex="-1">
533538 <span class="lbl">branches &amp; tags</span>
534539 <span class="k">section</span>
535540 </a>
536541 </li>
537-<li data-k="section merge requests">
542+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
538543 <a href="/diffs/merge-requests" tabindex="-1">
539544 <span class="lbl">merge requests</span>
540545 <span class="k">section</span>
541546 </a>
542547 </li>
543-<li data-k="section actions">
548+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
544549 <a href="/diffs/actions" tabindex="-1">
545550 <span class="lbl">actions</span>
546551 <span class="k">section</span>
547552 </a>
548553 </li>
549-<li data-k="branch main">
554+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch main">
550555 <a href="/diffs/tree/main" tabindex="-1">
551556 <span class="lbl">main</span>
552557 <span class="k">branch</span>
553558 </a>
554559 </li>
555-<li data-k="branch side">
560+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch side">
556561 <a href="/diffs/tree/side" tabindex="-1">
557562 <span class="lbl">side</span>
558563 <span class="k">branch</span>
559564 </a>
560565 </li>
561-<li data-k="tag v1.0.0">
566+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0">
562567 <a href="/diffs/tree/v1.0.0" tabindex="-1">
563568 <span class="lbl">v1.0.0</span>
564569 <span class="k">tag</span>
565570 </a>
566571 </li>
567-<li data-k="repo long">
572+<li id="cmd-o10" role="option" aria-selected="false" data-k="repo long">
568573 <a href="/long" tabindex="-1">
569574 <span class="lbl">long</span>
570575 <span class="k">repo</span>
571576 </a>
572577 </li>
578+<li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
579+<a href="#" data-fire="keys" tabindex="-1">
580+<span class="lbl">turn single-key shortcuts off</span>
581+<span class="k">action</span>
582+</a>
583+</li>
573584 </ul>
574585 <p class="panel-foot">
575586 <span>
@@ -608,7 +619,8 @@
608619 k-permalink from:body -> ermite.permalink();
609620 k-merge from:body -> window.ermiteMR?.openMerge();
610621 k-read from:body -> window.ermiteMR?.markRead();
611- k-gf from:body -> window.ermiteMR?.focusFiles()">
622+ k-gf from:body -> window.ermiteMR?.focusFiles();
623+ k-keys from:body -> ermite.toggleKeys()">
612624 </div>
613625 </body>
614626 </html>
M internal/app/testdata/web/empty.html +17 -5
@@ -102,6 +102,11 @@
102102 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
103103 <kbd>esc</kbd> close</button>
104104 </div>
105+<div class="keys-pref">
106+<label>
107+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
108+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
109+</div>
105110 <dl class="keys">
106111 <div>
107112 <dt>
@@ -220,24 +225,30 @@
220225 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
221226 <div class="panel-head">
222227 <label for="cmd-q" class="sr">go to</label>
223-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
228+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
224229 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
225230 <kbd>esc</kbd>
226231 </button>
227232 </div>
228-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
229-<li data-k="go home" data-cursor>
233+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
234+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
230235 <a href="/" tabindex="-1">
231236 <span class="lbl">home</span>
232237 <span class="k">go</span>
233238 </a>
234239 </li>
235-<li data-k="repo demo">
240+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">
236241 <a href="/demo" tabindex="-1">
237242 <span class="lbl">demo</span>
238243 <span class="k">repo</span>
239244 </a>
240245 </li>
246+<li id="cmd-o2" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
247+<a href="#" data-fire="keys" tabindex="-1">
248+<span class="lbl">turn single-key shortcuts off</span>
249+<span class="k">action</span>
250+</a>
251+</li>
241252 </ul>
242253 <p class="panel-foot">
243254 <span>
@@ -276,7 +287,8 @@
276287 k-permalink from:body -> ermite.permalink();
277288 k-merge from:body -> window.ermiteMR?.openMerge();
278289 k-read from:body -> window.ermiteMR?.markRead();
279- k-gf from:body -> window.ermiteMR?.focusFiles()">
290+ k-gf from:body -> window.ermiteMR?.focusFiles();
291+ k-keys from:body -> ermite.toggleKeys()">
280292 </div>
281293 </body>
282294 </html>
M internal/app/testdata/web/home.html +18 -6
@@ -127,6 +127,11 @@
127127 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
128128 <kbd>esc</kbd> close</button>
129129 </div>
130+<div class="keys-pref">
131+<label>
132+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
133+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
134+</div>
130135 <dl class="keys">
131136 <div>
132137 <dt>
@@ -245,30 +250,36 @@
245250 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
246251 <div class="panel-head">
247252 <label for="cmd-q" class="sr">go to</label>
248-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
253+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
249254 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
250255 <kbd>esc</kbd>
251256 </button>
252257 </div>
253-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
254-<li data-k="go home" data-cursor>
258+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
259+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
255260 <a href="/" tabindex="-1">
256261 <span class="lbl">home</span>
257262 <span class="k">go</span>
258263 </a>
259264 </li>
260-<li data-k="repo demo">
265+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">
261266 <a href="/demo" tabindex="-1">
262267 <span class="lbl">demo</span>
263268 <span class="k">repo</span>
264269 </a>
265270 </li>
266-<li data-k="repo quiet">
271+<li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet">
267272 <a href="/quiet" tabindex="-1">
268273 <span class="lbl">quiet</span>
269274 <span class="k">repo</span>
270275 </a>
271276 </li>
277+<li id="cmd-o3" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
278+<a href="#" data-fire="keys" tabindex="-1">
279+<span class="lbl">turn single-key shortcuts off</span>
280+<span class="k">action</span>
281+</a>
282+</li>
272283 </ul>
273284 <p class="panel-foot">
274285 <span>
@@ -307,7 +318,8 @@
307318 k-permalink from:body -> ermite.permalink();
308319 k-merge from:body -> window.ermiteMR?.openMerge();
309320 k-read from:body -> window.ermiteMR?.markRead();
310- k-gf from:body -> window.ermiteMR?.focusFiles()">
321+ k-gf from:body -> window.ermiteMR?.focusFiles();
322+ k-keys from:body -> ermite.toggleKeys()">
311323 </div>
312324 </body>
313325 </html>
M internal/app/testdata/web/not-found-page.html +16 -4
@@ -91,6 +91,11 @@
9191 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
9292 <kbd>esc</kbd> close</button>
9393 </div>
94+<div class="keys-pref">
95+<label>
96+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
97+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
98+</div>
9499 <dl class="keys">
95100 <div>
96101 <dt>
@@ -209,18 +214,24 @@
209214 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
210215 <div class="panel-head">
211216 <label for="cmd-q" class="sr">go to</label>
212-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
217+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
213218 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
214219 <kbd>esc</kbd>
215220 </button>
216221 </div>
217-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
218-<li data-k="go home" data-cursor>
222+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
223+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
219224 <a href="/" tabindex="-1">
220225 <span class="lbl">home</span>
221226 <span class="k">go</span>
222227 </a>
223228 </li>
229+<li id="cmd-o1" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
230+<a href="#" data-fire="keys" tabindex="-1">
231+<span class="lbl">turn single-key shortcuts off</span>
232+<span class="k">action</span>
233+</a>
234+</li>
224235 </ul>
225236 <p class="panel-foot">
226237 <span>
@@ -259,7 +270,8 @@
259270 k-permalink from:body -> ermite.permalink();
260271 k-merge from:body -> window.ermiteMR?.openMerge();
261272 k-read from:body -> window.ermiteMR?.markRead();
262- k-gf from:body -> window.ermiteMR?.focusFiles()">
273+ k-gf from:body -> window.ermiteMR?.focusFiles();
274+ k-keys from:body -> ermite.toggleKeys()">
263275 </div>
264276 </body>
265277 </html>
M internal/app/testdata/web/not-found.html +16 -4
@@ -91,6 +91,11 @@
9191 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
9292 <kbd>esc</kbd> close</button>
9393 </div>
94+<div class="keys-pref">
95+<label>
96+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
97+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
98+</div>
9499 <dl class="keys">
95100 <div>
96101 <dt>
@@ -209,18 +214,24 @@
209214 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
210215 <div class="panel-head">
211216 <label for="cmd-q" class="sr">go to</label>
212-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
217+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
213218 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
214219 <kbd>esc</kbd>
215220 </button>
216221 </div>
217-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
218-<li data-k="go home" data-cursor>
222+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
223+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
219224 <a href="/" tabindex="-1">
220225 <span class="lbl">home</span>
221226 <span class="k">go</span>
222227 </a>
223228 </li>
229+<li id="cmd-o1" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
230+<a href="#" data-fire="keys" tabindex="-1">
231+<span class="lbl">turn single-key shortcuts off</span>
232+<span class="k">action</span>
233+</a>
234+</li>
224235 </ul>
225236 <p class="panel-foot">
226237 <span>
@@ -259,7 +270,8 @@
259270 k-permalink from:body -> ermite.permalink();
260271 k-merge from:body -> window.ermiteMR?.openMerge();
261272 k-read from:body -> window.ermiteMR?.markRead();
262- k-gf from:body -> window.ermiteMR?.focusFiles()">
273+ k-gf from:body -> window.ermiteMR?.focusFiles();
274+ k-keys from:body -> ermite.toggleKeys()">
263275 </div>
264276 </body>
265277 </html>
M internal/app/testdata/web/refs.html +27 -15
@@ -221,6 +221,11 @@
221221 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
222222 <kbd>esc</kbd> close</button>
223223 </div>
224+<div class="keys-pref">
225+<label>
226+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
227+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
228+</div>
224229 <dl class="keys">
225230 <div>
226231 <dt>
@@ -378,84 +383,90 @@
378383 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
379384 <div class="panel-head">
380385 <label for="cmd-q" class="sr">go to</label>
381-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
386+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
382387 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
383388 <kbd>esc</kbd>
384389 </button>
385390 </div>
386-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
387-<li data-k="go home" data-cursor>
391+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
392+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
388393 <a href="/" tabindex="-1">
389394 <span class="lbl">home</span>
390395 <span class="k">go</span>
391396 </a>
392397 </li>
393-<li data-k="section summary">
398+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
394399 <a href="/demo" tabindex="-1">
395400 <span class="lbl">summary</span>
396401 <span class="k">section</span>
397402 </a>
398403 </li>
399-<li data-k="section files">
404+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
400405 <a href="/demo/tree/main" tabindex="-1">
401406 <span class="lbl">files</span>
402407 <span class="k">section</span>
403408 </a>
404409 </li>
405-<li data-k="section commits">
410+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
406411 <a href="/demo/commits/main" tabindex="-1">
407412 <span class="lbl">commits</span>
408413 <span class="k">section</span>
409414 </a>
410415 </li>
411-<li data-k="section branches &amp; tags">
416+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
412417 <a href="/demo/refs" tabindex="-1">
413418 <span class="lbl">branches &amp; tags</span>
414419 <span class="k">section</span>
415420 </a>
416421 </li>
417-<li data-k="section merge requests">
422+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
418423 <a href="/demo/merge-requests" tabindex="-1">
419424 <span class="lbl">merge requests</span>
420425 <span class="k">section</span>
421426 </a>
422427 </li>
423-<li data-k="section actions">
428+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
424429 <a href="/demo/actions" tabindex="-1">
425430 <span class="lbl">actions</span>
426431 <span class="k">section</span>
427432 </a>
428433 </li>
429-<li data-k="branch feature/x">
434+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
430435 <a href="/demo/tree/feature/x" tabindex="-1">
431436 <span class="lbl">feature/x</span>
432437 <span class="k">branch</span>
433438 </a>
434439 </li>
435-<li data-k="branch main">
440+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
436441 <a href="/demo/tree/main" tabindex="-1">
437442 <span class="lbl">main</span>
438443 <span class="k">branch</span>
439444 </a>
440445 </li>
441-<li data-k="tag v0.1.0">
446+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0">
442447 <a href="/demo/tree/v0.1.0" tabindex="-1">
443448 <span class="lbl">v0.1.0</span>
444449 <span class="k">tag</span>
445450 </a>
446451 </li>
447-<li data-k="tag v0.2.0">
452+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0">
448453 <a href="/demo/tree/v0.2.0" tabindex="-1">
449454 <span class="lbl">v0.2.0</span>
450455 <span class="k">tag</span>
451456 </a>
452457 </li>
453-<li data-k="repo quiet">
458+<li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet">
454459 <a href="/quiet" tabindex="-1">
455460 <span class="lbl">quiet</span>
456461 <span class="k">repo</span>
457462 </a>
458463 </li>
464+<li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
465+<a href="#" data-fire="keys" tabindex="-1">
466+<span class="lbl">turn single-key shortcuts off</span>
467+<span class="k">action</span>
468+</a>
469+</li>
459470 </ul>
460471 <p class="panel-foot">
461472 <span>
@@ -494,7 +505,8 @@
494505 k-permalink from:body -> ermite.permalink();
495506 k-merge from:body -> window.ermiteMR?.openMerge();
496507 k-read from:body -> window.ermiteMR?.markRead();
497- k-gf from:body -> window.ermiteMR?.focusFiles()">
508+ k-gf from:body -> window.ermiteMR?.focusFiles();
509+ k-keys from:body -> ermite.toggleKeys()">
498510 </div>
499511 </body>
500512 </html>
M internal/app/testdata/web/summary.html +27 -15
@@ -245,6 +245,11 @@
245245 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
246246 <kbd>esc</kbd> close</button>
247247 </div>
248+<div class="keys-pref">
249+<label>
250+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
251+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
252+</div>
248253 <dl class="keys">
249254 <div>
250255 <dt>
@@ -402,84 +407,90 @@
402407 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
403408 <div class="panel-head">
404409 <label for="cmd-q" class="sr">go to</label>
405-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
410+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
406411 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
407412 <kbd>esc</kbd>
408413 </button>
409414 </div>
410-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
411-<li data-k="go home" data-cursor>
415+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
416+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
412417 <a href="/" tabindex="-1">
413418 <span class="lbl">home</span>
414419 <span class="k">go</span>
415420 </a>
416421 </li>
417-<li data-k="section summary">
422+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
418423 <a href="/demo" tabindex="-1">
419424 <span class="lbl">summary</span>
420425 <span class="k">section</span>
421426 </a>
422427 </li>
423-<li data-k="section files">
428+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
424429 <a href="/demo/tree/main" tabindex="-1">
425430 <span class="lbl">files</span>
426431 <span class="k">section</span>
427432 </a>
428433 </li>
429-<li data-k="section commits">
434+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
430435 <a href="/demo/commits/main" tabindex="-1">
431436 <span class="lbl">commits</span>
432437 <span class="k">section</span>
433438 </a>
434439 </li>
435-<li data-k="section branches &amp; tags">
440+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
436441 <a href="/demo/refs" tabindex="-1">
437442 <span class="lbl">branches &amp; tags</span>
438443 <span class="k">section</span>
439444 </a>
440445 </li>
441-<li data-k="section merge requests">
446+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
442447 <a href="/demo/merge-requests" tabindex="-1">
443448 <span class="lbl">merge requests</span>
444449 <span class="k">section</span>
445450 </a>
446451 </li>
447-<li data-k="section actions">
452+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
448453 <a href="/demo/actions" tabindex="-1">
449454 <span class="lbl">actions</span>
450455 <span class="k">section</span>
451456 </a>
452457 </li>
453-<li data-k="branch feature/x">
458+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
454459 <a href="/demo/tree/feature/x" tabindex="-1">
455460 <span class="lbl">feature/x</span>
456461 <span class="k">branch</span>
457462 </a>
458463 </li>
459-<li data-k="branch main">
464+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
460465 <a href="/demo/tree/main" tabindex="-1">
461466 <span class="lbl">main</span>
462467 <span class="k">branch</span>
463468 </a>
464469 </li>
465-<li data-k="tag v0.1.0">
470+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0">
466471 <a href="/demo/tree/v0.1.0" tabindex="-1">
467472 <span class="lbl">v0.1.0</span>
468473 <span class="k">tag</span>
469474 </a>
470475 </li>
471-<li data-k="tag v0.2.0">
476+<li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0">
472477 <a href="/demo/tree/v0.2.0" tabindex="-1">
473478 <span class="lbl">v0.2.0</span>
474479 <span class="k">tag</span>
475480 </a>
476481 </li>
477-<li data-k="repo quiet">
482+<li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet">
478483 <a href="/quiet" tabindex="-1">
479484 <span class="lbl">quiet</span>
480485 <span class="k">repo</span>
481486 </a>
482487 </li>
488+<li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
489+<a href="#" data-fire="keys" tabindex="-1">
490+<span class="lbl">turn single-key shortcuts off</span>
491+<span class="k">action</span>
492+</a>
493+</li>
483494 </ul>
484495 <p class="panel-foot">
485496 <span>
@@ -518,7 +529,8 @@
518529 k-permalink from:body -> ermite.permalink();
519530 k-merge from:body -> window.ermiteMR?.openMerge();
520531 k-read from:body -> window.ermiteMR?.markRead();
521- k-gf from:body -> window.ermiteMR?.focusFiles()">
532+ k-gf from:body -> window.ermiteMR?.focusFiles();
533+ k-keys from:body -> ermite.toggleKeys()">
522534 </div>
523535 </body>
524536 </html>
M internal/app/testdata/web/tree-nested.html +25 -13
@@ -168,6 +168,11 @@
168168 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
169169 <kbd>esc</kbd> close</button>
170170 </div>
171+<div class="keys-pref">
172+<label>
173+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
174+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
175+</div>
171176 <dl class="keys">
172177 <div>
173178 <dt>
@@ -321,72 +326,78 @@
321326 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
322327 <div class="panel-head">
323328 <label for="cmd-q" class="sr">go to</label>
324-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
329+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
325330 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
326331 <kbd>esc</kbd>
327332 </button>
328333 </div>
329-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
330-<li data-k="go home" data-cursor>
334+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
335+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
331336 <a href="/" tabindex="-1">
332337 <span class="lbl">home</span>
333338 <span class="k">go</span>
334339 </a>
335340 </li>
336-<li data-k="section summary">
341+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
337342 <a href="/files" tabindex="-1">
338343 <span class="lbl">summary</span>
339344 <span class="k">section</span>
340345 </a>
341346 </li>
342-<li data-k="section files">
347+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
343348 <a href="/files/tree/main" tabindex="-1">
344349 <span class="lbl">files</span>
345350 <span class="k">section</span>
346351 </a>
347352 </li>
348-<li data-k="section commits">
353+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
349354 <a href="/files/commits/main" tabindex="-1">
350355 <span class="lbl">commits</span>
351356 <span class="k">section</span>
352357 </a>
353358 </li>
354-<li data-k="section branches &amp; tags">
359+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
355360 <a href="/files/refs" tabindex="-1">
356361 <span class="lbl">branches &amp; tags</span>
357362 <span class="k">section</span>
358363 </a>
359364 </li>
360-<li data-k="section merge requests">
365+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
361366 <a href="/files/merge-requests" tabindex="-1">
362367 <span class="lbl">merge requests</span>
363368 <span class="k">section</span>
364369 </a>
365370 </li>
366-<li data-k="section actions">
371+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
367372 <a href="/files/actions" tabindex="-1">
368373 <span class="lbl">actions</span>
369374 <span class="k">section</span>
370375 </a>
371376 </li>
372-<li data-k="branch feature/x">
377+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
373378 <a href="/files/tree/feature/x" tabindex="-1">
374379 <span class="lbl">feature/x</span>
375380 <span class="k">branch</span>
376381 </a>
377382 </li>
378-<li data-k="branch main">
383+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
379384 <a href="/files/tree/main" tabindex="-1">
380385 <span class="lbl">main</span>
381386 <span class="k">branch</span>
382387 </a>
383388 </li>
384-<li data-k="tag v1.0">
389+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
385390 <a href="/files/tree/v1.0" tabindex="-1">
386391 <span class="lbl">v1.0</span>
387392 <span class="k">tag</span>
388393 </a>
389394 </li>
395+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
396+<a href="#" data-fire="keys" tabindex="-1">
397+<span class="lbl">turn single-key shortcuts off</span>
398+<span class="k">action</span>
399+</a>
400+</li>
390401 </ul>
391402 <p class="panel-foot">
392403 <span>
@@ -425,7 +436,8 @@
425436 k-permalink from:body -> ermite.permalink();
426437 k-merge from:body -> window.ermiteMR?.openMerge();
427438 k-read from:body -> window.ermiteMR?.markRead();
428- k-gf from:body -> window.ermiteMR?.focusFiles()">
439+ k-gf from:body -> window.ermiteMR?.focusFiles();
440+ k-keys from:body -> ermite.toggleKeys()">
429441 </div>
430442 </body>
431443 </html>
M internal/app/testdata/web/tree-root.html +25 -13
@@ -389,6 +389,11 @@
389389 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
390390 <kbd>esc</kbd> close</button>
391391 </div>
392+<div class="keys-pref">
393+<label>
394+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
395+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
396+</div>
392397 <dl class="keys">
393398 <div>
394399 <dt>
@@ -542,72 +547,78 @@
542547 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
543548 <div class="panel-head">
544549 <label for="cmd-q" class="sr">go to</label>
545-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
550+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
546551 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
547552 <kbd>esc</kbd>
548553 </button>
549554 </div>
550-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
551-<li data-k="go home" data-cursor>
555+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
556+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
552557 <a href="/" tabindex="-1">
553558 <span class="lbl">home</span>
554559 <span class="k">go</span>
555560 </a>
556561 </li>
557-<li data-k="section summary">
562+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
558563 <a href="/files" tabindex="-1">
559564 <span class="lbl">summary</span>
560565 <span class="k">section</span>
561566 </a>
562567 </li>
563-<li data-k="section files">
568+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
564569 <a href="/files/tree/main" tabindex="-1">
565570 <span class="lbl">files</span>
566571 <span class="k">section</span>
567572 </a>
568573 </li>
569-<li data-k="section commits">
574+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
570575 <a href="/files/commits/main" tabindex="-1">
571576 <span class="lbl">commits</span>
572577 <span class="k">section</span>
573578 </a>
574579 </li>
575-<li data-k="section branches &amp; tags">
580+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
576581 <a href="/files/refs" tabindex="-1">
577582 <span class="lbl">branches &amp; tags</span>
578583 <span class="k">section</span>
579584 </a>
580585 </li>
581-<li data-k="section merge requests">
586+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
582587 <a href="/files/merge-requests" tabindex="-1">
583588 <span class="lbl">merge requests</span>
584589 <span class="k">section</span>
585590 </a>
586591 </li>
587-<li data-k="section actions">
592+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
588593 <a href="/files/actions" tabindex="-1">
589594 <span class="lbl">actions</span>
590595 <span class="k">section</span>
591596 </a>
592597 </li>
593-<li data-k="branch feature/x">
598+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
594599 <a href="/files/tree/feature/x" tabindex="-1">
595600 <span class="lbl">feature/x</span>
596601 <span class="k">branch</span>
597602 </a>
598603 </li>
599-<li data-k="branch main">
604+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
600605 <a href="/files/tree/main" tabindex="-1">
601606 <span class="lbl">main</span>
602607 <span class="k">branch</span>
603608 </a>
604609 </li>
605-<li data-k="tag v1.0">
610+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
606611 <a href="/files/tree/v1.0" tabindex="-1">
607612 <span class="lbl">v1.0</span>
608613 <span class="k">tag</span>
609614 </a>
610615 </li>
616+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
617+<a href="#" data-fire="keys" tabindex="-1">
618+<span class="lbl">turn single-key shortcuts off</span>
619+<span class="k">action</span>
620+</a>
621+</li>
611622 </ul>
612623 <p class="panel-foot">
613624 <span>
@@ -646,7 +657,8 @@
646657 k-permalink from:body -> ermite.permalink();
647658 k-merge from:body -> window.ermiteMR?.openMerge();
648659 k-read from:body -> window.ermiteMR?.markRead();
649- k-gf from:body -> window.ermiteMR?.focusFiles()">
660+ k-gf from:body -> window.ermiteMR?.focusFiles();
661+ k-keys from:body -> ermite.toggleKeys()">
650662 </div>
651663 </body>
652664 </html>
M internal/app/testdata/web/tree-slash-ref.html +25 -13
@@ -165,6 +165,11 @@
165165 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
166166 <kbd>esc</kbd> close</button>
167167 </div>
168+<div class="keys-pref">
169+<label>
170+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
171+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
172+</div>
168173 <dl class="keys">
169174 <div>
170175 <dt>
@@ -318,72 +323,78 @@
318323 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
319324 <div class="panel-head">
320325 <label for="cmd-q" class="sr">go to</label>
321-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
326+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
322327 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
323328 <kbd>esc</kbd>
324329 </button>
325330 </div>
326-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
327-<li data-k="go home" data-cursor>
331+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
332+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
328333 <a href="/" tabindex="-1">
329334 <span class="lbl">home</span>
330335 <span class="k">go</span>
331336 </a>
332337 </li>
333-<li data-k="section summary">
338+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
334339 <a href="/files" tabindex="-1">
335340 <span class="lbl">summary</span>
336341 <span class="k">section</span>
337342 </a>
338343 </li>
339-<li data-k="section files">
344+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
340345 <a href="/files/tree/main" tabindex="-1">
341346 <span class="lbl">files</span>
342347 <span class="k">section</span>
343348 </a>
344349 </li>
345-<li data-k="section commits">
350+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
346351 <a href="/files/commits/main" tabindex="-1">
347352 <span class="lbl">commits</span>
348353 <span class="k">section</span>
349354 </a>
350355 </li>
351-<li data-k="section branches &amp; tags">
356+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
352357 <a href="/files/refs" tabindex="-1">
353358 <span class="lbl">branches &amp; tags</span>
354359 <span class="k">section</span>
355360 </a>
356361 </li>
357-<li data-k="section merge requests">
362+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
358363 <a href="/files/merge-requests" tabindex="-1">
359364 <span class="lbl">merge requests</span>
360365 <span class="k">section</span>
361366 </a>
362367 </li>
363-<li data-k="section actions">
368+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
364369 <a href="/files/actions" tabindex="-1">
365370 <span class="lbl">actions</span>
366371 <span class="k">section</span>
367372 </a>
368373 </li>
369-<li data-k="branch feature/x">
374+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
370375 <a href="/files/tree/feature/x" tabindex="-1">
371376 <span class="lbl">feature/x</span>
372377 <span class="k">branch</span>
373378 </a>
374379 </li>
375-<li data-k="branch main">
380+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
376381 <a href="/files/tree/main" tabindex="-1">
377382 <span class="lbl">main</span>
378383 <span class="k">branch</span>
379384 </a>
380385 </li>
381-<li data-k="tag v1.0">
386+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
382387 <a href="/files/tree/v1.0" tabindex="-1">
383388 <span class="lbl">v1.0</span>
384389 <span class="k">tag</span>
385390 </a>
386391 </li>
392+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
393+<a href="#" data-fire="keys" tabindex="-1">
394+<span class="lbl">turn single-key shortcuts off</span>
395+<span class="k">action</span>
396+</a>
397+</li>
387398 </ul>
388399 <p class="panel-foot">
389400 <span>
@@ -422,7 +433,8 @@
422433 k-permalink from:body -> ermite.permalink();
423434 k-merge from:body -> window.ermiteMR?.openMerge();
424435 k-read from:body -> window.ermiteMR?.markRead();
425- k-gf from:body -> window.ermiteMR?.focusFiles()">
436+ k-gf from:body -> window.ermiteMR?.focusFiles();
437+ k-keys from:body -> ermite.toggleKeys()">
426438 </div>
427439 </body>
428440 </html>
M internal/app/testdata/web/tree-subdir.html +25 -13
@@ -181,6 +181,11 @@
181181 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
182182 <kbd>esc</kbd> close</button>
183183 </div>
184+<div class="keys-pref">
185+<label>
186+<input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"> Turn off single-key shortcuts</label>
187+<p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
188+</div>
184189 <dl class="keys">
185190 <div>
186191 <dt>
@@ -334,72 +339,78 @@
334339 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
335340 <div class="panel-head">
336341 <label for="cmd-q" class="sr">go to</label>
337-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
342+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="cmd-list" aria-activedescendant="cmd-o0" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)">
338343 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
339344 <kbd>esc</kbd>
340345 </button>
341346 </div>
342-<ul id="cmd-list" class="cmd-list" aria-label="Targets">
343-<li data-k="go home" data-cursor>
347+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
348+<li id="cmd-o0" role="option" aria-selected="true" data-k="go home" data-cursor>
344349 <a href="/" tabindex="-1">
345350 <span class="lbl">home</span>
346351 <span class="k">go</span>
347352 </a>
348353 </li>
349-<li data-k="section summary">
354+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
350355 <a href="/files" tabindex="-1">
351356 <span class="lbl">summary</span>
352357 <span class="k">section</span>
353358 </a>
354359 </li>
355-<li data-k="section files">
360+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
356361 <a href="/files/tree/main" tabindex="-1">
357362 <span class="lbl">files</span>
358363 <span class="k">section</span>
359364 </a>
360365 </li>
361-<li data-k="section commits">
366+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
362367 <a href="/files/commits/main" tabindex="-1">
363368 <span class="lbl">commits</span>
364369 <span class="k">section</span>
365370 </a>
366371 </li>
367-<li data-k="section branches &amp; tags">
372+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
368373 <a href="/files/refs" tabindex="-1">
369374 <span class="lbl">branches &amp; tags</span>
370375 <span class="k">section</span>
371376 </a>
372377 </li>
373-<li data-k="section merge requests">
378+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
374379 <a href="/files/merge-requests" tabindex="-1">
375380 <span class="lbl">merge requests</span>
376381 <span class="k">section</span>
377382 </a>
378383 </li>
379-<li data-k="section actions">
384+<li id="cmd-o6" role="option" aria-selected="false" data-k="section actions">
380385 <a href="/files/actions" tabindex="-1">
381386 <span class="lbl">actions</span>
382387 <span class="k">section</span>
383388 </a>
384389 </li>
385-<li data-k="branch feature/x">
390+<li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x">
386391 <a href="/files/tree/feature/x" tabindex="-1">
387392 <span class="lbl">feature/x</span>
388393 <span class="k">branch</span>
389394 </a>
390395 </li>
391-<li data-k="branch main">
396+<li id="cmd-o8" role="option" aria-selected="false" data-k="branch main">
392397 <a href="/files/tree/main" tabindex="-1">
393398 <span class="lbl">main</span>
394399 <span class="k">branch</span>
395400 </a>
396401 </li>
397-<li data-k="tag v1.0">
402+<li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0">
398403 <a href="/files/tree/v1.0" tabindex="-1">
399404 <span class="lbl">v1.0</span>
400405 <span class="k">tag</span>
401406 </a>
402407 </li>
408+<li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard">
409+<a href="#" data-fire="keys" tabindex="-1">
410+<span class="lbl">turn single-key shortcuts off</span>
411+<span class="k">action</span>
412+</a>
413+</li>
403414 </ul>
404415 <p class="panel-foot">
405416 <span>
@@ -438,7 +449,8 @@
438449 k-permalink from:body -> ermite.permalink();
439450 k-merge from:body -> window.ermiteMR?.openMerge();
440451 k-read from:body -> window.ermiteMR?.markRead();
441- k-gf from:body -> window.ermiteMR?.focusFiles()">
452+ k-gf from:body -> window.ermiteMR?.focusFiles();
453+ k-keys from:body -> ermite.toggleKeys()">
442454 </div>
443455 </body>
444456 </html>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close