feat(e10): a jump-to palette on Ctrl/Cmd+K

Reaching a channel three modules away cost a rail, a page and a scroll.
Now it costs a keystroke and half a word: Mod+K searches modules,
channels, playlists, the saved views applicable where you stand, and the
app-wide actions.

Scope agreed with the product owner: navigation + actions, but only the
reversible local ones (the shortcuts sheet, the theme). "Sync from
YouTube" is deliberately absent — it is a long-running job against a
quota, it wants the progress and error surface the Channels page already
gives it, and one mistyped Enter is a worse failure than the walk it
would have saved.

Matching is word-wise and accent-folded, because this is a bilingual app
and nobody reaches for the accent keys in a hurry: "pod thevr" finds
TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat
result list so crossing a group heading is invisible, and the field
carries the combobox/listbox aria tree that virtual selection needs.

ui/CommandPalette is domain-free (items are a prop, per the ui/ import
rule); AppCommandPalette supplies them. The shell is Modal again, with a
new `top` placement so a search-first dialog grows downwards instead of
jumping as results change.

Also fixes a bug the palette hit on its first run, and that PlayerModal
had already hit once: closing a dialog reclaims its Back-to-close
history entry with a programmatic history.back(), which undoes any
pushState made before it lands — so a command that navigates only closed
the palette. The rule is now closeThenNavigate() in history.ts with a
test, and PlayerModal's inline copy is gone.

The global `h` moves out of KeyboardHelp into useGlobalKeymap beside
Mod+K, so one listener owns the whole `global` scope and dispatch's
exhaustiveness keeps meaning what it says.
This commit is contained in:
2026-09-05 21:33:29 +02:00
parent 86163958d6
commit cc8cf96c19
16 changed files with 685 additions and 57 deletions
+3 -17
View File
@@ -1,18 +1,17 @@
import { Fragment, useEffect } from "react";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import {
closeKeyboardHelp,
dispatch,
formatChord,
helpLines,
toggleKeyboardHelp,
useActiveScopes,
useHelpConditionValues,
useKeyboardHelpOpen,
} from "../../lib/keymap";
// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
// `useGlobalKeymap`, beside the palette's Mod+K, so one listener owns the whole `global` scope.
//
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
@@ -31,19 +30,6 @@ export default function KeyboardHelp() {
const scopes = useActiveScopes();
const conditions = useHelpConditionValues();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
toggleKeyboardHelp();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
if (!open) return null;
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
// a promise the player does not keep.