Phase 1: mobile responsive foundation

- useMediaQuery hook extracted from CalendarPage inline pattern
- h-screen → h-dvh for mobile address bar viewport fix
- px-6 → px-4 md:px-6 on all page containers/toolbars (14 files)
- Input/Select text-base on mobile to prevent iOS auto-zoom
- Sheet full-width on mobile, max-w-[540px] on sm+
- Button icon size touch-friendly (44px mobile, 40px desktop)
- Tailwind hoverOnlyWhenSupported: true (fixes 157 hover interactions)
- PWA meta tags (apple-mobile-web-app-capable, theme-color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-07 16:51:53 +08:00
parent 36309c2460
commit 1c16df4db0
23 changed files with 58 additions and 44 deletions

View File

@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" />
<title>UMBRA</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -21,7 +21,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
@ -39,7 +39,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);

View File

@ -23,7 +23,7 @@ export default function AdminDashboardPage() {
dashboard ? dashboard.total_users - dashboard.active_users : null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats grid */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
{isLoading ? (

View File

@ -18,7 +18,7 @@ export default function AdminPortal() {
<div className="flex flex-col h-full animate-fade-in">
{/* Portal header with tab navigation */}
<div className="shrink-0 border-b bg-card">
<div className="px-6 h-16 flex items-center gap-4">
<div className="px-4 md:px-6 h-16 flex items-center gap-4">
<div className="flex items-center gap-2 mr-6">
<div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-5 w-5 text-red-400" />

View File

@ -54,7 +54,7 @@ export default function ConfigPage() {
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
<Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">

View File

@ -95,7 +95,7 @@ export default function IAMPage() {
: null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -161,13 +162,7 @@ export default function CalendarPage() {
const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches);
useEffect(() => {
const mql = window.matchMedia('(min-width: 1024px)');
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
const isDesktop = useMediaQuery('(min-width: 1024px)');
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
@ -483,7 +478,7 @@ export default function CalendarPage() {
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" />

View File

@ -86,13 +86,13 @@ export default function DashboardPage() {
if (isLoading) {
return (
<div className="flex flex-col h-full">
<div className="px-6 py-6">
<div className="px-4 md:px-6 py-6">
<div className="animate-pulse space-y-2">
<div className="h-8 w-48 rounded bg-muted" />
<div className="h-4 w-32 rounded bg-muted" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<DashboardSkeleton />
</div>
</div>
@ -110,7 +110,7 @@ export default function DashboardPage() {
return (
<div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */}
<div className="px-6 pt-6 pb-2 flex items-center justify-between">
<div className="px-4 md:px-6 pt-6 pb-2 flex items-center justify-between">
<div>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)}
@ -156,7 +156,7 @@ export default function DashboardPage() {
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<div className="space-y-5">
{/* Week Timeline */}
{upcomingData && (

View File

@ -22,7 +22,7 @@ export default function AppLayout() {
<LockProvider>
<AlertsProvider>
<NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background">
<div className="flex h-dvh overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {

View File

@ -285,7 +285,7 @@ export default function LocationsPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
<div className="flex-1 min-w-0">
@ -320,7 +320,7 @@ export default function LocationsPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 pb-6 pt-5">
<div className="px-4 md:px-6 pb-6 pt-5">
{isLoading ? (
<EntityTable<Location>
columns={columns}

View File

@ -162,7 +162,7 @@ export default function NotificationsPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Page header */}
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
@ -227,7 +227,7 @@ export default function NotificationsPage() {
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
'flex items-start gap-3 px-4 md:px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
!notification.is_read && 'bg-card'
)}
>

View File

@ -555,7 +555,7 @@ export default function PeoplePage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<div className="flex-1 min-w-0">
<CategoryFilterBar
@ -622,7 +622,7 @@ export default function PeoplePage() {
<div className="flex-1 overflow-hidden flex flex-col">
{/* Stat bar */}
{!isLoading && people.length > 0 && (
<div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
<div className="px-4 md:px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
<div className="flex gap-6 shrink-0">
<StatCounter
icon={Users}
@ -665,7 +665,7 @@ export default function PeoplePage() {
{/* Pending requests */}
{hasRequests && (
<div className="px-6 pb-3">
<div className="px-4 md:px-6 pb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests
@ -706,7 +706,7 @@ export default function PeoplePage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 pb-6">
<div className="px-4 md:px-6 pb-6">
{isLoading ? (
<EntityTable<Person>
columns={columns}

View File

@ -345,13 +345,13 @@ export default function ProjectDetail() {
if (isLoading) {
return (
<div className="flex flex-col h-full animate-fade-in">
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
<ListSkeleton rows={4} />
</div>
</div>
@ -375,7 +375,7 @@ export default function ProjectDetail() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
@ -417,7 +417,7 @@ export default function ProjectDetail() {
{/* Content area */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Summary section - scrolls with left panel on small, fixed on large */}
<div className="px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
<div className="px-4 md:px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
{/* Description */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
@ -490,7 +490,7 @@ export default function ProjectDetail() {
</div>
{/* Task list header + view controls */}
<div className="px-6 pb-3 flex items-center justify-between shrink-0">
<div className="px-4 md:px-6 pb-3 flex items-center justify-between shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2">
{/* View toggle */}
@ -544,7 +544,7 @@ export default function ProjectDetail() {
<div className="flex-1 overflow-hidden flex">
{/* Left panel: task list or kanban */}
<div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
<div className="px-6 pb-6">
<div className="px-4 md:px-6 pb-6">
{topLevelTasks.length === 0 ? (
<EmptyState
icon={ListChecks}

View File

@ -70,7 +70,7 @@ export default function ProjectsPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
@ -111,7 +111,7 @@ export default function ProjectsPage() {
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
{/* Summary stats */}
{!isLoading && projects.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -99,7 +99,7 @@ export default function RemindersPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
@ -148,7 +148,7 @@ export default function RemindersPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
<div className="px-4 md:px-6 py-5">
{/* Summary stats */}
{!isLoading && reminders.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -344,7 +344,7 @@ export default function SettingsPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-3 shrink-0">
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Settings</h1>
</div>

View File

@ -128,7 +128,7 @@ export default function TodosPage() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */}
@ -183,7 +183,7 @@ export default function TodosPage() {
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
<div className="px-4 md:px-6 py-5">
{/* Summary stats */}
{!isLoading && todos.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">

View File

@ -18,7 +18,7 @@ const buttonVariants = cva(
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
icon: 'h-11 w-11 md:h-10 md:w-10',
},
},
defaultVariants: {

View File

@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@ -10,7 +10,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative">
<select
className={cn(
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
'flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
className
)}
ref={ref}

View File

@ -60,7 +60,7 @@ const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
role="dialog"
aria-modal="true"
className={cn(
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-350',
'fixed right-0 top-0 h-full w-full sm:max-w-[540px] transition-transform duration-350',
visible ? 'translate-x-0' : 'translate-x-full'
)}
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}

View File

@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}

View File

@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss';
export default {
future: { hoverOnlyWhenSupported: true },
darkMode: ['class'],
content: [
'./index.html',