fix(e10): review round 1 — the boundary that did not bind, and 14 more
The epic-end review found 32 verified issues. This is the first fix round: the three highs, the user-facing mediums, and the tests that were passing for the wrong reason. Docs and the remaining low-severity tail are next. The boundary rule was the worst of them. Both `no-restricted-imports` regexes were written in plain "..." literals, where `\.` is an unknown escape that collapses to the any-char `.` — so the rule read like a boundary and matched far less than one. `../../lib/api` (the barrel every feature component imports) and `../../components/Feed` (the same file `../Feed` names) both sailed through a green gate. String.raw, a third pattern for the second spelling, and a vitest over ESLint.lintText so a rule that silently matches nothing can never look like a clean codebase again. closeThenNavigate waited for exactly one reclaim popstate. With a second Back-closable layer still mounted — a palette opened over the player — the navigation unmounted that layer too, and its reconcile backed over the entry the navigation had just pushed: "go to Channels" closed the player and landed on the feed. It now waits for the LAST programmatic back, then adopts the entries the navigation buried, and falls back after 250ms rather than hanging if no popstate is ever coming. The rail's inline rename died on arrival. With no domReference, floating- ui's returnFocus falls through to a module-global stack and restores some unrelated element from an earlier popover — blurring the field into its own commit-and-close, from the second popover of a session onward. The context menu now owns the restore: back to the row, but only if the row is still there and nothing else has claimed focus. Also: - Backspace under the cheat sheet stopped Plex playback and dropped to the grid; `back` now defers to any layer above, like `escape` beside it. - A move invalidated only the source list, so the target kept serving pre-move contents for 30s while the rail already counted N+1. - Every move error a user can reach is a 404, which `req` leaves to the caller — so the row snapped back in silence and the shipped moveErrors strings were unreachable. - swallowClick armed on long-press but was disarmed only by the click it was armed for; any press that produced no click ate the next tap on the whole list. - The context menu stayed parked over whatever row scrolled under it. - overflow-clip on the chip lanes ate the focus ring's top and bottom. - "Why is this off" rode on disabled rows, which show no tooltip and take no focus: a new `inert` item keeps the reason readable. - Shift+F10 was advertised app-wide but wired only on Playlists; it has its own scope now, and useContextMenu matches through the shared parser instead of its own literals. - Mod+K could not close the palette, `h` machine-gunned on auto-repeat, and both were bound above the branch that mounts their overlays. - Palette options were in the tab order under aria-activedescendant, and a scroll-synthesised mousemove stole the keyboard selection. - The OSD live region was created together with its own text. - Ending a rename stranded focus on <body>. - The Button variants' hover halves are `enabled:` — the one migrated site that had guarded its own lost the guard on the way in. - HU builtinLocked named the list "Watch later" in a Hungarian sentence. Tests: the chip-lane contract counted per FILE, so a second hand-rolled lane in an already-compliant file was invisible; the closeThenNavigate ordering test only passed on a popstate left behind by its neighbours and failed alone, so it moved to its own file.
This commit is contained in:
@@ -19,7 +19,7 @@ import { createStore } from "./store";
|
||||
|
||||
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
|
||||
* is mounted. The help sheet shows `global` plus the topmost registered scope. */
|
||||
export type KeyScope = "global" | "undo" | "ytPlayer" | "plexPlayer" | "plexBrowse";
|
||||
export type KeyScope = "global" | "undo" | "contextMenu" | "ytPlayer" | "plexPlayer" | "plexBrowse";
|
||||
|
||||
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
||||
type ShortcutWhen = "episode";
|
||||
@@ -51,13 +51,18 @@ export const SHORTCUTS = [
|
||||
// --- global ---------------------------------------------------------------------------------
|
||||
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
||||
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
|
||||
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette" },
|
||||
// `allowInInput` because the palette AUTOFOCUSES its search field: without it the typing guard ate
|
||||
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead
|
||||
// (E10 review round 1). Its handler preventDefaults, so nothing leaks to the browser either way.
|
||||
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
|
||||
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
|
||||
// Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the
|
||||
// menu belongs to is a property of focus, not of the app.
|
||||
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
||||
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
||||
// — where nothing answers the key (E10 review round 1). Same honest-states rule `plexBrowse` obeys.
|
||||
{
|
||||
id: "contextMenu",
|
||||
scope: "global",
|
||||
scope: "contextMenu",
|
||||
keys: ["Shift+F10", "ContextMenu"],
|
||||
labelKey: "keys.contextMenu",
|
||||
handledLocally: true,
|
||||
@@ -183,6 +188,16 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
|
||||
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
|
||||
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
|
||||
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
|
||||
/** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is
|
||||
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
|
||||
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
|
||||
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
|
||||
* editing the row changed the help sheet and nothing else (E10 review round 1). */
|
||||
export function matchesShortcut(e: KeyboardEvent, id: string): boolean {
|
||||
const row = ROWS.find((s) => s.id === id);
|
||||
return !!row?.keys.some((k) => matches(e, k));
|
||||
}
|
||||
|
||||
export function dispatch<S extends KeyScope>(
|
||||
e: KeyboardEvent,
|
||||
scope: S,
|
||||
@@ -252,10 +267,26 @@ export const usePaletteOpen = (): boolean => paletteOpen.use();
|
||||
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
|
||||
* overlay because both are opened by it: a listener that sat in one of them would have to route a
|
||||
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
|
||||
* calls this; the overlays are pure renderers of their store. */
|
||||
export function useGlobalKeymap(): void {
|
||||
* calls this; the overlays are pure renderers of their store.
|
||||
*
|
||||
* `enabled` must track where those overlays are actually MOUNTED. Bound unconditionally it swallowed
|
||||
* `h` and Mod+K on the loading/Welcome/setup screens — the browser shortcut was preventDefault'ed,
|
||||
* nothing appeared, and the flipped module-level store then popped the overlay open by itself the
|
||||
* moment the signed-in shell mounted (E10 review round 1). */
|
||||
export function useGlobalKeymap(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
||||
helpOpen.set(false);
|
||||
paletteOpen.set(false);
|
||||
return;
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Both global keys TOGGLE an overlay, and each overlay mount/unmount costs a real history
|
||||
// entry (useBackToClose). On auto-repeat that is ~30 push/back pairs a second, which Chrome
|
||||
// starts dropping while our own depth bookkeeping keeps counting them — a desynced stack whose
|
||||
// next browser Back walks out of the app. Holding a key is not a second press.
|
||||
if (e.repeat) return;
|
||||
dispatch(e, "global", {
|
||||
help: (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -270,7 +301,7 @@ export function useGlobalKeymap(): void {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/** Answers for the `when` conditions, published by the surface that knows them. */
|
||||
|
||||
Reference in New Issue
Block a user