feat(e24): reach the Home surface — the landing toggle, a palette command, Shift+W

S4's reachability half. Home already led the nav order; what was missing was every
way IN other than the rail.

- Settings → Appearance → Startup: "Start on Home", the documented escape hatch for
  the landing override. Deliberately still localStorage and not a server pref —
  `loadInitialPage` answers before /api/me lands, and a landing module decided after
  the first paint is a visible jump. Its two stored values are now ONE exported
  constant, so the writer and the boot-time reader cannot drift on a magic string.
- The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and
  rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home
  module row already matches that phrase through its descriptive title.
- A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare
  letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown
  a viewer out of the film.

The palette's request crosses a lazy module boundary, so it is a token that waits
(lib/homeIntent) rather than an event fired into an empty room. Browser-measured:
taken with a compare-and-clear, because StrictMode's double-invoked mount effect
otherwise rerolled twice and landed on the page two picks deep.

HU/EN parity kept; UAT on localdev in both languages, including the empty shelf.
This commit is contained in:
2026-09-08 00:06:34 +02:00
parent 82914b56ca
commit 828dad015c
18 changed files with 238 additions and 29 deletions
+49 -2
View File
@@ -30,8 +30,16 @@ afterEach(() => {
});
/** Renders the live stores so an assertion reads what a real consumer would render. */
function Host({ enabled = true, onState }: { enabled?: boolean; onState: (s: string) => void }) {
useGlobalKeymap(enabled);
function Host({
enabled = true,
onState,
onHome = () => {},
}: {
enabled?: boolean;
onState: (s: string) => void;
onHome?: () => void;
}) {
useGlobalKeymap(enabled, onHome);
const help = useKeyboardHelpOpen();
const palette = usePaletteOpen();
useEffect(() => {
@@ -111,6 +119,45 @@ describe("useGlobalKeymap", () => {
expect(state).toBe("none");
});
// E24. The chord is the point: the Plex player binds bare letters and `w` sits on top of `s`, so a
// bare `w` would yank a viewer out of the film on a blind reach for the subtitle key.
it("jumps Home on Shift+W and ignores a bare w", () => {
const onHome = vi.fn();
render(<Host onState={() => {}} onHome={onHome} />);
key({ key: "w" });
expect(onHome).not.toHaveBeenCalled();
key({ key: "w", shiftKey: true });
expect(onHome).toHaveBeenCalledTimes(1);
});
it("leaves Shift+W to the text field it was typed into", () => {
const onHome = vi.fn();
render(<Host onState={() => {}} onHome={onHome} />);
const field = document.createElement("input");
document.body.appendChild(field);
field.focus();
act(() => {
field.dispatchEvent(
new KeyboardEvent("keydown", { key: "w", shiftKey: true, bubbles: true, cancelable: true }),
);
});
expect(onHome).not.toHaveBeenCalled();
field.remove();
});
// The handler is App's `() => setPage("home")` — a fresh closure every render. It is held in a ref
// rather than in the effect's deps, so the listener is bound once; this pins that the ref is the
// CURRENT one and not the closure captured at mount.
it("calls the latest handler after a re-render, without rebinding", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender } = render(<Host onState={() => {}} onHome={first} />);
rerender(<Host onState={() => {}} onHome={second} />);
key({ key: "w", shiftKey: true });
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
// Bound above the branch that mounts the overlays, the keys were swallowed on the loading/Welcome
// screens and the flipped store then sprang the overlay open by itself once the shell mounted.
it("binds nothing, and clears a stale store, while disabled", () => {
+30
View File
@@ -0,0 +1,30 @@
import { createStore } from "./store";
// A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by
// callers that cannot reach its state — today the command palette's "Meglepetés".
//
// A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted
// when the request is made (the palette navigates to it in the same click), so an event would be
// shouted into an empty room. Home takes the token on arrival, which clears it, so walking back to
// Home an hour later does not replay the press — a stale reroll would look like the page deciding
// on its own that the answer you came back for was wrong.
const surprise = createStore(0);
/** Ask Home to reroll its hero as soon as it is on screen. Pair it with a `setPage("home")`. */
export const requestSurprise = (): void => surprise.set((n) => n + 1);
/** The pending token (0 = nothing pending) — the render subscription that wakes Home up. Home only. */
export const useSurpriseRequest = (): number => surprise.use();
/** Take the pending request, clearing it; false when there was nothing to take.
*
* Compare-and-clear rather than a plain read + `consume()`, because a reroll is NOT idempotent — it
* spends a `/discover/pick` and steps the tier rotation — and React 18's StrictMode invokes a mount
* effect twice with the same render snapshot. Measured in the browser (2026-09-07): the palette's
* "Meglepetés" from another module arrived at Home as `start=2`, two picks deep, before the page had
* painted once. Checking the LIVE store is what makes the second pass a no-op. */
export const takeSurprise = (): boolean => {
if (!surprise.get()) return false;
surprise.set(0);
return true;
};
+1
View File
@@ -146,6 +146,7 @@ describe("dispatch", () => {
const handlers = {
help: () => void calls.push("help"),
palette: () => void calls.push("palette"),
home: () => void calls.push("home"),
};
expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false);
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
+24 -10
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { createStore } from "./store";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
@@ -56,6 +56,10 @@ export const SHORTCUTS = [
// (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" },
// Shift+W and not a bare `w` (E24): the Plex player binds bare letters (a s d f h i k m) and `w`
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
// --- 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
@@ -281,11 +285,16 @@ export const usePaletteOpen = (): boolean => paletteOpen.use();
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* 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 {
* `enabled` must track where those overlays (and the pages Shift+W jumps to) 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: boolean, onHome: () => void): void {
// The Home jump is the one global key whose action lives OUTSIDE this file — navigation is a
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
const homeRef = useRef(onHome);
homeRef.current = onHome;
useEffect(() => {
if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount.
@@ -294,10 +303,11 @@ export function useGlobalKeymap(enabled = true): void {
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.
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
// way, 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) => {
@@ -309,6 +319,10 @@ export function useGlobalKeymap(enabled = true): void {
ev.preventDefault();
paletteOpen.set((v) => !v);
},
home: (ev) => {
ev.preventDefault();
homeRef.current();
},
});
};
window.addEventListener("keydown", onKey);