diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index e7ec581f..0c7e2016 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -12,6 +12,7 @@ import { useNavigation } from "./navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; import { useChannels, useChannelsActions } from "./channelsContext"; +import { PaletteLauncher } from "./PaletteLauncher"; // The floating top header: a row of glass pills over the content — the module's SearchBar where it // has one, and the per-user sync chip at the right end. @@ -42,7 +43,8 @@ const Header = forwardRef< const blur = () => (document.activeElement as HTMLElement | null)?.blur?.(); // Per-page SearchBar wiring — null for modules without a search. - let search: ReactNode = null; + // A page without a search field still gets the palette's entry (PaletteLauncher). + let search: ReactNode = ; if (page === "feed") { const trimmed = filters.q.trim(); search = ( diff --git a/frontend/src/components/PaletteLauncher.dom.test.tsx b/frontend/src/components/PaletteLauncher.dom.test.tsx new file mode 100644 index 00000000..1e19691f --- /dev/null +++ b/frontend/src/components/PaletteLauncher.dom.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +// +// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's +// search field, and a "Jump to…" pill in its place on a page that has no search field. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); + +let page = "home"; +vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page }) })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ q: "", sort: "newest" }), + useFeedFiltersActions: () => ({ setFilters: vi.fn() }), +})); +vi.mock("./playlistsContext", () => ({ + usePlaylists: () => ({ search: "" }), + usePlaylistsActions: () => ({ setSearch: vi.fn() }), +})); +vi.mock("./channelsContext", () => ({ + useChannels: () => ({ search: "" }), + useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), +})); +vi.mock("./SyncStatus", () => ({ default: () => null })); + +import Header from "./Header"; +import { closePalette, usePaletteOpen } from "../lib/keymap"; +import type { Me } from "../lib/api"; + +afterEach(() => { + cleanup(); + act(() => closePalette()); +}); + +function mount() { + let open = false; + function Probe() { + open = usePaletteOpen(); + return null; + } + render( + <> +
{}} /> + + , + ); + return () => open; +} + +describe("the command palette's visible entry", () => { + it("is a key cap inside the search field, naming the chord the palette row binds", () => { + page = "feed"; + const isOpen = mount(); + const cap = screen.getByRole("button", { name: "palette.open" }); + expect(cap.textContent).toMatch(/^(Ctrl|⌘) K$/); + expect(screen.getByRole("textbox")).toBeTruthy(); + fireEvent.click(cap); + expect(isOpen()).toBe(true); + }); + + it("is a Jump-to pill on a page with no search field", () => { + page = "home"; + const isOpen = mount(); + expect(screen.queryByRole("textbox")).toBeNull(); + const pill = screen.getByRole("button", { name: "palette.open" }); + expect(pill.textContent).toContain("palette.title"); + fireEvent.click(pill); + expect(isOpen()).toBe(true); + }); +}); diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx new file mode 100644 index 00000000..49cfb064 --- /dev/null +++ b/frontend/src/components/PaletteLauncher.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { Command } from "lucide-react"; + +import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap"; + +// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and +// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat +// behind the other undiscoverable chord. The owner's pick (2026-09-19): a key cap where you already +// type, the header's search field; a page with no search field gets the same key cap on a small +// "Jump to…" pill in its place, so the entry is on every page. + +/** The chord the palette row actually binds, read from the table rather than restated. */ +const paletteChord = SHORTCUTS.find((s) => s.id === "palette")?.keys[0] ?? "Mod+k"; + +function useCap(): string { + const { t } = useTranslation(); + return formatChord(paletteChord, { + Space: t("keys.cap.space"), + ContextMenu: t("keys.cap.menu"), + }); +} + +/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it + * too; left out of a phone's narrow header, where there is no keyboard to press it on. */ +export function PaletteKeycap() { + const { t } = useTranslation(); + const cap = useCap(); + return ( + + ); +} + +/** The same entry for a page whose header has no search field. */ +export function PaletteLauncher() { + const { t } = useTranslation(); + const cap = useCap(); + return ( + + ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 423ee1a4..0f41492b 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { Search, X } from "lucide-react"; import { useSearchFocusKey } from "../lib/keymap"; import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { PaletteKeycap } from "./PaletteLauncher"; // The floating search pill in the top header. One component for every module that searches // (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a @@ -100,6 +101,7 @@ export default function SearchBar({ )} + {onGo && ( navigablePages.use(); const paletteOpen = createStore(false); export const closePalette = (): void => paletteOpen.set(false); +/** Open it from a visible control (the header's key cap / launcher), not only from Ctrl/Cmd+K. */ +export const openPalette = (): void => paletteOpen.set(true); export const usePaletteOpen = (): boolean => paletteOpen.use(); /** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside