From db8ed5a0042a4ca1f142cd82f32e78caa513b50f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 17:58:39 +0200 Subject: [PATCH] feat(ui): a visible way into the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl/Cmd+K and the shortcut sheet had no entry point but a palette row, behind the other undiscoverable chord. Owner's pick from the S7 mockups (2026-09-19): a key cap at the end of the header's search field opens the palette, and a page with no search field shows a small "Jump to…" pill with the same key cap in its place, so the entry is on every page. The cap reads the chord the palette row binds from the keymap table (Ctrl K, or ⌘ K on a Mac) and stays out of a phone's header, where there is no keyboard to press it on. --- frontend/src/components/Header.tsx | 4 +- .../components/PaletteLauncher.dom.test.tsx | 73 +++++++++++++++++++ frontend/src/components/PaletteLauncher.tsx | 62 ++++++++++++++++ frontend/src/components/SearchBar.tsx | 2 + frontend/src/i18n/locales/en/palette.json | 3 +- frontend/src/i18n/locales/hu/palette.json | 3 +- frontend/src/lib/keymap.ts | 2 + 7 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/PaletteLauncher.dom.test.tsx create mode 100644 frontend/src/components/PaletteLauncher.tsx 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