diff --git a/docs/plans/2026-03-25-mobile-bottom-sheet-design.md b/docs/plans/2026-03-25-mobile-bottom-sheet-design.md new file mode 100644 index 0000000..d5a6295 --- /dev/null +++ b/docs/plans/2026-03-25-mobile-bottom-sheet-design.md @@ -0,0 +1,75 @@ +# Mobile Bottom Sheet Design + +## Overview + +On mobile (<640px), the chat widget renders as a full-width bottom sheet instead of a floating card. Desktop behavior is unchanged. + +## Behavior + +- Slides up from bottom to ~90vh height +- Dark scrim backdrop behind sheet (tap scrim to close) +- Drag handle bar at top of sheet +- Swipe-to-close: velocity > 500px/s OR drag distance > 30% of sheet height +- `prefers-reduced-motion`: opacity fade instead of slide animation + +## Implementation + +### Files to change + +1. **`widget/src/hooks/useSwipeToDismiss.ts`** (new) - vanilla JS touch handler +2. **`widget/src/components/ChatWindow.tsx`** - bottom sheet layout on mobile +3. **`widget/src/components/ChatWidget.tsx`** - render scrim backdrop on mobile +4. **`widget/src/styles.css`** - transitions, scrim, reduced-motion + +### Hook: useSwipeToDismiss + +```typescript +useSwipeToDismiss(ref: RefObject, onDismiss: () => void): { offsetY: number } +``` + +- `touchstart`: record Y position +- `touchmove`: calculate delta, apply `transform: translateY()` directly (no CSS transition during drag) +- `touchend`: if velocity > 500px/s OR distance > 30% of sheet height, call onDismiss. Otherwise snap back. +- Passive touch listeners for performance +- No-op when `window.innerWidth >= 640` + +### ChatWindow changes + +- Below 640px: `fixed inset-x-0 bottom-0 h-[90vh] w-full rounded-t-2xl` +- Add drag handle div (centered horizontal bar) at top +- Remove floating-card positioning classes on mobile +- Apply `translateY` offset from swipe hook + +### ChatWidget changes + +- Render scrim `
` behind ChatWindow when open on mobile +- Scrim: `fixed inset-0 bg-black/50` with fade transition +- Tap scrim calls `handleToggle` + +### CSS transitions + +```css +.claudius-bottom-sheet { + transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.claudius-scrim { + transition: opacity 300ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .claudius-bottom-sheet { + transition: opacity 200ms ease; + transform: none !important; + } + .claudius-scrim { + transition: opacity 150ms ease; + } +} +``` + +### Gesture details + +- Only vertical swipe (ignore horizontal movement > 10px) +- Resist upward drag (dampen by 0.3x when dragging above origin) +- No scroll interference: only activate swipe when sheet is scrolled to top diff --git a/docs/plans/2026-03-25-mobile-bottom-sheet.md b/docs/plans/2026-03-25-mobile-bottom-sheet.md new file mode 100644 index 0000000..5508c3b --- /dev/null +++ b/docs/plans/2026-03-25-mobile-bottom-sheet.md @@ -0,0 +1,665 @@ +# Mobile Bottom Sheet Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** On mobile (<640px), render the chat as a full-width bottom sheet with swipe-to-close gesture and reduced-motion support. + +**Architecture:** CSS transitions for slide/fade animations, vanilla JS touch handlers in a custom hook, media-query-based conditional rendering in ChatWidget/ChatWindow. No new dependencies. + +**Tech Stack:** React, TypeScript, Tailwind CSS, CSS transitions, Touch Events API + +--- + +### Task 1: useSwipeToDismiss hook - tests + +**Files:** +- Create: `widget/src/hooks/__tests__/useSwipeToDismiss.test.ts` + +**Step 1: Write tests for the hook** + +```typescript +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useSwipeToDismiss } from "../useSwipeToDismiss"; + +describe("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 }; + }; + + it("returns offsetY of 0 initially", () => { + const ref = createRef(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, vi.fn(), true) + ); + expect(result.current.offsetY).toBe(0); + }); + + it("does not attach listeners when disabled", () => { + const ref = createRef(); + const spy = vi.spyOn(ref.current, "addEventListener"); + renderHook(() => useSwipeToDismiss(ref, vi.fn(), false)); + expect(spy).not.toHaveBeenCalled(); + }); + + it("tracks vertical drag distance", () => { + const ref = createRef(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, vi.fn(), true) + ); + + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchstart", { + touches: [{ clientY: 300 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchmove", { + touches: [{ clientY: 400, clientX: 300 } as Touch], + }) + ); + }); + + expect(result.current.offsetY).toBe(100); + }); + + it("calls onDismiss when dragged past 30% threshold", () => { + const ref = createRef(); + const onDismiss = vi.fn(); + renderHook(() => useSwipeToDismiss(ref, onDismiss, true)); + + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchstart", { + touches: [{ clientY: 100 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchmove", { + touches: [{ clientY: 300, clientX: 0 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent(new TouchEvent("touchend", { touches: [] })); + }); + + expect(onDismiss).toHaveBeenCalled(); + }); + + it("snaps back when drag is below threshold", () => { + const ref = createRef(); + const onDismiss = vi.fn(); + const { result } = renderHook(() => + useSwipeToDismiss(ref, onDismiss, true) + ); + + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchstart", { + touches: [{ clientY: 300 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchmove", { + touches: [{ clientY: 320, clientX: 300 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent(new TouchEvent("touchend", { touches: [] })); + }); + + expect(onDismiss).not.toHaveBeenCalled(); + expect(result.current.offsetY).toBe(0); + }); + + it("does not activate swipe when scrollTop > 0", () => { + const ref = createRef(100); + const { result } = renderHook(() => + useSwipeToDismiss(ref, vi.fn(), true) + ); + + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchstart", { + touches: [{ clientY: 300 } as Touch], + }) + ); + }); + act(() => { + ref.current.dispatchEvent( + new TouchEvent("touchmove", { + touches: [{ clientY: 400, clientX: 300 } as Touch], + }) + ); + }); + + expect(result.current.offsetY).toBe(0); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd widget && pnpm test -- --run src/hooks/__tests__/useSwipeToDismiss.test.ts` +Expected: FAIL - module not found + +**Step 3: Commit** + +```bash +git add widget/src/hooks/__tests__/useSwipeToDismiss.test.ts +git commit -m "test: add useSwipeToDismiss hook tests" +``` + +--- + +### Task 2: useSwipeToDismiss hook - implementation + +**Files:** +- Create: `widget/src/hooks/useSwipeToDismiss.ts` + +**Step 1: Implement the hook** + +```typescript +import { useEffect, useRef, useState, useCallback, type RefObject } from "react"; + +export function useSwipeToDismiss( + sheetRef: RefObject, + onDismiss: () => void, + enabled: boolean +) { + const [offsetY, setOffsetY] = useState(0); + const startY = useRef(0); + const startTime = useRef(0); + const tracking = useRef(false); + + const reset = useCallback(() => { + setOffsetY(0); + tracking.current = false; + }, []); + + useEffect(() => { + const el = sheetRef.current; + if (!el || !enabled) return; + + const onTouchStart = (e: TouchEvent) => { + // Only start swipe when content is scrolled to top + if (el.scrollTop > 0) return; + startY.current = e.touches[0].clientY; + startTime.current = Date.now(); + tracking.current = true; + }; + + const onTouchMove = (e: TouchEvent) => { + if (!tracking.current) return; + const currentY = e.touches[0].clientY; + const deltaY = currentY - startY.current; + + // Ignore horizontal swipes + const deltaX = (e.touches[0].clientX ?? 0) - (e.touches[0].clientX ?? 0); + // We only care about downward drag + if (deltaY < 0) { + // Resist upward drag + setOffsetY(deltaY * 0.3); + } else { + setOffsetY(deltaY); + } + }; + + const onTouchEnd = () => { + if (!tracking.current) { + reset(); + return; + } + + const elapsed = Date.now() - startTime.current; + const velocity = Math.abs(offsetY) / (elapsed || 1) * 1000; + const sheetHeight = el.getBoundingClientRect().height; + const distanceRatio = offsetY / sheetHeight; + + if (velocity > 500 || distanceRatio > 0.3) { + onDismiss(); + } + + reset(); + }; + + el.addEventListener("touchstart", onTouchStart, { passive: true }); + el.addEventListener("touchmove", onTouchMove, { passive: true }); + el.addEventListener("touchend", onTouchEnd); + + return () => { + el.removeEventListener("touchstart", onTouchStart); + el.removeEventListener("touchmove", onTouchMove); + el.removeEventListener("touchend", onTouchEnd); + }; + }, [sheetRef, onDismiss, enabled, offsetY, reset]); + + return { offsetY }; +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `cd widget && pnpm test -- --run src/hooks/__tests__/useSwipeToDismiss.test.ts` +Expected: PASS + +**Step 3: Commit** + +```bash +git add widget/src/hooks/useSwipeToDismiss.ts +git commit -m "feat: add useSwipeToDismiss hook for mobile bottom sheet" +``` + +--- + +### Task 3: useMediaQuery hook - tests and implementation + +**Files:** +- Create: `widget/src/hooks/useMediaQuery.ts` +- Create: `widget/src/hooks/__tests__/useMediaQuery.test.ts` + +**Step 1: Write test** + +```typescript +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); + }); +}); +``` + +**Step 2: Implement the hook** + +```typescript +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; +} +``` + +**Step 3: Run tests** + +Run: `cd widget && pnpm test -- --run src/hooks/__tests__/useMediaQuery.test.ts` +Expected: PASS + +**Step 4: Commit** + +```bash +git add widget/src/hooks/useMediaQuery.ts widget/src/hooks/__tests__/useMediaQuery.test.ts +git commit -m "feat: add useMediaQuery hook" +``` + +--- + +### Task 4: CSS transitions and reduced-motion styles + +**Files:** +- Modify: `widget/src/styles.css` + +**Step 1: Add bottom sheet CSS** + +Add to `widget/src/styles.css` after the Tailwind directives: + +```css +@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; + } +} +``` + +**Step 2: Commit** + +```bash +git add widget/src/styles.css +git commit -m "feat: add bottom sheet and reduced-motion CSS transitions" +``` + +--- + +### Task 5: ChatWindow bottom sheet layout + +**Files:** +- Modify: `widget/src/components/ChatWindow.tsx` + +**Step 1: Update ChatWindow to accept isMobile prop and render bottom sheet layout** + +Changes to `ChatWindow.tsx`: + +1. Add `isMobile` prop to `ChatWindowProps` +2. Import and use `useSwipeToDismiss` +3. Conditionally apply bottom sheet classes when `isMobile` is true +4. Add drag handle element when mobile +5. Apply `translateY` from swipe hook +6. Pass `messagesContainerRef` to swipe hook (only swipe when scrolled to top) + +The outer `
` should change from: + +```tsx +
+``` + +To conditionally render based on `isMobile`: + +```tsx +const sheetRef = useRef(null); +const { offsetY } = useSwipeToDismiss(sheetRef, onClose, isMobile); +const isDragging = offsetY !== 0; + +const reducedMotion = typeof window !== "undefined" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; + +const sheetStyle: React.CSSProperties | undefined = isMobile && !reducedMotion + ? { transform: `translateY(${Math.max(0, offsetY)}px)` } + : undefined; + +// ... + +
+ {/* Drag handle (mobile only) */} + {isMobile && ( + +``` + +**Step 2: Run all widget tests** + +Run: `cd widget && pnpm test` +Expected: All existing tests PASS (they don't pass `isMobile`, so it defaults to `false` and the component behaves as before) + +**Step 3: Commit** + +```bash +git add widget/src/components/ChatWindow.tsx +git commit -m "feat: add bottom sheet layout to ChatWindow for mobile" +``` + +--- + +### Task 6: ChatWidget scrim and mobile detection + +**Files:** +- Modify: `widget/src/components/ChatWidget.tsx` + +**Step 1: Add mobile detection and scrim backdrop** + +Changes to `ChatWidget.tsx`: + +1. Import `useMediaQuery` +2. Detect mobile: `const isMobile = useMediaQuery("(max-width: 639px)")` +3. Render scrim backdrop when open on mobile +4. Pass `isMobile` to `ChatWindow` +5. Keep `ChatWindow` always mounted (for animation), control visibility with state + +```tsx +import { useMediaQuery } from "../hooks/useMediaQuery"; + +// Inside ChatWidget: +const isMobile = useMediaQuery("(max-width: 639px)"); + +// In JSX, replace the existing {isOpen && } with: + +{/* Scrim backdrop (mobile only) */} +{isOpen && isMobile && ( +