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