diff --git a/src/tests/web-responsive.test.ts b/src/tests/web-responsive.test.ts new file mode 100644 index 000000000..847a7a5e2 --- /dev/null +++ b/src/tests/web-responsive.test.ts @@ -0,0 +1,144 @@ +/** + * Structural tests verifying mobile-responsive CSS classes exist in key web UI components. + * + * These tests read the source files and assert that responsive Tailwind classes + * (md:, sm:, lg:, xl:) and mobile-specific markup are present where expected. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const WEB_ROOT = resolve(import.meta.dirname, '../../web') + +function readComponent(relativePath: string): string { + return readFileSync(resolve(WEB_ROOT, relativePath), 'utf-8') +} + +// ── layout.tsx ────────────────────────────────────────────────────────────── + +test('layout.tsx exports a Viewport with device-width', () => { + const src = readComponent('app/layout.tsx') + assert.ok(src.includes("Viewport"), 'should import Viewport type from next') + assert.ok(src.includes("device-width"), 'should set width to device-width') + assert.ok(src.includes("maximumScale"), 'should set maximumScale for mobile') +}) + +// ── app-shell.tsx ─────────────────────────────────────────────────────────── + +test('app-shell.tsx has a mobile hamburger menu toggle', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-nav-toggle'), 'should have mobile-nav-toggle test id') + assert.ok(src.includes('Menu'), 'should import Menu icon for hamburger') +}) + +test('app-shell.tsx hides desktop sidebar on mobile with md:flex', () => { + const src = readComponent('components/gsd/app-shell.tsx') + // The desktop sidebar wrapper should use hidden + md:flex + assert.ok(src.includes('hidden md:flex'), 'desktop sidebar should be hidden on mobile') +}) + +test('app-shell.tsx has a mobile nav drawer', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-nav-drawer'), 'should have mobile-nav-drawer test id') + assert.ok(src.includes('mobile-nav-overlay'), 'should have mobile-nav-overlay test id') +}) + +test('app-shell.tsx has a mobile milestone drawer', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-milestone-drawer'), 'should have mobile-milestone-drawer test id') + assert.ok(src.includes('mobile-milestone-toggle'), 'should have mobile-milestone-toggle test id') +}) + +test('app-shell.tsx has a mobile bottom bar', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-bottom-bar'), 'should have mobile-bottom-bar test id') +}) + +test('app-shell.tsx header uses responsive padding', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('md:px-4'), 'header should have responsive horizontal padding') +}) + +test('app-shell.tsx hides project label on small screens', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('hidden sm:inline'), 'project label should be hidden on mobile') +}) + +test('app-shell.tsx hides desktop milestone sidebar on mobile', () => { + const src = readComponent('components/gsd/app-shell.tsx') + // The milestone sidebar resize handle should be hidden on mobile + assert.ok( + src.includes('hidden md:flex') || src.includes('hidden md:block'), + 'milestone sidebar should be hidden on mobile', + ) +}) + +// ── sidebar.tsx ────────────────────────────────────────────────────────────── + +test('sidebar.tsx supports a mobile prop', () => { + const src = readComponent('components/gsd/sidebar.tsx') + assert.ok(src.includes('mobile?:'), 'Sidebar should accept a mobile prop') + assert.ok(src.includes('mobile?: boolean'), 'mobile prop should be boolean') +}) + +test('sidebar.tsx has a MobileNavPanel with touch-friendly targets', () => { + const src = readComponent('components/gsd/sidebar.tsx') + assert.ok(src.includes('mobile-nav-panel'), 'should have mobile-nav-panel test id') + assert.ok(src.includes('min-h-[44px]'), 'nav items should have 44px minimum touch target height') +}) + +// ── dashboard.tsx ─────────────────────────────────────────────────────────── + +test('dashboard.tsx has responsive grid for metric cards', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('sm:grid-cols-2'), 'metric grid should stack to 2 cols on sm') + assert.ok(src.includes('xl:grid-cols-4'), 'metric grid should expand to 4 cols on xl') +}) + +test('dashboard.tsx has responsive padding on content area', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('md:p-6'), 'content area should have responsive padding') +}) + +test('dashboard.tsx has responsive header padding', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('md:px-6'), 'dashboard header should have responsive horizontal padding') +}) + +// ── status-bar.tsx ────────────────────────────────────────────────────────── + +test('status-bar.tsx hides branch info on small screens', () => { + const src = readComponent('components/gsd/status-bar.tsx') + // Branch info should be hidden on mobile + assert.ok( + src.includes('hidden sm:flex'), + 'branch info should use hidden sm:flex for responsive display', + ) +}) + +test('status-bar.tsx has responsive text sizing', () => { + const src = readComponent('components/gsd/status-bar.tsx') + assert.ok(src.includes('md:text-xs'), 'status bar should have responsive text size') +}) + +test('status-bar.tsx has responsive gap spacing', () => { + const src = readComponent('components/gsd/status-bar.tsx') + assert.ok(src.includes('md:gap-4'), 'status bar should have responsive gap') +}) + +// ── globals.css ───────────────────────────────────────────────────────────── + +test('globals.css has mobile touch target styles', () => { + const src = readComponent('../web/app/globals.css') + assert.ok(src.includes('max-width: 767px'), 'should have a mobile media query') + assert.ok(src.includes('mobile-touch-target'), 'should define mobile-touch-target class') + assert.ok(src.includes('min-height: 44px'), 'touch targets should be at least 44px') +}) + +test('globals.css has mobile sidebar drawer styles', () => { + const src = readComponent('../web/app/globals.css') + assert.ok(src.includes('mobile-sidebar-drawer'), 'should define mobile-sidebar-drawer class') + assert.ok(src.includes('mobile-sidebar-overlay'), 'should define mobile-sidebar-overlay class') +}) diff --git a/web/app/globals.css b/web/app/globals.css index c87d2c15d..085e0fa3e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -146,6 +146,39 @@ } } +/* ── Mobile responsive: touch targets & safe areas ── */ +@media (max-width: 767px) { + /* Ensure touch targets meet 44px minimum */ + .mobile-touch-target { + min-height: 44px; + min-width: 44px; + } + + /* Mobile overlay for sidebar drawer */ + .mobile-sidebar-overlay { + position: fixed; + inset: 0; + z-index: 40; + background: oklch(0 0 0 / 0.5); + } + + /* Mobile sidebar drawer */ + .mobile-sidebar-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 50; + width: 260px; + transform: translateX(-100%); + transition: transform 200ms ease-out; + } + + .mobile-sidebar-drawer.open { + transform: translateX(0); + } +} + /* ── File viewer: Shiki code blocks ── */ .file-viewer-code pre { margin: 0; diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 8a3202a2b..f5afdf9d0 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next' +import type { Metadata, Viewport } from 'next' import { Geist, Geist_Mono } from 'next/font/google' import { Toaster } from '@/components/ui/sonner' import { ThemeProvider } from '@/components/theme-provider' @@ -36,6 +36,13 @@ export const metadata: Metadata = { }, } +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +} + export default function RootLayout({ children, }: Readonly<{ diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx index 8f3454922..cfe8440d9 100644 --- a/web/components/gsd/app-shell.tsx +++ b/web/components/gsd/app-shell.tsx @@ -2,6 +2,7 @@ import Image from "next/image" import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react" +import { Menu, X } from "lucide-react" import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/gsd/sidebar" import { ShellTerminal } from "@/components/gsd/shell-terminal" import { Dashboard } from "@/components/gsd/dashboard" @@ -57,6 +58,8 @@ function WorkspaceChrome() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [viewRestored, setViewRestored] = useState(false) const [projectsPanelOpen, setProjectsPanelOpen] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false) const workspace = useGSDWorkspaceState() const { refreshBoot } = useGSDWorkspaceActions() @@ -122,8 +125,10 @@ function WorkspaceChrome() { document.title = titleOverride ? `${titleOverride} · ${base}` : base }, [titleOverride, projectLabel]) + // Close mobile nav on view change const handleViewChange = useCallback((view: string) => { setActiveView(view) + setMobileNavOpen(false) }, []) // Listen for cross-component file navigation events (e.g. sidebar task clicks) @@ -232,8 +237,17 @@ function WorkspaceChrome() { return (