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
@@ -0,0 +1,249 @@
/**
* The key story, told once.
*
* The single-key global bindings are staying (plan 007, Decision 1),
* and until now Settings was the only place they were written down —
* three of the four categories of them, at that, so the autotag keys
* were written down nowhere. `?` opens this from anywhere the app owns
* the keyboard.
*
* It reads `services/shortcut-meta` for the labels and the *store* for
* the keys, so a rebound key shows its new binding rather than the
* default it shipped with.
*
* It is a `wa-dialog` for the reason every dialog in this app is: the
* focus trap, Escape and focus restore come with it. Being help rather
* than a question, it is a dialog in a host template rather than a
* `confirmAction()` call.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import { designTokens } from '../../styles/tokens.css';
import {
SHORTCUT_CATEGORIES,
SHORTCUT_META,
} from '../../services/shortcut-meta';
import { shortcutsStore } from '@store/shortcuts-store';
/** How a key string reads to a person: `Ctrl+F` is two keys. */
function keyParts(key: string): string[] {
return key.split('+');
}
/** Where a binding applies, in the user's terms. A panel binding that
* does not say so reads as broken everywhere else. */
function scopeNote(scope: string): string {
if (scope === 'panel:track-list') return 'in the track list';
if (scope === 'panel:autotag') return 'on the Autotag page';
return '';
}
@customElement('shortcuts-overlay')
export class ShortcutsOverlay extends LitElement {
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@state() private isOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
.category {
margin-bottom: 1.25em;
}
.category:last-of-type {
margin-bottom: 0;
}
h3 {
margin: 0 0 0.5em;
font-size: var(--yj-text-sm, 0.8125rem);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-secondary, #b3b3b3);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1em;
padding: 0.25em 0;
}
.label {
font-size: var(--yj-text-sm, 0.8125rem);
}
.scope {
color: var(--yj-text-tertiary, #888);
}
.keys {
display: flex;
gap: 0.25em;
flex-shrink: 0;
}
kbd {
background: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
padding: 0.1em 0.45em;
font-family: inherit;
font-size: var(--yj-text-xs, 0.6875rem);
}
.footnote {
margin: 1em 0 0;
font-size: var(--yj-text-xs, 0.6875rem);
color: var(--yj-text-tertiary, #888);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener('shortcut:app-shortcuts', this.open);
// The keys come from the backend, so the first open can arrive
// before they do.
this.unsubscribe = shortcutsStore.subscribe(() =>
this.requestUpdate(),
);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener('shortcut:app-shortcuts', this.open);
this.unsubscribe?.();
this.unsubscribe = null;
}
/**
* `?` opens; Escape closes, as it does for every dialog here.
*
* It is deliberately not a toggle. A dialog owns the whole keyboard
* while it is up — `focusedControlOwnsKey` yields every unmodified
* key to anything inside one — so a second `?` never reaches the
* service, and a toggle would be a promise the shortcut layer
* cannot keep. Written after watching an e2e spec assert it and
* fail.
*/
private open = (): void => {
if (this.isOpen) return;
this.isOpen = true;
void this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
});
};
private close(): void {
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
}
override render() {
if (!this.isOpen) return nothing;
const bindings = shortcutsStore.getBindings();
return html`
<wa-dialog
label="Keyboard Shortcuts"
data-testid="shortcuts-overlay"
@wa-hide=${() => this.close()}
>
${SHORTCUT_CATEGORIES.map((category) => {
const rows = Object.entries(SHORTCUT_META).filter(
([, meta]) => meta.category === category,
);
if (rows.length === 0) return nothing;
// A note every row in the category repeats is a
// note about the category. "— on the Autotag page"
// seven times is noise; once is the heading.
const first = scopeNote(rows[0]![1].scope);
const uniform = rows.every(
([, meta]) => scopeNote(meta.scope) === first,
);
// …and a note the *heading* already says is not a
// note either: "Autotag — on the Autotag page".
const shared =
uniform &&
!first
.toLowerCase()
.includes(category.toLowerCase())
? first
: '';
return html`
<div class="category">
<h3>
${category}
${shared
? html`<span class="scope"
>— ${shared}</span
>`
: nothing}
</h3>
${rows.map(([action, meta]) => {
const key =
bindings.get(action) ?? meta.defaultKey;
const note =
shared || uniform
? ''
: scopeNote(meta.scope);
return html`
<div class="row">
<span class="label">
${meta.label}
${note
? html`<span class="scope"
>— ${note}</span
>`
: nothing}
</span>
<span class="keys">
${keyParts(key).map(
(part) =>
html`<kbd>${part}</kbd>`,
)}
</span>
</div>
`;
})}
</div>
`;
})}
<p class="footnote">
Every one of these can be rebound in Settings →
Keyboard Shortcuts.
</p>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'shortcuts-overlay': ShortcutsOverlay;
}
}