From eaf25091b0c13364b4cf859d170f15284638c34a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:23:25 +0000 Subject: [PATCH 1/2] feat(mobile): swipe between Recent and My Space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tabs were the only way to change what the feed shows, and they sit at the top of the screen — the one place a thumb cannot comfortably reach. A horizontal drag anywhere across the list now moves between them. The gesture follows the finger rather than waiting for the release: the pill sits between the two tabs as the drag crosses, and the list gives way with a damped offset that resists harder once there is no further tab to reveal. Releasing commits on distance or on velocity, so a short confident flick works as well as a long deliberate drag. None of it goes through React state. Both the pill's position and the list's offset are CSS custom properties written straight to the DOM on every pointer move — the same approach the aside's resize handle takes — because a render per frame would re-render every card in the feed to move a pill. React still owns the resting values, so pressing a tab animates through the very same property a swipe drives: one animation, two ways to ask for it. Vertical drags are left alone. The surface keeps `touch-action: pan-y`, so the browser scrolls the list natively and cancels our pointer, and the recogniser locks to whichever axis the finger commits to first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FkPLWajNT3W8tgVoyBiUq9 --- e2e/helpers.ts | 28 ++++ e2e/mobile.spec.ts | 130 ++++++++++++++++ src/__tests__/tabSwipe.test.ts | 179 +++++++++++++++++++++++ src/components/MobileHome/FeedTabs.tsx | 32 ++-- src/components/MobileHome/MobileHome.tsx | 34 ++++- src/hooks/useSwipeTabs.ts | 163 +++++++++++++++++++++ src/lib/tabSwipe.ts | 144 ++++++++++++++++++ 7 files changed, 686 insertions(+), 24 deletions(-) create mode 100644 e2e/mobile.spec.ts create mode 100644 src/__tests__/tabSwipe.test.ts create mode 100644 src/hooks/useSwipeTabs.ts create mode 100644 src/lib/tabSwipe.ts diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c4e5b84..2c54984 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -19,3 +19,31 @@ export function aside(page: Page): Locator { export function asideRow(page: Page, name: string): Locator { return aside(page).getByRole("treeitem", { name, exact: true }); } + +/** + * Drags horizontally across an element, in `steps` moves of `dx / steps` px. + * + * `pauseMs` between the moves is what controls the gesture's *velocity*, which + * the swipe recogniser weighs alongside distance: without a pause Playwright + * replays the whole drag in a couple of milliseconds, and every swipe — however + * short — reads as a flick. + */ +export async function dragHorizontally( + page: Page, + target: Locator, + { dx, steps = 8, pauseMs = 0 }: { dx: number; steps?: number; pauseMs?: number }, +) { + const box = await target.boundingBox(); + if (!box) throw new Error("Swipe target has no bounding box"); + + const startX = box.x + box.width / 2; + const y = box.y + Math.min(box.height / 2, 160); + + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let step = 1; step <= steps; step++) { + await page.mouse.move(startX + (dx * step) / steps, y); + if (pauseMs) await page.waitForTimeout(pauseMs); + } + await page.mouse.up(); +} diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts new file mode 100644 index 0000000..f045949 --- /dev/null +++ b/e2e/mobile.spec.ts @@ -0,0 +1,130 @@ +import { test, expect, type Page } from "@playwright/test"; +import { dragHorizontally } from "./helpers"; + +/** + * The touch layout's feed, which below `lg` replaces the aside entirely. + * + * Driven with the mouse rather than `page.touchscreen`: the gesture listens to + * pointer events, which both devices raise, and Playwright's touchscreen API + * cannot express a drag. What matters — the axis lock, the distance and + * velocity thresholds, the suppressed click — is identical either way. + */ +test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + +/** Waits for the mobile home rather than the aside `gotoApp` looks for. */ +async function gotoMobileApp(page: Page) { + await page.goto("/app"); + await expect(page.getByRole("tab", { name: "Recent" })).toBeVisible(); +} + +function panel(page: Page) { + return page.getByRole("tabpanel"); +} + +async function expectSelected(page: Page, name: "Recent" | "My Space") { + await expect(page.getByRole("tab", { name })).toHaveAttribute("aria-selected", "true"); +} + +test("swipes between Recent and My Space, and back", async ({ page }) => { + await gotoMobileApp(page); + await expectSelected(page, "Recent"); + + await dragHorizontally(page, panel(page), { dx: -220 }); + await expectSelected(page, "My Space"); + // The seeded folder's row only exists in the structure tab, so the panel + // really did change and not just the pill. + await expect(panel(page).getByRole("button", { name: /welcome/ })).toBeVisible(); + + await dragHorizontally(page, panel(page), { dx: 220 }); + await expectSelected(page, "Recent"); +}); + +test("the switcher follows the finger before the swipe is released", async ({ page }) => { + await gotoMobileApp(page); + + const box = await panel(page).boundingBox(); + if (!box) throw new Error("Panel has no bounding box"); + const startX = box.x + box.width / 2; + const y = box.y + 120; + + const main = page.getByRole("main"); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(startX - 40, y); + await page.mouse.move(startX - 120, y); + + // Mid-gesture the pill sits between the two tabs rather than waiting for the + // release, and its settle animation is suspended so it tracks the finger. + await expect(main).toHaveAttribute("data-swiping", "true"); + const progress = await main.evaluate((el) => + Number(getComputedStyle(el).getPropertyValue("--tab-progress")), + ); + expect(progress).toBeGreaterThan(0); + expect(progress).toBeLessThan(1); + + await page.mouse.up(); + await expect(main).not.toHaveAttribute("data-swiping", "true"); + await expectSelected(page, "My Space"); +}); + +test("a swipe that falls short springs back", async ({ page }) => { + await gotoMobileApp(page); + + // Under the commit distance, and slow enough not to count as a flick. + await dragHorizontally(page, panel(page), { dx: -60, pauseMs: 40 }); + await expectSelected(page, "Recent"); +}); + +test("a short flick still commits", async ({ page }) => { + await gotoMobileApp(page); + + await dragHorizontally(page, panel(page), { dx: -60 }); + await expectSelected(page, "My Space"); +}); + +test("swiping past the last tab stays there", async ({ page }) => { + await gotoMobileApp(page); + + // Right from the first tab: there is nothing before Recent to reveal. + await dragHorizontally(page, panel(page), { dx: 260 }); + await expectSelected(page, "Recent"); +}); + +test("a vertical drag scrolls the feed instead of changing tab", async ({ page }) => { + await gotoMobileApp(page); + + const box = await panel(page).boundingBox(); + if (!box) throw new Error("Panel has no bounding box"); + const x = box.x + box.width / 2; + await page.mouse.move(x, box.y + 120); + await page.mouse.down(); + // Mostly vertical, with the sideways wobble a real thumb adds. + for (let step = 1; step <= 8; step++) { + await page.mouse.move(x + step, box.y + 120 - step * 12); + } + await page.mouse.up(); + + await expectSelected(page, "Recent"); +}); + +test("swiping off a snippet card does not open the snippet", async ({ page }) => { + await gotoMobileApp(page); + + const card = page.getByRole("button", { name: "klipcode.md", exact: true }).first(); + await expect(card).toBeVisible(); + + await dragHorizontally(page, card, { dx: -220 }); + + await expectSelected(page, "My Space"); + await expect(page).toHaveURL(/\/app$/); +}); + +test("the tab buttons still switch on their own", async ({ page }) => { + await gotoMobileApp(page); + + await page.getByRole("tab", { name: "My Space" }).click(); + await expectSelected(page, "My Space"); + + await page.getByRole("tab", { name: "Recent" }).click(); + await expectSelected(page, "Recent"); +}); diff --git a/src/__tests__/tabSwipe.test.ts b/src/__tests__/tabSwipe.test.ts new file mode 100644 index 0000000..fca604f --- /dev/null +++ b/src/__tests__/tabSwipe.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; + +import { + applyTabSwipe, + contentShift, + detectSwipeAxis, + resolveSwipe, + setTabSwiping, + SWIPE_AXIS_SLOP, + SWIPE_COMMIT_RATIO, + SWIPE_COMMIT_VELOCITY, + TAB_PROGRESS_VAR, + TAB_SHIFT_VAR, + tabProgress, +} from "@/lib/tabSwipe"; + +/** The mobile feed: two tabs across a phone-width panel. */ +const WIDTH = 390; +const COUNT = 2; + +/** Distance that is unambiguously past the commit threshold. */ +const FAR = WIDTH * SWIPE_COMMIT_RATIO + 1; +/** Slow enough that only distance can commit the swipe. */ +const SLOW = 2000; + +// ── detectSwipeAxis() ───────────────────────────────────────────────────────── + +describe("detectSwipeAxis()", () => { + it("stays undecided until the finger has cleared the slop", () => { + expect(detectSwipeAxis(SWIPE_AXIS_SLOP - 1, 0)).toBeNull(); + expect(detectSwipeAxis(0, SWIPE_AXIS_SLOP - 1)).toBeNull(); + expect(detectSwipeAxis(0, 0)).toBeNull(); + }); + + it("commits to the dominant axis once it has", () => { + expect(detectSwipeAxis(40, 5)).toBe("horizontal"); + expect(detectSwipeAxis(-40, 5)).toBe("horizontal"); + expect(detectSwipeAxis(5, 40)).toBe("vertical"); + expect(detectSwipeAxis(5, -40)).toBe("vertical"); + }); + + it("gives a tie to the scroll, which is what a list is expected to do", () => { + expect(detectSwipeAxis(30, 30)).toBe("vertical"); + }); + + it("measures the slop on the axis that is actually moving", () => { + // A long vertical drag with a 2px horizontal wobble is a scroll. + expect(detectSwipeAxis(2, 200)).toBe("vertical"); + }); +}); + +// ── tabProgress() ───────────────────────────────────────────────────────────── + +describe("tabProgress()", () => { + it("moves toward the next tab as the finger goes left", () => { + expect(tabProgress(0, -WIDTH / 2, WIDTH, COUNT)).toBeCloseTo(0.5); + expect(tabProgress(0, -WIDTH, WIDTH, COUNT)).toBeCloseTo(1); + }); + + it("moves toward the previous tab as the finger goes right", () => { + expect(tabProgress(1, WIDTH / 4, WIDTH, COUNT)).toBeCloseTo(0.75); + }); + + it("never leaves the range of tabs that exist", () => { + expect(tabProgress(0, WIDTH, WIDTH, COUNT)).toBe(0); + expect(tabProgress(1, -WIDTH * 3, WIDTH, COUNT)).toBe(COUNT - 1); + }); + + it("holds still for a degenerate surface", () => { + expect(tabProgress(1, -200, 0, COUNT)).toBe(1); + expect(tabProgress(0, -200, WIDTH, 1)).toBe(0); + }); +}); + +// ── contentShift() ──────────────────────────────────────────────────────────── + +describe("contentShift()", () => { + it("follows the finger, damped, in the same direction", () => { + const shift = contentShift(0, -100, WIDTH, COUNT); + expect(shift).toBeLessThan(0); + expect(Math.abs(shift)).toBeLessThan(100); + }); + + it("resists much harder when there is no tab to reveal", () => { + const free = Math.abs(contentShift(0, -100, WIDTH, COUNT)); + const againstTheEdge = Math.abs(contentShift(0, 100, WIDTH, COUNT)); + expect(againstTheEdge).toBeGreaterThan(0); + expect(againstTheEdge).toBeLessThan(free); + }); + + it("caps the follow however far the finger goes", () => { + const far = Math.abs(contentShift(0, -5000, WIDTH, COUNT)); + const further = Math.abs(contentShift(0, -50_000, WIDTH, COUNT)); + expect(far).toBe(further); + expect(far).toBeLessThanOrEqual(56); + }); + + it("does not move at all without a gesture, or without a surface", () => { + expect(contentShift(0, 0, WIDTH, COUNT)).toBe(0); + expect(contentShift(0, -100, 0, COUNT)).toBe(0); + }); +}); + +// ── resolveSwipe() ──────────────────────────────────────────────────────────── + +describe("resolveSwipe()", () => { + it("moves to the next tab on a long drag left", () => { + expect(resolveSwipe(0, -FAR, SLOW, WIDTH, COUNT)).toBe(1); + }); + + it("moves back on a long drag right", () => { + expect(resolveSwipe(1, FAR, SLOW, WIDTH, COUNT)).toBe(0); + }); + + it("springs back when a slow drag falls short", () => { + const short = WIDTH * SWIPE_COMMIT_RATIO - 1; + expect(resolveSwipe(0, -short, SLOW, WIDTH, COUNT)).toBe(0); + }); + + it("commits a short drag that was flicked", () => { + const short = WIDTH * SWIPE_COMMIT_RATIO - 1; + const quick = short / (SWIPE_COMMIT_VELOCITY * 2); + expect(resolveSwipe(0, -short, quick, WIDTH, COUNT)).toBe(1); + }); + + it("ignores a flick too small to be a gesture at all", () => { + // Below the axis slop nothing was ever painted, so nothing may commit. + expect(resolveSwipe(0, -(SWIPE_AXIS_SLOP - 1), 1, WIDTH, COUNT)).toBe(0); + }); + + it("stays put at the ends", () => { + expect(resolveSwipe(0, FAR, SLOW, WIDTH, COUNT)).toBe(0); + expect(resolveSwipe(1, -FAR, SLOW, WIDTH, COUNT)).toBe(1); + }); + + it("can still commit when the surface has no measurable width", () => { + expect(resolveSwipe(0, -100, 10, WIDTH, COUNT)).toBe(1); + }); + + it("does nothing with fewer than two tabs", () => { + expect(resolveSwipe(0, -FAR, SLOW, WIDTH, 1)).toBe(0); + }); +}); + +// ── painting ────────────────────────────────────────────────────────────────── + +describe("applyTabSwipe() / setTabSwiping()", () => { + /** A stand-in for the container element: only `style` and `dataset` matter. */ + function fakeElement() { + const properties = new Map(); + return { + dataset: {} as Record, + style: { + setProperty: (name: string, value: string) => void properties.set(name, value), + }, + read: (name: string) => properties.get(name), + }; + } + + it("writes both custom properties, the shift in px", () => { + const el = fakeElement(); + applyTabSwipe(el as unknown as HTMLElement, 0.25, -12.5); + expect(el.read(TAB_PROGRESS_VAR)).toBe("0.25"); + expect(el.read(TAB_SHIFT_VAR)).toBe("-12.5px"); + }); + + it("adds and removes the flag that suspends the settle animation", () => { + const el = fakeElement(); + setTabSwiping(el as unknown as HTMLElement, true); + expect(el.dataset.swiping).toBe("true"); + setTabSwiping(el as unknown as HTMLElement, false); + expect("swiping" in el.dataset).toBe(false); + }); + + it("tolerates an element that is not mounted", () => { + expect(() => applyTabSwipe(null, 1, 0)).not.toThrow(); + expect(() => setTabSwiping(null, true)).not.toThrow(); + }); +}); diff --git a/src/components/MobileHome/FeedTabs.tsx b/src/components/MobileHome/FeedTabs.tsx index c7f850a..e2e1bc4 100644 --- a/src/components/MobileHome/FeedTabs.tsx +++ b/src/components/MobileHome/FeedTabs.tsx @@ -1,5 +1,6 @@ "use client"; +import { SWIPE_TRANSITION, TAB_PROGRESS_VAR } from "@/lib/tabSwipe"; import { cn } from "@/lib/utils"; /** @@ -16,14 +17,14 @@ import { cn } from "@/lib/utils"; * segment is exactly half, the pill moves by its own width, and its inner copy * counter-slides by half of its own doubled width. No refs, no measuring, no * resize observer. + * + * The pill's position is not read from `active` but from {@link + * TAB_PROGRESS_VAR}, inherited from the ancestor `useSwipeTabs` writes to. A + * press moves that property between whole numbers and the transition below + * animates the gap; a swipe moves it continuously and suspends the transition. + * One pill, one animation, two ways to ask for it. */ -/** Tailwind v4 compiles `translate-x-full` to the `translate` property, not to - * `transform` — transitioning the wrong one leaves the pill snapping. Kept as - * classes rather than an inline style so `motion-reduce` can still win. */ -const SLIDE = - "transition-[translate] duration-[420ms] ease-[cubic-bezier(0.34,1.4,0.64,1)] motion-reduce:transition-none"; - /** The three stacked copies of the row must agree on their columns exactly. */ const ROW = "flex h-11 items-center"; const CELL = "flex-1 truncate px-3 text-center text-[13.5px] font-medium"; @@ -47,13 +48,8 @@ export function FeedTabs({ panelId: string; onSelect: (id: T) => void; }) { - const shifted = active === tabs[1].id; - return ( -
+