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:
@@ -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: <Sparkles className="w-4 h-4" />,
|
||||
keywords: t("home.title"),
|
||||
run: () => {
|
||||
setPage("home");
|
||||
requestSurprise();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action:shortcuts",
|
||||
label: t("keys.title"),
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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(
|
||||
<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 () => {
|
||||
mount();
|
||||
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
|
||||
|
||||
@@ -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 <LoadingState />;
|
||||
if (q.isError)
|
||||
return (
|
||||
|
||||
@@ -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 }) {
|
||||
</SettingRow>
|
||||
</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")}>
|
||||
<input
|
||||
type="range"
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("../lib/storage", async (importOriginal) => {
|
||||
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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(<Host />);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user