From 0b4d20f3e1a361205e6038eaf93293c99d65b39a Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 25 Mar 2026 14:17:16 -0400 Subject: [PATCH 1/9] test: add useSwipeToDismiss hook tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hooks/__tests__/useSwipeToDismiss.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 widget/src/hooks/__tests__/useSwipeToDismiss.test.ts diff --git a/widget/src/hooks/__tests__/useSwipeToDismiss.test.ts b/widget/src/hooks/__tests__/useSwipeToDismiss.test.ts new file mode 100644 index 0000000..06f4dee --- /dev/null +++ b/widget/src/hooks/__tests__/useSwipeToDismiss.test.ts @@ -0,0 +1,115 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useSwipeToDismiss } from "../useSwipeToDismiss"; + +const createRef = (scrollTop = 0) => { + const el = document.createElement("div"); + Object.defineProperty(el, "scrollTop", { value: scrollTop, writable: true }); + Object.defineProperty(el, "getBoundingClientRect", { + value: () => ({ height: 600 }), + }); + return { current: el }; +}; + +const fireTouch = ( + el: HTMLElement, + type: "touchstart" | "touchmove" | "touchend", + clientY: number +) => { + const event = new TouchEvent(type, { + touches: type === "touchend" ? [] : [{ clientY } as Touch], + changedTouches: [{ clientY } as Touch], + }); + el.dispatchEvent(event); +}; + +describe("useSwipeToDismiss", () => { + let onDismiss: ReturnType; + + beforeEach(() => { + onDismiss = vi.fn(); + }); + + it("returns offsetY of 0 initially", () => { + const ref = createRef(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + expect(result.current.offsetY).toBe(0); + }); + + it("does not attach listeners when disabled", () => { + const ref = createRef(); + const addSpy = vi.spyOn(ref.current, "addEventListener"); + + renderHook(() => + useSwipeToDismiss(ref, onDismiss, false) + ); + + expect(addSpy).not.toHaveBeenCalled(); + }); + + it("tracks vertical drag distance", () => { + const ref = createRef(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + act(() => { + fireTouch(ref.current, "touchstart", 300); + fireTouch(ref.current, "touchmove", 400); + }); + + expect(result.current.offsetY).toBe(100); + }); + + it("calls onDismiss when dragged past 30% threshold", () => { + const ref = createRef(); + renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + // 30% of 600 = 180, so dragging 200 should exceed threshold + act(() => { + fireTouch(ref.current, "touchstart", 200); + fireTouch(ref.current, "touchmove", 400); + fireTouch(ref.current, "touchend", 400); + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("snaps back when drag is below threshold", () => { + const ref = createRef(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + // 30% of 600 = 180, so dragging 100 should snap back + act(() => { + fireTouch(ref.current, "touchstart", 300); + fireTouch(ref.current, "touchmove", 400); + fireTouch(ref.current, "touchend", 400); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(result.current.offsetY).toBe(0); + }); + + it("does not activate swipe when scrollTop > 0", () => { + const ref = createRef(50); + const { result } = renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + act(() => { + fireTouch(ref.current, "touchstart", 200); + fireTouch(ref.current, "touchmove", 500); + fireTouch(ref.current, "touchend", 500); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(result.current.offsetY).toBe(0); + }); +}); From 70f0e139c2711d4dd6a7ede5d093797416ec65e4 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 25 Mar 2026 14:20:47 -0400 Subject: [PATCH 2/9] feat: add useSwipeToDismiss hook for mobile bottom sheet Co-Authored-By: Claude Opus 4.6 (1M context) --- widget/src/hooks/useSwipeToDismiss.ts | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 widget/src/hooks/useSwipeToDismiss.ts diff --git a/widget/src/hooks/useSwipeToDismiss.ts b/widget/src/hooks/useSwipeToDismiss.ts new file mode 100644 index 0000000..57a8749 --- /dev/null +++ b/widget/src/hooks/useSwipeToDismiss.ts @@ -0,0 +1,73 @@ +import { RefObject, useEffect, useRef, useState } from "react"; + +export function useSwipeToDismiss( + sheetRef: RefObject, + onDismiss: () => void, + enabled: boolean +): { offsetY: number } { + const [offsetY, setOffsetY] = useState(0); + const offsetYRef = useRef(0); + const startYRef = useRef(0); + const startTimeRef = useRef(0); + const trackingRef = useRef(false); + + useEffect(() => { + const el = sheetRef.current; + if (!enabled || !el) return; + + const handleTouchStart = (e: TouchEvent) => { + if (el.scrollTop > 0) { + trackingRef.current = false; + return; + } + trackingRef.current = true; + startYRef.current = e.touches[0].clientY; + startTimeRef.current = Date.now(); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!trackingRef.current) return; + + let deltaY = e.touches[0].clientY - startYRef.current; + + // Dampen upward drag + if (deltaY < 0) { + deltaY = deltaY * 0.3; + } + + offsetYRef.current = deltaY; + setOffsetY(deltaY); + }; + + const handleTouchEnd = () => { + if (!trackingRef.current) return; + trackingRef.current = false; + + const currentOffsetY = offsetYRef.current; + const elapsed = (Date.now() - startTimeRef.current) / 1000; + const height = el.getBoundingClientRect().height; + const isFastFlick = + elapsed > 0.05 && currentOffsetY > 0 && currentOffsetY / elapsed > 500; + const isLongDrag = currentOffsetY > height * 0.3; + + if (isFastFlick || isLongDrag) { + onDismiss(); + } else { + offsetYRef.current = 0; + setOffsetY(0); + } + }; + + el.addEventListener("touchstart", handleTouchStart, { passive: true }); + el.addEventListener("touchmove", handleTouchMove, { passive: true }); + el.addEventListener("touchend", handleTouchEnd); + + return () => { + el.removeEventListener("touchstart", handleTouchStart); + el.removeEventListener("touchmove", handleTouchMove); + el.removeEventListener("touchend", handleTouchEnd); + }; + }, [sheetRef, onDismiss, enabled]); + + return { offsetY }; +} From 7aa44496d028e1738e0567bcf597228e29347a47 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 25 Mar 2026 14:24:26 -0400 Subject: [PATCH 3/9] feat: add useMediaQuery hook Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/hooks/__tests__/useMediaQuery.test.ts | 35 +++++++++++++++++++ widget/src/hooks/useMediaQuery.ts | 19 ++++++++++ 2 files changed, 54 insertions(+) create mode 100644 widget/src/hooks/__tests__/useMediaQuery.test.ts create mode 100644 widget/src/hooks/useMediaQuery.ts diff --git a/widget/src/hooks/__tests__/useMediaQuery.test.ts b/widget/src/hooks/__tests__/useMediaQuery.test.ts new file mode 100644 index 0000000..15b64b0 --- /dev/null +++ b/widget/src/hooks/__tests__/useMediaQuery.test.ts @@ -0,0 +1,35 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useMediaQuery } from "../useMediaQuery"; + +describe("useMediaQuery", () => { + let listeners: Array<(e: { matches: boolean }) => void>; + + beforeEach(() => { + listeners = []; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => { + listeners.push(cb); + }, + removeEventListener: vi.fn(), + })), + }); + }); + + it("returns initial match state", () => { + const { result } = renderHook(() => useMediaQuery("(max-width: 639px)")); + expect(result.current).toBe(false); + }); + + it("updates when media query changes", () => { + const { result } = renderHook(() => useMediaQuery("(max-width: 639px)")); + act(() => { + listeners.forEach((cb) => cb({ matches: true })); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/widget/src/hooks/useMediaQuery.ts b/widget/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..f91d325 --- /dev/null +++ b/widget/src/hooks/useMediaQuery.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from "react"; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + const mq = window.matchMedia(query); + setMatches(mq.matches); + + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [query]); + + return matches; +} From 83c4845ad487af755bb8e00a3c34a19734f5cd41 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 25 Mar 2026 14:25:28 -0400 Subject: [PATCH 4/9] feat: add bottom sheet and reduced-motion CSS transitions Co-Authored-By: Claude Opus 4.6 (1M context) --- widget/src/styles.css | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/widget/src/styles.css b/widget/src/styles.css index b5c61c9..8f502e1 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -1,3 +1,29 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Bottom sheet slide-up animation */ +.claudius-bottom-sheet { + transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.claudius-bottom-sheet[data-dragging="true"] { + transition: none; +} + +/* Scrim fade */ +.claudius-scrim { + transition: opacity 300ms ease; +} + +/* Reduced motion: replace slide with opacity, skip transforms */ +@media (prefers-reduced-motion: reduce) { + .claudius-bottom-sheet { + transition: opacity 200ms ease; + transform: none !important; + } + + .claudius-scrim { + transition: opacity 150ms ease; + } +} From b60772531019238a815b453a4424ae9fabf6fe8c Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 25 Mar 2026 14:29:00 -0400 Subject: [PATCH 5/9] feat: add bottom sheet layout to ChatWindow for mobile Co-Authored-By: Claude Opus 4.6 (1M context) --- widget/src/components/ChatWindow.tsx | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/widget/src/components/ChatWindow.tsx b/widget/src/components/ChatWindow.tsx index a21e79b..477d81c 100644 --- a/widget/src/components/ChatWindow.tsx +++ b/widget/src/components/ChatWindow.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { MessageBubble } from "./MessageBubble"; import { ChatInput } from "./ChatInput"; import { ChatSources } from "./ChatSources"; +import { useSwipeToDismiss } from "../hooks/useSwipeToDismiss"; import type { WidgetPosition } from "./ChatWidget"; import type { ClaudiusTranslations } from "../i18n"; import type { Source } from "../api/types"; @@ -25,6 +26,7 @@ interface ChatWindowProps { placeholder?: string; position?: WidgetPosition; translations?: ClaudiusTranslations; + isMobile?: boolean; } function TypingIndicator() { @@ -58,10 +60,18 @@ export function ChatWindow({ placeholder, position = "bottom-right", translations, + isMobile = false, }: ChatWindowProps) { const messagesContainerRef = useRef(null); const [activeSources, setActiveSources] = useState<{ messageId: string; sources: Source[] } | null>(null); + const { offsetY } = useSwipeToDismiss(messagesContainerRef, onClose, isMobile); + const isDragging = offsetY !== 0; + const reducedMotion = + typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; + useEffect(() => { const container = messagesContainerRef.current; if (container) { @@ -74,8 +84,24 @@ export function ChatWindow({ return (
+ {isMobile && ( +