From 828dad015c3952f2f1f48096853cfe68a9a0ed9f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 8 Sep 2026 00:06:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(e24):=20reach=20the=20Home=20surface=20?= =?UTF-8?q?=E2=80=94=20the=20landing=20toggle,=20a=20palette=20command,=20?= =?UTF-8?q?Shift+W?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 9 ++-- frontend/src/components/AppCommandPalette.tsx | 18 ++++++- frontend/src/components/Home.dom.test.tsx | 49 ++++++++++++++++++ frontend/src/components/Home.tsx | 13 ++++- frontend/src/components/SettingsPanel.tsx | 19 +++++++ .../src/components/navigation.dom.test.ts | 6 +-- frontend/src/components/navigationInit.ts | 15 ++++-- .../components/ui/KeyboardHelp.dom.test.tsx | 8 +-- frontend/src/i18n/locales/en/keys.json | 1 + frontend/src/i18n/locales/en/palette.json | 3 ++ frontend/src/i18n/locales/en/settings.json | 3 ++ frontend/src/i18n/locales/hu/keys.json | 1 + frontend/src/i18n/locales/hu/palette.json | 3 ++ frontend/src/i18n/locales/hu/settings.json | 3 ++ frontend/src/lib/globalKeymap.dom.test.tsx | 51 ++++++++++++++++++- frontend/src/lib/homeIntent.ts | 30 +++++++++++ frontend/src/lib/keymap.test.ts | 1 + frontend/src/lib/keymap.ts | 34 +++++++++---- 18 files changed, 238 insertions(+), 29 deletions(-) create mode 100644 frontend/src/lib/homeIntent.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7fdb2b6e..b15fd02b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -137,10 +137,11 @@ export default function App() { 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 - // signed in, because that is where both overlays are mounted (bottom of this tree) — see - // useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it. - useGlobalKeymap(!!meQuery.data); + // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to + // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted + // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it + // 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 // an account (it was riding the session's default) would silently swap identity when ANOTHER diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx index 500ef172..a4c3f33a 100644 --- a/frontend/src/components/AppCommandPalette.tsx +++ b/frontend/src/components/AppCommandPalette.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; 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 { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; @@ -9,6 +9,7 @@ import { useMe } from "../lib/useMe"; import { moduleDef, moduleOrder } from "../lib/modules"; import { SCHEMES, type Scheme } from "../lib/theme"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; +import { requestSurprise } from "../lib/homeIntent"; import { useNavigationActions } from "./NavigationProvider"; import { usePlaylistsActions } from "./PlaylistsProvider"; import { usePrefs } from "./PrefsProvider"; @@ -124,6 +125,21 @@ export default function AppCommandPalette() { id: "actions", label: t("palette.groups.actions"), 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: , + keywords: t("home.title"), + run: () => { + setPage("home"); + requestSurprise(); + }, + }, { id: "action:shortcuts", label: t("keys.title"), diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index ac5b3ba7..2fa249e3 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -9,6 +9,7 @@ // 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 // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { StrictMode } from "react"; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -63,6 +64,7 @@ vi.mock("./PlexProvider", () => ({ })); import Home from "./Home"; +import { requestSurprise, takeSurprise } from "../lib/homeIntent"; function ytCard(id: string, title: string, extra: Record = {}) { return { @@ -155,6 +157,8 @@ beforeEach(() => { setPage.mockReset(); setJumpTo.mockReset(); 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); @@ -219,6 +223,51 @@ describe("Home", () => { 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( + + + + + , + ); + 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 () => { mount(); expect(await screen.findByText("home.comfort.empty")).toBeTruthy(); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index b2c7f6a7..bd5e1d8c 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -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 { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -23,6 +23,7 @@ import { type VideoStatus, } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; +import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent"; import { formatDuration } from "../lib/format"; import { toggleWatchState } from "../lib/plexWatch"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; @@ -511,6 +512,16 @@ export default function Home() { }); }, [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 ; if (q.isError) return ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index e473d7b8..e32c270c 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -16,6 +16,7 @@ import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; import { StateMessage } from "./ui/QueryState"; import { useWizard } from "./WizardProvider"; +import { START_ON_HOME } from "./navigationInit"; import { usePrefs } from "./PrefsProvider"; import Tabs from "./ui/Tabs"; 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 // persisted on an explicit Save (handled by the panel's Save/Discard bar). 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 ( <> @@ -214,6 +220,19 @@ function Appearance({ prefs }: { prefs: PrefsController }) { +
+ + setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)} + /> + +
+
{ 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"; const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); @@ -66,12 +66,12 @@ describe("loadInitialPage precedence", () => { expect(loadInitialPage()).toBe("home"); }); 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"; expect(loadInitialPage()).toBe("downloads"); }); 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"); stored[LS.page] = "nope"; expect(loadInitialPage()).toBe("feed"); diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts index 00452d50..f1109778 100644 --- a/frontend/src/components/navigationInit.ts +++ b/frontend/src/components/navigationInit.ts @@ -6,10 +6,16 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState"; import { getAccountRaw, LS } from "../lib/storage"; export const PAGE_KEY = LS.page; -// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this -// alias would only be a second name for the same key. +// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would +// only be a second name for the same key. 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 { id: 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 // 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 - // replace. The account can opt out (Settings) and get the remembered module back. - if (getAccountRaw(START_HOME_KEY) !== "0") return "home"; + // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered + // module back. + if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home"; const stored = getAccountRaw(PAGE_KEY); return isPage(stored) ? stored : "feed"; } diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx index cf99270c..7949ff89 100644 --- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx +++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx @@ -27,9 +27,9 @@ afterEach(() => { 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 }) { - useGlobalKeymap(); + useGlobalKeymap(true, () => {}); return ( <> {children} @@ -83,13 +83,13 @@ describe("KeyboardHelp", () => { const rows = descriptions(); // A plexPlayer row first, the app-wide rows last — the reading order the reader wants. 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", () => { render(); 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 diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index 5c255351..37f9076c 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -15,6 +15,7 @@ "audio": "Cycle audio track", "subtitle": "Cycle subtitle", "help": "Show this help", + "home": "Go to Home (what should I watch?)", "info": "Media info", "stats": "Stats for nerds", "back": "Stop & back to feed", diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json index 75dda89d..16963d00 100644 --- a/frontend/src/i18n/locales/en/palette.json +++ b/frontend/src/i18n/locales/en/palette.json @@ -11,5 +11,8 @@ "playlists": "Playlists", "views": "Saved views", "actions": "Actions" + }, + "actions": { + "surprise": "Surprise me" } } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 1c84c950..9d6d0606 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -34,6 +34,9 @@ "showHintsHint": "These little hover explanations. Turn them off once you know your way around.", "showTuner": "Show the glass tuner", "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" }, "notifications": { diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json index c8cfca4f..b6e24c44 100644 --- a/frontend/src/i18n/locales/hu/keys.json +++ b/frontend/src/i18n/locales/hu/keys.json @@ -15,6 +15,7 @@ "audio": "Hangsáv váltása", "subtitle": "Felirat váltása", "help": "Súgó megjelenítése", + "home": "Ugrás a kezdőlapra (mit nézzek?)", "info": "Média infó", "stats": "Statisztika (nerdöknek)", "back": "Leállítás és vissza a feedre", diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json index 88e80ca2..3e00a1a6 100644 --- a/frontend/src/i18n/locales/hu/palette.json +++ b/frontend/src/i18n/locales/hu/palette.json @@ -11,5 +11,8 @@ "playlists": "Listák", "views": "Mentett nézetek", "actions": "Műveletek" + }, + "actions": { + "surprise": "Meglepetés" } } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 9fb47826..9515829c 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -34,6 +34,9 @@ "showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.", "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.", + "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" }, "notifications": { diff --git a/frontend/src/lib/globalKeymap.dom.test.tsx b/frontend/src/lib/globalKeymap.dom.test.tsx index 2df7ec95..023f242c 100644 --- a/frontend/src/lib/globalKeymap.dom.test.tsx +++ b/frontend/src/lib/globalKeymap.dom.test.tsx @@ -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( {}} 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( {}} 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( {}} onHome={first} />); + rerender( {}} 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", () => { diff --git a/frontend/src/lib/homeIntent.ts b/frontend/src/lib/homeIntent.ts new file mode 100644 index 00000000..f58d173f --- /dev/null +++ b/frontend/src/lib/homeIntent.ts @@ -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; +}; diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts index 44b37a84..681a3c25 100644 --- a/frontend/src/lib/keymap.test.ts +++ b/frontend/src/lib/keymap.test.ts @@ -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); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index d907e692..ee47c000 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -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);