diff --git a/frontend/src/app/(authenticated)/layout.tsx b/frontend/src/app/(authenticated)/layout.tsx
index e6afc53..6d95f64 100644
--- a/frontend/src/app/(authenticated)/layout.tsx
+++ b/frontend/src/app/(authenticated)/layout.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import dynamic from "next/dynamic";
import AuthGuard from "@/components/AuthGuard";
import Breadcrumbs from "@/components/Breadcrumbs";
+import { CommandPaletteTrigger } from "@/components/CommandPalette";
import LocaleSwitcher from "@/components/LocaleSwitcher";
import PaymentToastListener from "@/components/PaymentToastListener";
import Sidebar from "@/components/Sidebar";
@@ -65,6 +66,7 @@ export default function AuthenticatedLayout({
+
diff --git a/frontend/src/components/CommandPalette.test.tsx b/frontend/src/components/CommandPalette.test.tsx
index 1a5a740..49262f0 100644
--- a/frontend/src/components/CommandPalette.test.tsx
+++ b/frontend/src/components/CommandPalette.test.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import CommandPalette from "./CommandPalette";
+import CommandPalette, { CommandPaletteTrigger, openCommandPalette } from "./CommandPalette";
const pushMock = vi.fn();
const toggleThemeMock = vi.fn();
@@ -51,6 +51,10 @@ function pressGlobalShortcut() {
async function openPalette() {
pressGlobalShortcut();
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
+ // Also wait for the rAF focus, otherwise focus assertions race it.
+ await waitFor(() =>
+ expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true),
+ );
}
describe("CommandPalette", () => {
@@ -428,3 +432,121 @@ describe("CommandPalette", () => {
});
});
});
+
+describe("CommandPalette mobile drawer", () => {
+ function swipe(el: Element, fromY: number, toY: number) {
+ fireEvent.touchStart(el, { touches: [{ clientX: 100, clientY: fromY }] });
+ fireEvent.touchMove(el, { touches: [{ clientX: 100, clientY: toY }] });
+ fireEvent.touchEnd(el, { changedTouches: [{ clientX: 100, clientY: toY }] });
+ }
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ document.body.style.overflow = "";
+ });
+
+ it("opens from the on-screen trigger, which has no keyboard shortcut dependency", async () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ const trigger = screen.getByRole("button", { name: "Open command palette" });
+ expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
+
+ fireEvent.click(trigger);
+ expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
+ });
+
+ it("opens via the openCommandPalette() event helper", async () => {
+ render(
);
+ act(() => openCommandPalette());
+ expect(await screen.findByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("renders a drag handle and a Cancel button for touch users", async () => {
+ render(
);
+ await openPalette();
+
+ expect(screen.getByTestId("command-palette-drawer-handle")).toHaveAttribute("aria-hidden", "true");
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("locks page scroll while open and restores it on close", async () => {
+ render(
);
+ await openPalette();
+ expect(document.body.style.overflow).toBe("hidden");
+
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
+ expect(document.body.style.overflow).toBe("");
+ });
+
+ it("swiping down on the drawer dismisses it", async () => {
+ render(
);
+ await openPalette();
+
+ swipe(screen.getByTestId("command-palette-drawer-handle"), 100, 260);
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("follows the finger while dragging and snaps back on a short, slow drag", async () => {
+ render(
);
+ await openPalette();
+ const dialog = screen.getByRole("dialog");
+ const handle = screen.getByTestId("command-palette-drawer-handle");
+
+ fireEvent.touchStart(handle, { touches: [{ clientX: 100, clientY: 100 }] });
+ fireEvent.touchMove(handle, { touches: [{ clientX: 100, clientY: 130 }] });
+ expect(dialog.style.transform).toContain("30px");
+ expect(dialog).toHaveAttribute("data-dragging", "true");
+
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2000);
+ fireEvent.touchEnd(handle, { changedTouches: [{ clientX: 100, clientY: 130 }] });
+ nowSpy.mockRestore();
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(dialog.style.transform).toBe("");
+ expect(dialog).not.toHaveAttribute("data-dragging");
+ });
+
+ it("does not dismiss when the command list is scrolled and the user drags down", async () => {
+ render(
);
+ await openPalette();
+ const list = screen.getByRole("listbox");
+ list.scrollTop = 120;
+
+ swipe(list.querySelector("li")!, 100, 400);
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("leaves swipe handling to the converter view instead of closing the palette", async () => {
+ render(
);
+ await openPalette();
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "converter" } });
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" });
+ expect(screen.getByText("Asset Converter View")).toBeInTheDocument();
+
+ swipe(screen.getByText("Asset Converter View"), 100, 400);
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("focuses the dialog instead of the input on touch devices to keep the keyboard closed", async () => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: query === "(pointer: coarse)",
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+
+ render(
);
+ pressGlobalShortcut();
+ const dialog = await screen.findByRole("dialog");
+
+ await waitFor(() => expect(dialog).toHaveFocus());
+ expect(screen.getByRole("combobox")).not.toHaveFocus();
+ });
+});
diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx
index e02013a..19dc793 100644
--- a/frontend/src/components/CommandPalette.tsx
+++ b/frontend/src/components/CommandPalette.tsx
@@ -7,6 +7,26 @@ import { useMerchantApiKey } from "@/lib/merchant-store";
import { ThemeMode, useThemeActions, useThemeState } from "@/lib/theme-context";
import { toast } from "sonner";
import { filterPaletteCommands, PaletteCommand } from "@/components/commandPaletteData";
+import { useSwipeToDismiss } from "@/hooks/useSwipeToDismiss";
+
+/**
+ * Window event that opens the palette. Touch devices have no Cmd/Ctrl+K, so
+ * on-screen triggers (e.g. the mobile header search button) dispatch this.
+ */
+export const OPEN_COMMAND_PALETTE_EVENT = "command-palette:open";
+
+export function openCommandPalette() {
+ window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT));
+}
+
+/**
+ * On touch-first devices, focusing the search input on open would pop the
+ * on-screen keyboard over the bottom drawer. There the dialog is focused
+ * instead and the user taps the input when they want to search.
+ */
+function prefersTouchInput() {
+ return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches === true;
+}
const THEME_CYCLE: ThemeMode[] = ["light", "dark", "system"];
@@ -166,6 +186,16 @@ export default function CommandPalette() {
const filtered = useMemo(() => filterPaletteCommands(query), [query]);
+ const close = useCallback(() => setOpen(false), []);
+
+ // Swipe down to dismiss the mobile drawer. Disabled in the converter view,
+ // which handles its own swipe-down (back to the command list).
+ const swipe = useSwipeToDismiss({
+ onDismiss: close,
+ enabled: view === "commands",
+ scrollRef: listRef,
+ });
+
useEffect(() => {
function handleGlobalKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
@@ -174,10 +204,28 @@ export default function CommandPalette() {
}
}
+ function handleOpenRequest() {
+ setOpen(true);
+ }
+
window.addEventListener("keydown", handleGlobalKeydown);
- return () => window.removeEventListener("keydown", handleGlobalKeydown);
+ window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, handleOpenRequest);
+ return () => {
+ window.removeEventListener("keydown", handleGlobalKeydown);
+ window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, handleOpenRequest);
+ };
}, []);
+ // Lock page scroll behind the palette so swipes don't scroll the page.
+ useEffect(() => {
+ if (!open) return;
+ const originalOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = originalOverflow;
+ };
+ }, [open]);
+
useEffect(() => {
if (open) {
setQuery("");
@@ -186,7 +234,9 @@ export default function CommandPalette() {
setExecutingId(null);
// Store the previously focused element to restore focus on close
previousActiveElementRef.current = document.activeElement as HTMLElement;
- requestAnimationFrame(() => inputRef.current?.focus());
+ requestAnimationFrame(() =>
+ (prefersTouchInput() ? dialogRef.current : inputRef.current)?.focus(),
+ );
} else {
// Restore focus to the previously focused element when dialog closes
if (previousActiveElementRef.current) {
@@ -378,9 +428,12 @@ export default function CommandPalette() {
if (!open) return null;
return (
+ // Mobile: bottom drawer. sm and up: centered palette near the top.
setOpen(false)}
+ className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-start sm:pt-[15vh]"
+ style={{ backgroundColor: swipe.offset > 0 ? `rgba(0,0,0,${Math.max(0.2, 0.6 - swipe.offset / 600)})` : undefined }}
+ onClick={close}
+ data-testid="command-palette-backdrop"
>
0 ? `translate3d(0, ${swipe.offset}px, 0)` : undefined }}
+ className={`flex max-h-[85dvh] w-full flex-col rounded-t-[2rem] border-t border-[#1F1F1F] bg-black pb-[env(safe-area-inset-bottom)] shadow-[0_30px_60px_rgba(0,0,0,0.8)] focus:outline-none motion-safe:animate-palette-drawer-in sm:max-h-none sm:max-w-lg sm:overflow-hidden sm:rounded-[2rem] sm:border sm:pb-0 sm:backdrop-blur-xl sm:motion-safe:animate-none ${
+ // AssetConverter is its own fixed bottom drawer on mobile; a clipping or
+ // backdrop-filtered parent would trap it, so the shell steps aside there.
+ view === "converter"
+ ? "max-sm:border-0 max-sm:bg-transparent max-sm:shadow-none"
+ : "overflow-hidden backdrop-blur-xl"
+ } ${swipe.dragging ? "" : "transition-transform duration-200 ease-out motion-reduce:transition-none"}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={handlePaletteKeydown}
+ {...swipe.handlers}
>
{view === "converter" ? (
setView("commands")} />
@@ -401,7 +464,20 @@ export default function CommandPalette() {
Command palette dialog. Use arrow keys to navigate, Enter to select, Escape to close. Tab cycles through focusable elements.
-
+ {/* Mobile drag handle: swipe down to close */}
+
+
+
-
+
{filtered.length === 0 && (
- No matching commands
)}
@@ -451,10 +541,10 @@ export default function CommandPalette() {
aria-selected={index === activeIndex}
aria-busy={isExecuting}
aria-disabled={executingId !== null && !isExecuting}
- className={`flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors ${
+ className={`flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors ${
executingId !== null && !isExecuting
? "cursor-not-allowed opacity-40"
- : "cursor-pointer"
+ : "cursor-pointer active:scale-[0.99] active:bg-white/10"
} ${index === activeIndex ? "bg-accent/10 text-white" : "text-slate-300 hover:bg-white/5"}`}
onMouseEnter={() => {
if (!executingId) setActiveIndex(index);
@@ -497,7 +587,11 @@ export default function CommandPalette() {
})}
-
+
+ Tap a command to run it · swipe down to close
+
+
+
UP/DOWN
navigate
@@ -517,3 +611,24 @@ export default function CommandPalette() {
);
}
+
+/**
+ * On-screen button that opens the palette. Shown in the dashboard header
+ * below `lg`, where a hardware keyboard (and Cmd/Ctrl+K) is unlikely.
+ */
+export function CommandPaletteTrigger({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/__snapshots__/CommandPalette.test.tsx.snap b/frontend/src/components/__snapshots__/CommandPalette.test.tsx.snap
index fdc15ef..d655695 100644
--- a/frontend/src/components/__snapshots__/CommandPalette.test.tsx.snap
+++ b/frontend/src/components/__snapshots__/CommandPalette.test.tsx.snap
@@ -2,13 +2,30 @@
exports[`CommandPalette > matches its dialog structure for the default (no query) state 1`] = `
+
+ Command palette dialog. Use arrow keys to navigate, Enter to select, Escape to close. Tab cycles through focusable elements.
+
+
@@ -55,7 +78,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="true"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer bg-accent/10 text-white"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 bg-accent/10 text-white"
id="cmd-dashboard"
role="option"
>
@@ -105,7 +128,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-settings"
role="option"
>
@@ -150,7 +173,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-docs"
role="option"
>
@@ -195,7 +218,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-create-new-payment"
role="option"
>
@@ -234,7 +257,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-copy-api-key"
role="option"
>
@@ -279,7 +302,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-toggle-theme"
role="option"
>
@@ -319,7 +342,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-api-keys"
role="option"
>
@@ -364,7 +387,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-webhooks"
role="option"
>
@@ -409,7 +432,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-payment-history"
role="option"
>
@@ -448,7 +471,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-help-api-guide"
role="option"
>
@@ -499,7 +522,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-help-hmac-signatures"
role="option"
>
@@ -550,7 +573,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-help-x402-agentic-payments"
role="option"
>
@@ -601,7 +624,7 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
aria-busy="false"
aria-disabled="false"
aria-selected="false"
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer text-slate-300 hover:bg-white/5"
+ class="flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors cursor-pointer active:scale-[0.99] active:bg-white/10 text-slate-300 hover:bg-white/5"
id="cmd-asset-converter"
role="option"
>
@@ -644,8 +667,13 @@ exports[`CommandPalette > matches its dialog structure for the default (no query
+
+ Tap a command to run it · swipe down to close
+
{
+ it("dismisses past the distance threshold", () => {
+ expect(shouldDismissSwipe(SWIPE_DISMISS_DISTANCE_PX, 1000)).toBe(true);
+ });
+
+ it("dismisses a short, fast flick", () => {
+ expect(shouldDismissSwipe(40, 50)).toBe(true);
+ });
+
+ it("does not dismiss a short, slow drag", () => {
+ expect(shouldDismissSwipe(40, 1000)).toBe(false);
+ });
+
+ it("never dismisses upward movement", () => {
+ expect(shouldDismissSwipe(-200, 10)).toBe(false);
+ expect(shouldDismissSwipe(0, 10)).toBe(false);
+ });
+});
+
+describe("useSwipeToDismiss", () => {
+ it("tracks the drag offset and dismisses on release past the threshold", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss({ onDismiss }));
+
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(0, 150)));
+ expect(result.current.offset).toBe(150);
+ expect(result.current.dragging).toBe(true);
+
+ act(() => result.current.handlers.onTouchEnd(touchEvent(0, 150)));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(result.current.offset).toBe(0);
+ expect(result.current.dragging).toBe(false);
+ });
+
+ it("ignores mostly-horizontal swipes", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss({ onDismiss }));
+
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(200, 30)));
+ act(() => result.current.handlers.onTouchEnd(touchEvent(200, 30)));
+
+ expect(result.current.offset).toBe(0);
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when disabled", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss({ onDismiss, enabled: false }));
+
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(0, 300)));
+ act(() => result.current.handlers.onTouchEnd(touchEvent(0, 300)));
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+
+ it("only drags from scrollable content when it is scrolled to the top", () => {
+ const onDismiss = vi.fn();
+ const scrollEl = document.createElement("div");
+ const child = document.createElement("span");
+ scrollEl.appendChild(child);
+ const scrollRef = { current: scrollEl };
+ const { result } = renderHook(() => useSwipeToDismiss({ onDismiss, scrollRef }));
+
+ scrollEl.scrollTop = 50;
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0, child)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(0, 300, child)));
+ act(() => result.current.handlers.onTouchEnd(touchEvent(0, 300, child)));
+ expect(onDismiss).not.toHaveBeenCalled();
+
+ scrollEl.scrollTop = 0;
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0, child)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(0, 300, child)));
+ act(() => result.current.handlers.onTouchEnd(touchEvent(0, 300, child)));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it("resets on touch cancel without dismissing", () => {
+ const onDismiss = vi.fn();
+ const { result } = renderHook(() => useSwipeToDismiss({ onDismiss }));
+
+ act(() => result.current.handlers.onTouchStart(touchEvent(0, 0)));
+ act(() => result.current.handlers.onTouchMove(touchEvent(0, 200)));
+ act(() => result.current.handlers.onTouchCancel());
+
+ expect(result.current.offset).toBe(0);
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/hooks/useSwipeToDismiss.ts b/frontend/src/hooks/useSwipeToDismiss.ts
new file mode 100644
index 0000000..ccdfc9d
--- /dev/null
+++ b/frontend/src/hooks/useSwipeToDismiss.ts
@@ -0,0 +1,149 @@
+/**
+ * useSwipeToDismiss hook
+ *
+ * Swipe-down-to-dismiss for bottom drawers. The drawer follows the finger
+ * while dragging (via the returned `offset`) and either dismisses or snaps
+ * back on release, based on distance or flick velocity.
+ *
+ * Pass `scrollRef` for drawers with scrollable content: a drag that starts
+ * inside it only moves the drawer when the content is scrolled to the top,
+ * so normal scrolling is never hijacked.
+ *
+ * Gestures must always have a non-gesture alternative (WCAG 2.5.1) such as a
+ * close button or Escape; this hook only adds the touch affordance.
+ *
+ * @example
+ * ```tsx
+ * const { offset, dragging, handlers } = useSwipeToDismiss({ onDismiss: close });
+ * return (
+ * …
+ * );
+ * ```
+ */
+
+import React, { useCallback, useRef, useState } from "react";
+
+/** Downward drag (px) that dismisses on release. */
+export const SWIPE_DISMISS_DISTANCE_PX = 100;
+/** Downward speed (px/ms) that dismisses a short, quick flick. */
+export const SWIPE_DISMISS_VELOCITY = 0.5;
+/** Movement (px) before a touch is locked to one axis. */
+const AXIS_LOCK_PX = 10;
+
+/**
+ * Whether a completed downward drag should dismiss the drawer.
+ */
+export function shouldDismissSwipe(
+ dy: number,
+ durationMs: number,
+ distance = SWIPE_DISMISS_DISTANCE_PX,
+ velocity = SWIPE_DISMISS_VELOCITY,
+): boolean {
+ if (dy <= 0) return false;
+ return dy >= distance || dy / Math.max(durationMs, 1) >= velocity;
+}
+
+interface UseSwipeToDismissOptions {
+ onDismiss: () => void;
+ /** When false, touches are ignored. Defaults to true. */
+ enabled?: boolean;
+ /** Scrollable region inside the drawer; drags there start only at scrollTop 0. */
+ scrollRef?: React.RefObject;
+ distance?: number;
+ velocity?: number;
+}
+
+interface TouchState {
+ x: number;
+ y: number;
+ time: number;
+ axis: "x" | "y" | null;
+ canDrag: boolean;
+}
+
+export function useSwipeToDismiss({
+ onDismiss,
+ enabled = true,
+ scrollRef,
+ distance = SWIPE_DISMISS_DISTANCE_PX,
+ velocity = SWIPE_DISMISS_VELOCITY,
+}: UseSwipeToDismissOptions) {
+ const touch = useRef(null);
+ const [offset, setOffset] = useState(0);
+ const [dragging, setDragging] = useState(false);
+
+ const reset = useCallback(() => {
+ touch.current = null;
+ setOffset(0);
+ setDragging(false);
+ }, []);
+
+ const onTouchStart = useCallback(
+ (event: React.TouchEvent) => {
+ if (!enabled) return;
+ const point = event.touches[0];
+ const scrollEl = scrollRef?.current;
+ const inScroll = scrollEl?.contains(event.target as Node) ?? false;
+ touch.current = {
+ x: point.clientX,
+ y: point.clientY,
+ time: Date.now(),
+ axis: null,
+ canDrag: !inScroll || (scrollEl?.scrollTop ?? 0) <= 0,
+ };
+ },
+ [enabled, scrollRef],
+ );
+
+ const onTouchMove = useCallback((event: React.TouchEvent) => {
+ const state = touch.current;
+ if (!state || !state.canDrag) return;
+
+ const point = event.touches[0];
+ const dx = point.clientX - state.x;
+ const dy = point.clientY - state.y;
+
+ if (!state.axis) {
+ if (Math.abs(dx) < AXIS_LOCK_PX && Math.abs(dy) < AXIS_LOCK_PX) return;
+ state.axis = Math.abs(dy) >= Math.abs(dx) ? "y" : "x";
+ }
+
+ if (state.axis === "y" && dy > 0) {
+ setDragging(true);
+ setOffset(dy);
+ } else if (state.axis === "y") {
+ // Dragging back up past the start point: keep the drawer anchored.
+ setOffset(0);
+ }
+ }, []);
+
+ const onTouchEnd = useCallback(
+ (event: React.TouchEvent) => {
+ const state = touch.current;
+ if (!state) return;
+
+ const dy = event.changedTouches[0].clientY - state.y;
+ const dismiss =
+ state.canDrag &&
+ state.axis === "y" &&
+ shouldDismissSwipe(dy, Date.now() - state.time, distance, velocity);
+
+ reset();
+ if (dismiss) onDismiss();
+ },
+ [distance, velocity, onDismiss, reset],
+ );
+
+ return {
+ /** Current downward drag in px; apply as translateY. */
+ offset,
+ /** True while the user is actively dragging the drawer. */
+ dragging,
+ handlers: {
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel: reset,
+ },
+ };
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 36a7ebb..e629d1e 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -64,6 +64,10 @@ module.exports = {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
},
+ "palette-drawer-in": {
+ from: { transform: "translateY(100%)" },
+ to: { transform: "translateY(0)" },
+ },
"drawer-in": {
from: { transform: "translateY(100%)" },
to: { transform: "translateY(0)" },