feat(shortcuts): tell the key story once, and give the arrows back
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m24s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Canceled after 8s

Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).

The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.

A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
This commit is contained in:
2026-08-12 12:33:50 -04:00
parent 4615afe7f7
commit 1aa1598ecb
11 changed files with 730 additions and 164 deletions
@@ -86,6 +86,20 @@ describe('buildKeyString', () => {
expect(names).toEqual(['Up', 'Down', 'Left', 'Right', 'Space']);
});
it('does not report Shift for a character Shift produced', () => {
// `?` is Shift+/ on a US layout and something else elsewhere, so
// "Shift+?" is a binding nobody would write down. Letters keep it:
// `Shift+A` and `A` are the same character.
const question = new KeyboardEvent('keydown', {
key: '?',
shiftKey: true,
});
const letter = new KeyboardEvent('keydown', { key: 'A', shiftKey: true });
expect(buildKeyString(question)).toBe('?');
expect(buildKeyString(letter)).toBe('Shift+A');
});
it('leaves multi-character named keys alone', () => {
expect(
buildKeyString(new KeyboardEvent('keydown', { key: 'Escape' })),
@@ -233,6 +247,37 @@ describe('shortcut dispatch: scope', () => {
expect(calls('player.Player.ChangeVolume')).toHaveLength(0);
});
it('leaves Up and Down to a focused row, which moves on them', () => {
bindings({ 'player.volumeUp': 'Up' });
const row = mount(document.createElement('div'));
row.setAttribute('role', 'row');
row.tabIndex = 0;
row.focus();
press('ArrowUp');
expect(calls('player.Player.ChangeVolume')).toHaveLength(0);
});
it('still seeks on Left/Right from a focused row', () => {
// Phase 1 gave the arrows to the grid, correctly — but all six of
// them, and no list in this app moves horizontally, so seeking
// stopped working from a focused row and nothing gained the keys.
// Reproduced in the app: two ArrowRights on a focused track row,
// zero Player.Seek calls, against one per press from the body.
bindings({ 'player.seekForward': 'Right' });
const row = mount(document.createElement('div'));
row.setAttribute('role', 'row');
row.tabIndex = 0;
row.focus();
press('ArrowRight');
expect(calls('player.Player.CurrentPositionSeconds')).toHaveLength(1);
});
it('leaves every unmodified key to an open dialog', () => {
const dialog = mount(document.createElement('div'));
const button = document.createElement('button');