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
+5 -4
View File
@@ -137,10 +137,11 @@ export default function App() {
refetchInterval: (query) => (query.state.data ? 60_000 : false), refetchInterval: (query) => (query.state.data ? 60_000 : false),
}); });
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette. Bound only once // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// signed in, because that is where both overlays are mounted (bottom of this tree) — see // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it. // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
useGlobalKeymap(!!meQuery.data); // above the signed-out branch is worse than not binding it.
useGlobalKeymap(!!meQuery.data, () => setPage("home"));
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked // Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER // an account (it was riding the session's default) would silently swap identity when ANOTHER
+17 -1
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Keyboard, ListVideo, Palette, Tv } from "lucide-react"; import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette"; import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -9,6 +9,7 @@ import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules"; import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme"; import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider"; import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider"; import { usePrefs } from "./PrefsProvider";
@@ -124,6 +125,21 @@ export default function AppCommandPalette() {
id: "actions", id: "actions",
label: t("palette.groups.actions"), label: t("palette.groups.actions"),
items: [ items: [
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
// duplication in the one list whose whole job is to be scanned quickly.
{
id: "action:surprise",
label: t("palette.actions.surprise"),
icon: <Sparkles className="w-4 h-4" />,
keywords: t("home.title"),
run: () => {
setPage("home");
requestSurprise();
},
},
{ {
id: "action:shortcuts", id: "action:shortcuts",
label: t("keys.title"), label: t("keys.title"),
+49
View File
@@ -9,6 +9,7 @@
// identical card straight back and the button looks dead. // identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside // i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -63,6 +64,7 @@ vi.mock("./PlexProvider", () => ({
})); }));
import Home from "./Home"; import Home from "./Home";
import { requestSurprise, takeSurprise } from "../lib/homeIntent";
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) { function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
return { return {
@@ -155,6 +157,8 @@ beforeEach(() => {
setPage.mockReset(); setPage.mockReset();
setJumpTo.mockReset(); setJumpTo.mockReset();
discover.mockResolvedValue(payload("v1")); discover.mockResolvedValue(payload("v1"));
// Module-level state: a token left behind by one test would reroll the next one's first render.
takeSurprise();
}); });
afterEach(cleanup); afterEach(cleanup);
@@ -219,6 +223,51 @@ describe("Home", () => {
expect(screen.getByText("Hero v1")).toBeTruthy(); expect(screen.getByText("Hero v1")).toBeTruthy();
}); });
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
// the page overruling the answer you came back for.
it("acts on a pending surprise request once, and never replays it", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const first = mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
first.unmount();
discoverPick.mockClear();
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
});
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
// taken with a compare-and-clear against the LIVE store, which is what makes the second pass moot.
it("does not reroll twice under StrictMode's double-invoked mount effect", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<StrictMode>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</StrictMode>,
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
// …and on the press count the server reads as "which tier answers first".
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
});
it("says the shelf is empty rather than hiding it", async () => { it("says the shelf is empty rather than hiding it", async () => {
mount(); mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy(); expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
+12 -1
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -23,6 +23,7 @@ import {
type VideoStatus, type VideoStatus,
} from "../lib/api"; } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch"; import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
@@ -511,6 +512,16 @@ export default function Home() {
}); });
}, [heroKey]); }, [heroKey]);
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
// false to every later pass, including StrictMode's second mount invocation and the re-runs
// `reroll`'s changing identity causes.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
if (surpriseRequest && takeSurprise()) reroll();
}, [surpriseRequest, reroll]);
if (q.isPending) return <LoadingState />; if (q.isPending) return <LoadingState />;
if (q.isError) if (q.isError)
return ( return (
+19
View File
@@ -16,6 +16,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { StateMessage } from "./ui/QueryState"; import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./WizardProvider"; import { useWizard } from "./WizardProvider";
import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./PrefsProvider"; import { usePrefs } from "./PrefsProvider";
import Tabs from "./ui/Tabs"; import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
@@ -129,6 +130,11 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
// Controlled by App's prefs draft: each change applies locally for preview but is only // Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar). // persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs; const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
// has to answer before /api/me lands, so the value must be readable synchronously from
// localStorage — a landing module decided after the first paint would be a visible jump. The two
// stored values come from navigationInit, which is what reads them back at boot.
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
return ( return (
<> <>
@@ -214,6 +220,19 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</SettingRow> </SettingRow>
</Section> </Section>
<Section title={t("settings.appearance.startup")}>
<SettingRow
label={t("settings.appearance.startOnHome")}
hint={t("settings.appearance.startOnHomeHint")}
>
<Switch
label={t("settings.appearance.startOnHome")}
checked={startOnHome !== START_ON_HOME.off}
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
/>
</SettingRow>
</Section>
<Section title={t("settings.appearance.textSize")}> <Section title={t("settings.appearance.textSize")}>
<input <input
type="range" type="range"
@@ -15,7 +15,7 @@ vi.mock("../lib/storage", async (importOriginal) => {
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null }; return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
}); });
import { loadInitialPage, channelViewFrom } from "./navigationInit"; import { loadInitialPage, channelViewFrom, START_ON_HOME } from "./navigationInit";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
@@ -66,12 +66,12 @@ describe("loadInitialPage precedence", () => {
expect(loadInitialPage()).toBe("home"); expect(loadInitialPage()).toBe("home");
}); });
it("opting out restores the account's stored landing page", () => { it("opting out restores the account's stored landing page", () => {
stored[LS.startOnHome] = "0"; stored[LS.startOnHome] = START_ON_HOME.off;
stored[LS.page] = "downloads"; stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads"); expect(loadInitialPage()).toBe("downloads");
}); });
it("opted out with no / invalid stored page still defaults to feed", () => { it("opted out with no / invalid stored page still defaults to feed", () => {
stored[LS.startOnHome] = "0"; stored[LS.startOnHome] = START_ON_HOME.off;
expect(loadInitialPage()).toBe("feed"); expect(loadInitialPage()).toBe("feed");
stored[LS.page] = "nope"; stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed"); expect(loadInitialPage()).toBe("feed");
+11 -4
View File
@@ -6,10 +6,16 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage"; import { getAccountRaw, LS } from "../lib/storage";
export const PAGE_KEY = LS.page; export const PAGE_KEY = LS.page;
// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this // Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
// alias would only be a second name for the same key. // only be a second name for the same key.
const START_HOME_KEY = LS.startOnHome; const START_HOME_KEY = LS.startOnHome;
/** The two stored values of the landing preference (raw strings, via useAccountPersistedState).
* Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined
* ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has
* bitten this codebase before (`cancelled` vs `canceled`). */
export const START_ON_HOME = { on: "1", off: "0" } as const;
export interface ChannelView { export interface ChannelView {
id: string; id: string;
name?: string; name?: string;
@@ -26,8 +32,9 @@ export function loadInitialPage(): Page {
// A bare URL → Home (E24), the surface whose whole job is to answer "what should I watch" before // A bare URL → Home (E24), the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing // the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to // on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
// replace. The account can opt out (Settings) and get the remembered module back. // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
if (getAccountRaw(START_HOME_KEY) !== "0") return "home"; // module back.
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
const stored = getAccountRaw(PAGE_KEY); const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed"; return isPage(stored) ? stored : "feed";
} }
@@ -27,9 +27,9 @@ afterEach(() => {
closeKeyboardHelp(); closeKeyboardHelp();
}); });
/** App's job: it binds the `global` scope (h / Mod+K) once for the whole tree. */ /** App's job: it binds the `global` scope (h / Mod+K / Shift+W) once for the whole tree. */
function Host({ children }: { children?: React.ReactNode }) { function Host({ children }: { children?: React.ReactNode }) {
useGlobalKeymap(); useGlobalKeymap(true, () => {});
return ( return (
<> <>
{children} {children}
@@ -83,13 +83,13 @@ describe("KeyboardHelp", () => {
const rows = descriptions(); const rows = descriptions();
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants. // A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
expect(rows[0]).toBe("keys.playPause"); expect(rows[0]).toBe("keys.playPause");
expect(rows.slice(-2)).toEqual(["keys.palette", "keys.help"]); expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.home"]);
}); });
it("lists only the app-wide keys when no surface is registered", () => { it("lists only the app-wide keys when no surface is registered", () => {
render(<Host />); render(<Host />);
pressH(); pressH();
expect(descriptions()).toEqual(["keys.palette", "keys.help"]); expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.home"]);
}); });
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the // The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
+1
View File
@@ -15,6 +15,7 @@
"audio": "Cycle audio track", "audio": "Cycle audio track",
"subtitle": "Cycle subtitle", "subtitle": "Cycle subtitle",
"help": "Show this help", "help": "Show this help",
"home": "Go to Home (what should I watch?)",
"info": "Media info", "info": "Media info",
"stats": "Stats for nerds", "stats": "Stats for nerds",
"back": "Stop & back to feed", "back": "Stop & back to feed",
@@ -11,5 +11,8 @@
"playlists": "Playlists", "playlists": "Playlists",
"views": "Saved views", "views": "Saved views",
"actions": "Actions" "actions": "Actions"
},
"actions": {
"surprise": "Surprise me"
} }
} }
@@ -34,6 +34,9 @@
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.", "showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
"showTuner": "Show the glass tuner", "showTuner": "Show the glass tuner",
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.", "showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
"startup": "Startup",
"startOnHome": "Start on Home",
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
"textSize": "Text size" "textSize": "Text size"
}, },
"notifications": { "notifications": {
+1
View File
@@ -15,6 +15,7 @@
"audio": "Hangsáv váltása", "audio": "Hangsáv váltása",
"subtitle": "Felirat váltása", "subtitle": "Felirat váltása",
"help": "Súgó megjelenítése", "help": "Súgó megjelenítése",
"home": "Ugrás a kezdőlapra (mit nézzek?)",
"info": "Média infó", "info": "Média infó",
"stats": "Statisztika (nerdöknek)", "stats": "Statisztika (nerdöknek)",
"back": "Leállítás és vissza a feedre", "back": "Leállítás és vissza a feedre",
@@ -11,5 +11,8 @@
"playlists": "Listák", "playlists": "Listák",
"views": "Mentett nézetek", "views": "Mentett nézetek",
"actions": "Műveletek" "actions": "Műveletek"
},
"actions": {
"surprise": "Meglepetés"
} }
} }
@@ -34,6 +34,9 @@
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.", "showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
"showTuner": "Glass tuner megjelenítése", "showTuner": "Glass tuner megjelenítése",
"showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.", "showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
"startup": "Indulás",
"startOnHome": "Indítás a kezdőlapon",
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
"textSize": "Betűméret" "textSize": "Betűméret"
}, },
"notifications": { "notifications": {
+49 -2
View File
@@ -30,8 +30,16 @@ afterEach(() => {
}); });
/** Renders the live stores so an assertion reads what a real consumer would render. */ /** 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 }) { function Host({
useGlobalKeymap(enabled); enabled = true,
onState,
onHome = () => {},
}: {
enabled?: boolean;
onState: (s: string) => void;
onHome?: () => void;
}) {
useGlobalKeymap(enabled, onHome);
const help = useKeyboardHelpOpen(); const help = useKeyboardHelpOpen();
const palette = usePaletteOpen(); const palette = usePaletteOpen();
useEffect(() => { useEffect(() => {
@@ -111,6 +119,45 @@ describe("useGlobalKeymap", () => {
expect(state).toBe("none"); 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 // 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. // 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", () => { 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 = { const handlers = {
help: () => void calls.push("help"), help: () => void calls.push("help"),
palette: () => void calls.push("palette"), 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: "F10", shiftKey: true }), "global", handlers)).toBe(false);
expect(dispatch(press({ key: "ContextMenu" }), "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"; import { createStore } from "./store";
// ── The app's ONE keyboard map ──────────────────────────────────────────────────────────────── // ── 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. // (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: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" }, { 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) --------------------------------- // --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it, // 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 // 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 * key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* calls this; the overlays are pure renderers of their store. * calls this; the overlays are pure renderers of their store.
* *
* `enabled` must track where those overlays are actually MOUNTED. Bound unconditionally it swallowed * `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
* `h` and Mod+K on the loading/Welcome/setup screens — the browser shortcut was preventDefault'ed, * Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
* nothing appeared, and the flipped module-level store then popped the overlay open by itself the * browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
* moment the signed-in shell mounted (E10 review round 1). */ * popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
export function useGlobalKeymap(enabled = true): void { 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(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount. // 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; return;
} }
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// Both global keys TOGGLE an overlay, and each overlay mount/unmount costs a real history // Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
// entry (useBackToClose). On auto-repeat that is ~30 push/back pairs a second, which Chrome // history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
// starts dropping while our own depth bookkeeping keeps counting them — a desynced stack whose // way, on auto-repeat that is ~30 push/back pairs a second, which Chrome starts dropping while
// next browser Back walks out of the app. Holding a key is not a second press. // 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; if (e.repeat) return;
dispatch(e, "global", { dispatch(e, "global", {
help: (ev) => { help: (ev) => {
@@ -309,6 +319,10 @@ export function useGlobalKeymap(enabled = true): void {
ev.preventDefault(); ev.preventDefault();
paletteOpen.set((v) => !v); paletteOpen.set((v) => !v);
}, },
home: (ev) => {
ev.preventDefault();
homeRef.current();
},
}); });
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);