From 820237b4810f216d97ce024a39a3cef80512ac19 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Mon, 2 Feb 2026 18:46:57 -0800 Subject: [PATCH 1/3] feat(MobileGate): add MobileGate component and associated tests - Introduced the MobileGate component to inform users that the application isn't optimized for mobile yet, featuring a waitlist button. - Implemented comprehensive tests for the MobileGate component, covering rendering, button behavior, and accessibility. - Updated RootView to conditionally render MobileGate for mobile users, enhancing user experience across devices. --- .../components/MobileGate/MobileGate.test.tsx | 80 +++++++++++++++++ .../src/components/MobileGate/MobileGate.tsx | 88 +++++++++++++++++++ packages/web/src/views/Root.tsx | 8 ++ 3 files changed, 176 insertions(+) create mode 100644 packages/web/src/components/MobileGate/MobileGate.test.tsx create mode 100644 packages/web/src/components/MobileGate/MobileGate.tsx diff --git a/packages/web/src/components/MobileGate/MobileGate.test.tsx b/packages/web/src/components/MobileGate/MobileGate.test.tsx new file mode 100644 index 000000000..89e11372f --- /dev/null +++ b/packages/web/src/components/MobileGate/MobileGate.test.tsx @@ -0,0 +1,80 @@ +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { render } from "@web/__tests__/__mocks__/mock.render"; +import { MobileGate } from "./MobileGate"; + +describe("MobileGate", () => { + const mockWindowOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + window.open = mockWindowOpen; + }); + + describe("Component Rendering", () => { + it("renders the title message", () => { + render(); + + expect( + screen.getByText("Compass isn't built for mobile yet"), + ).toBeInTheDocument(); + }); + + it("renders the descriptive message", () => { + render(); + + expect( + screen.getByText( + /We're focusing on perfecting the web experience first/, + ), + ).toBeInTheDocument(); + }); + + it("renders the Join Mobile Waitlist button", () => { + render(); + + const waitlistButton = screen.getByRole("button", { + name: /join mobile waitlist/i, + }); + expect(waitlistButton).toBeInTheDocument(); + }); + }); + + describe("Waitlist Button Behavior", () => { + it("opens waitlist URL in new tab when clicked", async () => { + const user = userEvent.setup(); + render(); + + const waitlistButton = screen.getByRole("button", { + name: /join mobile waitlist/i, + }); + await user.click(waitlistButton); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + expect(mockWindowOpen).toHaveBeenCalledWith( + "https://tylerdane.kit.com/compass-mobile", + "_blank", + "noopener,noreferrer", + ); + }); + }); + + describe("Accessibility", () => { + it("has proper button role", () => { + render(); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("Join Mobile Waitlist"); + }); + + it("renders heading for the title", () => { + render(); + + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent("Compass isn't built for mobile yet"); + }); + }); +}); diff --git a/packages/web/src/components/MobileGate/MobileGate.tsx b/packages/web/src/components/MobileGate/MobileGate.tsx new file mode 100644 index 000000000..91e578e69 --- /dev/null +++ b/packages/web/src/components/MobileGate/MobileGate.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import styled from "styled-components"; + +const WAITLIST_URL = "https://tylerdane.kit.com/compass-mobile"; + +const Container = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + min-height: 100dvh; + background-color: ${({ theme }) => theme.color.bg.primary}; + padding: ${({ theme }) => theme.spacing.m}; +`; + +const Card = styled.div` + background-color: ${({ theme }) => theme.color.bg.secondary}; + border: 1px solid ${({ theme }) => theme.color.border.primary}; + border-radius: ${({ theme }) => theme.shape.borderRadius}; + padding: ${({ theme }) => theme.spacing.xl}; + width: 400px; + max-width: 90vw; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +`; + +const Title = styled.h1` + font-family: "Rubik", sans-serif; + font-size: 24px; + font-weight: 500; + color: ${({ theme }) => theme.color.common.white}; + margin: 0 0 ${({ theme }) => theme.spacing.l}; +`; + +const Message = styled.p` + font-family: "Rubik", sans-serif; + font-size: 16px; + font-weight: 400; + color: #a0a0a0; + margin: 0 0 ${({ theme }) => theme.spacing.xl}; + line-height: 1.6; +`; + +const WaitlistButton = styled.button` + font-family: "Rubik", sans-serif; + font-size: 16px; + font-weight: 500; + min-height: 44px; + padding: ${({ theme }) => theme.spacing.s} ${({ theme }) => theme.spacing.xl}; + background-color: ${({ theme }) => theme.color.text.accent}; + color: ${({ theme }) => theme.color.common.white}; + border: none; + border-radius: ${({ theme }) => theme.shape.borderRadius}; + cursor: pointer; + transition: opacity ${({ theme }) => theme.transition.default}; + + &:hover { + opacity: 0.9; + } + + &:focus { + outline: 2px solid ${({ theme }) => theme.color.text.accent}; + outline-offset: 2px; + } +`; + +export const MobileGate: React.FC = () => { + const handleJoinWaitlist = () => { + window.open(WAITLIST_URL, "_blank", "noopener,noreferrer"); + }; + + return ( + + + Compass isn't built for mobile yet + + We're focusing on perfecting the web experience first. Join our + mobile waitlist to be the first to know when we launch. + + + Join Mobile Waitlist + + + + ); +}; diff --git a/packages/web/src/views/Root.tsx b/packages/web/src/views/Root.tsx index 2a3a949da..0c0659c55 100644 --- a/packages/web/src/views/Root.tsx +++ b/packages/web/src/views/Root.tsx @@ -1,8 +1,16 @@ import { UserProvider } from "@web/auth/context/UserProvider"; +import { useIsMobile } from "@web/common/hooks/useIsMobile"; import { AuthenticatedLayout } from "@web/components/AuthenticatedLayout/AuthenticatedLayout"; +import { MobileGate } from "@web/components/MobileGate/MobileGate"; import SocketProvider from "@web/socket/provider/SocketProvider"; export const RootView = () => { + const isMobile = useIsMobile(); + + if (isMobile) { + return ; + } + return ( From f7ff420edd70d0f420d283822f30209df2a91457 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Mon, 2 Feb 2026 18:59:25 -0800 Subject: [PATCH 2/3] refactor(useIsMobile): optimize initial mobile state detection - Introduced a synchronous method to determine the initial mobile state, preventing unnecessary API calls during the first render on mobile devices. - Updated the useState hook to initialize with the new method, ensuring consistent mobile state detection. --- packages/web/src/common/hooks/useIsMobile.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/web/src/common/hooks/useIsMobile.ts b/packages/web/src/common/hooks/useIsMobile.ts index 28962119d..d77c6ded9 100644 --- a/packages/web/src/common/hooks/useIsMobile.ts +++ b/packages/web/src/common/hooks/useIsMobile.ts @@ -2,19 +2,31 @@ import { useEffect, useState } from "react"; const MOBILE_BREAKPOINT = "(max-width: 768px)"; +/** + * Get initial mobile state synchronously + * This prevents unnecessary API calls on mobile devices during first render + */ +const getInitialMobileState = (): boolean => { + if (typeof window === "undefined") { + return false; + } + return window.matchMedia(MOBILE_BREAKPOINT).matches; +}; + /** * Hook to detect if the current viewport is mobile-sized * Uses window.matchMedia with a 768px breakpoint * @returns boolean indicating if viewport is mobile-sized */ export const useIsMobile = (): boolean => { - const [isMobile, setIsMobile] = useState(false); + // Initialize synchronously to prevent unnecessary provider mounts on mobile + const [isMobile, setIsMobile] = useState(getInitialMobileState); useEffect(() => { // Create media query object once const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT); - // Check initial state + // Update state if it changed (shouldn't happen on first render, but ensures consistency) setIsMobile(mediaQuery.matches); // Create listener From 4b1706079f9ce1e74c3e4e42ff79bbfb37aec977 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Mon, 2 Feb 2026 19:14:20 -0800 Subject: [PATCH 3/3] feat(e2e): skip OAuth overlay tests on mobile due to MobileGate interference - Updated OAuth overlay tests to skip execution on mobile devices, as the MobileGate component prevents the overlay from rendering. - Simplified the test for blurring the active element by removing mobile-specific conditions, ensuring consistent behavior across devices. - Enhanced MobileGate component styling using Tailwind CSS for improved responsiveness and visual consistency. --- e2e/oauth/oauth-overlay.spec.ts | 39 +++++---- .../src/components/MobileGate/MobileGate.tsx | 87 +++---------------- 2 files changed, 34 insertions(+), 92 deletions(-) diff --git a/e2e/oauth/oauth-overlay.spec.ts b/e2e/oauth/oauth-overlay.spec.ts index c7ba4b635..569719d98 100644 --- a/e2e/oauth/oauth-overlay.spec.ts +++ b/e2e/oauth/oauth-overlay.spec.ts @@ -22,8 +22,17 @@ import { * The overlay shows two phases: * 1. OAuth phase: "Complete Google sign-in..." - when isSyncing=true, importing=false * 2. Import phase: "Importing your Google Calendar..." - when importing=true + * + * NOTE: These tests are skipped on mobile because the MobileGate component + * blocks the entire app on mobile viewports, preventing the OAuth overlay + * from ever being rendered. This is intentional product behavior. */ test.describe("OAuth Overlay", () => { + // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders + test.skip( + ({ isMobile }) => isMobile, + "OAuth overlay not available on mobile", + ); test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); await page.goto("/"); @@ -89,25 +98,11 @@ test.describe("OAuth Overlay", () => { await expectBodyLocked(page, false); }); - test("blurs active element when overlay activates", async ({ - page, - isMobile, - }) => { - // On mobile, the main grid might not be visible - use a different focusable element - // or skip for mobile as the blur behavior is the same regardless of viewport - if (isMobile) { - // On mobile, find any focusable element that's visible - const focusable = page - .locator("button:visible, [tabindex]:visible") - .first(); - await focusable.waitFor({ state: "visible", timeout: 10000 }); - await focusable.focus(); - } else { - // Wait for main grid to be visible and focusable on desktop - const mainGrid = page.locator("#mainGrid"); - await mainGrid.waitFor({ state: "visible", timeout: 10000 }); - await mainGrid.focus(); - } + test("blurs active element when overlay activates", async ({ page }) => { + // Wait for main grid to be visible and focusable + const mainGrid = page.locator("#mainGrid"); + await mainGrid.waitFor({ state: "visible", timeout: 10000 }); + await mainGrid.focus(); await page.waitForTimeout(100); // Give time for focus to settle @@ -201,6 +196,12 @@ test.describe("OAuth Overlay", () => { }); test.describe("OAuth Overlay - Edge Cases", () => { + // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders + test.skip( + ({ isMobile }) => isMobile, + "OAuth overlay not available on mobile", + ); + test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); await page.goto("/"); diff --git a/packages/web/src/components/MobileGate/MobileGate.tsx b/packages/web/src/components/MobileGate/MobileGate.tsx index 91e578e69..bc1c48e9f 100644 --- a/packages/web/src/components/MobileGate/MobileGate.tsx +++ b/packages/web/src/components/MobileGate/MobileGate.tsx @@ -1,88 +1,29 @@ import React from "react"; -import styled from "styled-components"; const WAITLIST_URL = "https://tylerdane.kit.com/compass-mobile"; -const Container = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - min-height: 100dvh; - background-color: ${({ theme }) => theme.color.bg.primary}; - padding: ${({ theme }) => theme.spacing.m}; -`; - -const Card = styled.div` - background-color: ${({ theme }) => theme.color.bg.secondary}; - border: 1px solid ${({ theme }) => theme.color.border.primary}; - border-radius: ${({ theme }) => theme.shape.borderRadius}; - padding: ${({ theme }) => theme.spacing.xl}; - width: 400px; - max-width: 90vw; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -`; - -const Title = styled.h1` - font-family: "Rubik", sans-serif; - font-size: 24px; - font-weight: 500; - color: ${({ theme }) => theme.color.common.white}; - margin: 0 0 ${({ theme }) => theme.spacing.l}; -`; - -const Message = styled.p` - font-family: "Rubik", sans-serif; - font-size: 16px; - font-weight: 400; - color: #a0a0a0; - margin: 0 0 ${({ theme }) => theme.spacing.xl}; - line-height: 1.6; -`; - -const WaitlistButton = styled.button` - font-family: "Rubik", sans-serif; - font-size: 16px; - font-weight: 500; - min-height: 44px; - padding: ${({ theme }) => theme.spacing.s} ${({ theme }) => theme.spacing.xl}; - background-color: ${({ theme }) => theme.color.text.accent}; - color: ${({ theme }) => theme.color.common.white}; - border: none; - border-radius: ${({ theme }) => theme.shape.borderRadius}; - cursor: pointer; - transition: opacity ${({ theme }) => theme.transition.default}; - - &:hover { - opacity: 0.9; - } - - &:focus { - outline: 2px solid ${({ theme }) => theme.color.text.accent}; - outline-offset: 2px; - } -`; - export const MobileGate: React.FC = () => { const handleJoinWaitlist = () => { window.open(WAITLIST_URL, "_blank", "noopener,noreferrer"); }; return ( - - - Compass isn't built for mobile yet - +
+
+

+ Compass isn't built for mobile yet +

+

We're focusing on perfecting the web experience first. Join our mobile waitlist to be the first to know when we launch. - - +

+ +
+
); };